补齐第3-4章composer功能缺口:跨话题草稿/@提醒/表情面板/附件面板/名片/收藏发送/按住说话/语音未读红点
第3-4章复测确认这批原型图要求的功能之前完全没做(真实聊天引擎 messages-view.jsx/
send-message.jsx 从Ch1开始一直是最基础的版本:两个附件图标+文本框+发送/麦克风,
没有@、没有表情面板、没有7宫格附件面板、没有名片/收藏发送、语音是点一下进入录音模式
不是按住说话)。这次一次性把8项都接上,除了新功能外,过程中顺带发现并修了4个真bug。
**跨话题草稿**(im-api.js 新增 getTopicDraft/updateTopicDraft,messages-view.jsx)
话题切换时拉取 GET /topics/{topic}/draft(真实验证过P2P用原生话题名——对方user_id——
可以正常读写,接口内部自己会查Subs.Get校验,不需要额外归一化),输入框内容600ms防抖
PUT回去;真正发送成功后立即清空服务端草稿,不等下一次防抖。真实验证:输入文字→切走
再切回→草稿还在;刷新整个页面(新WS会话)→草稿依然从服务端正确恢复。
**@提醒**(send-message.jsx 新增 mention 相关状态和UI,messages-view.jsx 新增
mentionSearch/mentionedUsers/applyMentionResolution)
只在群聊开放,检测"光标前最近一个未闭合的@"触发面板,调 getGroupMembers(topic,
{mentionable:true, query}) 做实时搜索。协议层面:Tinode原生Drafty.parse本来就会把
纯文本里的"@word"自动识别成MN实体,但data.val存的是匹配到的原始文字,不是uid——
真正的uid只有用户从选择器里点选时才知道,记在 messages-view.jsx 的
this.mentionedUsers,发送前重新parse一遍把匹配上的占位符换成真uid。真实验证:选
"kcg051307"发送后直接查后端存储,MN实体的data.val确认是真实uid
"usrYVeV0yZs8tw",不是原始文字。
**表情面板**(send-message.jsx)
2个tab(😊表情/♡常用),常用表情本地localStorage维护。图片没有拍到第二个tab具体
内容,判断"常用"是这类两tab设计里最常见、也是本项目里唯一有本地存储依据能撑起来的
语义。真实验证:点表情正确插入文本框,用过的表情正确出现在"常用"tab。
**7宫格附件面板**(send-message.jsx,替换原来常驻的照片/文件两个图标)
照片/拍摄/语音通话/名片/文件/收藏/视频通话。语音通话、视频通话复用第8章已有的
handleStartAudioCall/handleStartVideoCall(从 tinode-web.jsx 新抽出 canStartCall()
方法,同时替换掉"..."菜单里原来的内联判断,避免同一个判断逻辑两处维护),仅P2P且
真实通话功能可用时才不置灰——真实验证过群聊里这两项正确显示为禁用状态。
**名片消息**(send-message.jsx openCardPicker/sendCard,复用原生TheCard/
Drafty.appendTheCard)
选好友生成名片,uid必须用TheCard.setTinodeID写进vCard的comm数组(不是随便塞进某个
字段——widgets/the-card-mini.jsx渲染时用TheCard.getFirstTinodeID读这个协议项来判断
"发消息"按钮指向谁)。真实验证:选中好友后发出的名片消息真实带"聊天"按钮,点击可用。
**收藏发送到当前会话**(send-message.jsx openFavPicker/sendFavorite,复用Ch7的
forwardFavorites接口)
真实联调中发现并修复一个新bug:这个接口和其它几个批量接口一样,HTTP层恒定200,
真实成败要看items[].status——原来的写法完全没检查这个字段,等于假装总是成功。已改成
检查失败项并报错。同时真实测试还发现该接口对P2P目标会返回200+items[0].status:
"failed", reason:"PERMISSION_DENIED"(哪怕是真实好友、这个会话本身发消息完全正常),
看起来是后端这个接口自己的写权限判断有问题,不是前端能修的,已记录。
**按住说话+上滑取消录音**(send-message.jsx新增手势处理,audio-recorder.jsx新增
holdMode展示态)
mousedown/touchstart直接挂载AudioRecorder(挂载即开始录音,复用原有机制不动),
移动超过80px判定为"松开取消",松手时通过ref直接调组件内部的handleDone/handleDelete。
过程中发现并修复3个真bug:① injectIntl包裹AudioRecorder默认不支持ref转发
("Function components cannot be given refs"警告,ref静默失效),加
{forwardRef:true}修好;② handleDelete从来没有像handleDone那样判空mediaRecorder,
录音还没真正开始(getUserMedia尚未resolve)就点删除会直接抛TypeError,这是原有代码
一直存在的问题,这次因为按住松手可能在录音刚开始的瞬间发生而被真实触发;③
cleanUp()同理没判空this.stream。补上判空后,"从未真正开始录音就结束"这个场景会
直接当作取消处理并正确调用onDeleted,不会再把UI卡在录音态出不来(真实验证:这个
环境的浏览器面板本身会拦截麦克风访问,用这个"getUserMedia永远拒绝"的真实场景反而
完整测出了正常/取消两条路径在录音真正开始前结束时都不再卡死或报错)。
**语音消息未读红点**(chat-message.jsx/audio-player.jsx/formatters.js)
纯客户端概念,原生Tinode的recv/read只到"消息本身有没有被看到",跟"这条语音有没有
真的被点开听过"是两回事。audio-player.jsx的播放按钮新增可选onPlay回调(不传不影响
现有调用方),formatters.js的AU分支透传给它,chat-message.jsx按话题维护一份
localStorage"已播放seq集合",对收到的(非自己发的)语音消息,没播放过就在气泡右上角
显示红点。真实验证:用原生SDK的_routeData往话题本地缓存注入一条真实结构的语音消息
(浏览器面板拦截麦克风,没有真实语音消息可用,用这个方式验证渲染/播放回调/持久化
三段逻辑),红点正确出现;直接触发播放回调后红点消失且localStorage正确写入对应seq。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
abef778c7f
commit
505a6d7d56
+199
@@ -3695,11 +3695,173 @@ img.channel {
|
||||
}
|
||||
|
||||
#send-message-wrapper {
|
||||
position: relative;
|
||||
background-color: var(--clr-background-blue-gray-50);
|
||||
box-shadow: 0px -2px 3px -1px var(--clr-shadow-panel-divider); /* Faint shadow above panel */
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#mention-picker {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 100%;
|
||||
margin-bottom: 0.3rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: 0 -0.2rem 0.6rem rgba(0,0,0,0.15);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.mention-picker-row {
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mention-picker-row.active,
|
||||
.mention-picker-row:hover {
|
||||
background: var(--clr-background-blue-gray-50);
|
||||
}
|
||||
|
||||
.mention-picker-empty {
|
||||
padding: 0.6rem 0.9rem;
|
||||
color: var(--clr-text-tertiary);
|
||||
}
|
||||
|
||||
#emoji-panel, #attach-panel {
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--clr-shadow-panel-divider);
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#emoji-panel-tabs {
|
||||
display: flex;
|
||||
padding: 0.4rem 0.6rem 0;
|
||||
}
|
||||
|
||||
#emoji-panel-tabs a {
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 1.3rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#emoji-panel-tabs a.active {
|
||||
border-bottom-color: var(--clr-primary-accent);
|
||||
}
|
||||
|
||||
#emoji-panel-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
#emoji-panel-grid a {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emoji-panel-empty {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--clr-text-tertiary);
|
||||
}
|
||||
|
||||
#attach-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 0.6rem;
|
||||
}
|
||||
|
||||
#attach-panel a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--clr-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#attach-panel a .material-icons {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--clr-background-blue-gray-50);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#attach-panel a.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.send-message-picker-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background: rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.send-message-picker-backdrop > section {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 0.6rem 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.send-message-picker-backdrop header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--clr-shadow-panel-divider);
|
||||
}
|
||||
|
||||
.send-message-picker-backdrop header a {
|
||||
color: var(--clr-text-tertiary);
|
||||
}
|
||||
|
||||
.send-message-picker-list {
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.send-message-picker-list a {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--clr-text-primary);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-message-picker-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--clr-text-tertiary);
|
||||
}
|
||||
|
||||
#sendMessage {
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
@@ -4214,6 +4376,18 @@ video {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.voice-unread-dot {
|
||||
position: absolute;
|
||||
top: -0.15rem;
|
||||
right: -0.15rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #e3444d;
|
||||
border: 1.5px solid var(--clr-background-primary, #fff);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -5257,6 +5431,31 @@ ul.tabbar li.active > a {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.audio-hold {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.audio-hold canvas {
|
||||
width: 6rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -71,6 +71,11 @@ export function fullFormatter(style, data, values, key, stack) {
|
||||
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
|
||||
attr.preview = data.preview;
|
||||
attr.loading = 'lazy';
|
||||
// 4.9 语音未读红点:chat-message.jsx 的 onVoicePlayed 记录"这条语音真的被点开听过",
|
||||
// 跟消息本身有没有被标记已读是两件事。favorites/history 等别处复用同一个 formatter
|
||||
// context 没有这个字段时,attr.onPlay 就是 undefined,AudioPlayer 那边本来就把它当
|
||||
// 可选 prop 处理,不会因此出问题。
|
||||
attr.onPlay = this.onVoicePlayed;
|
||||
}
|
||||
el = AudioPlayer;
|
||||
// Audio element cannot have content.
|
||||
|
||||
@@ -510,6 +510,13 @@ export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
|
||||
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
|
||||
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
|
||||
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
|
||||
// 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用
|
||||
// version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是
|
||||
// REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限,
|
||||
// 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。
|
||||
export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
|
||||
export const updateTopicDraft = (topic, content, entities, version) =>
|
||||
imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version});
|
||||
|
||||
export async function fetchFileState(fileId, range = '') {
|
||||
const headers = {
|
||||
|
||||
+137
-4
@@ -30,7 +30,8 @@ import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constan
|
||||
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
|
||||
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, recallMessage } from '../lib/im-api.js';
|
||||
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
|
||||
updateTopicDraft } from '../lib/im-api.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
@@ -156,6 +157,7 @@ class MessagesView extends React.Component {
|
||||
this.state.messagePolicy = null;
|
||||
this.state.toast = null;
|
||||
this.state.riskDialog = null;
|
||||
this.mentionedUsers = [];
|
||||
|
||||
this.componentSetup = this.componentSetup.bind(this);
|
||||
this.leave = this.leave.bind(this);
|
||||
@@ -189,6 +191,11 @@ class MessagesView extends React.Component {
|
||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||
this.loadRelationshipContext = this.loadRelationshipContext.bind(this);
|
||||
this.loadGroupContext = this.loadGroupContext.bind(this);
|
||||
this.loadDraft = this.loadDraft.bind(this);
|
||||
this.handleComposerChange = this.handleComposerChange.bind(this);
|
||||
this.clearDraft = this.clearDraft.bind(this);
|
||||
this.mentionSearch = this.mentionSearch.bind(this);
|
||||
this.mentionSelected = this.mentionSelected.bind(this);
|
||||
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
|
||||
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
|
||||
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
|
||||
@@ -350,8 +357,10 @@ class MessagesView extends React.Component {
|
||||
topic.onAuxUpdated = this.handleAuxUpdate;
|
||||
}
|
||||
|
||||
this.mentionedUsers = [];
|
||||
this.loadRelationshipContext(topic);
|
||||
this.loadGroupContext(topic);
|
||||
this.loadDraft(topic);
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
@@ -413,7 +422,9 @@ class MessagesView extends React.Component {
|
||||
groupRoles: null,
|
||||
myGroupRole: null,
|
||||
groupAnnouncement: null,
|
||||
pendingJoinRequests: 0
|
||||
pendingJoinRequests: 0,
|
||||
draftText: null,
|
||||
draftVersion: 0
|
||||
};
|
||||
} else if (nextProps.topic != prevState.topic) {
|
||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||
@@ -440,6 +451,8 @@ class MessagesView extends React.Component {
|
||||
myGroupRole: null,
|
||||
groupAnnouncement: null,
|
||||
pendingJoinRequests: 0,
|
||||
draftText: null,
|
||||
draftVersion: 0,
|
||||
// MessagesView isn't remounted on topic switch (no `key={topic}` at the call site in
|
||||
// tinode-web.jsx), so a pending risk-check dialog from the conversation just left would
|
||||
// otherwise survive the switch -- confirming it would then send the old topic's message
|
||||
@@ -1139,6 +1152,81 @@ class MessagesView extends React.Component {
|
||||
}).catch(_ => {});
|
||||
}
|
||||
|
||||
// 4.2-4.3 @提醒:只对群话题开放(私聊提@没有意义,也没有可选的"其他成员")。返回给
|
||||
// send-message.jsx 的选择器渲染,真正的 uid 解析在 mentionSelected 里记下来,供发送时
|
||||
// applyMentionResolution 使用。
|
||||
mentionSearch(query) {
|
||||
const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : null;
|
||||
if (!topic || !topic.isGroupType()) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return getGroupMembers(this.state.topic, {mentionable: true, query}).then(data => data.items || []);
|
||||
}
|
||||
|
||||
mentionSelected(uid, name) {
|
||||
this.mentionedUsers = (this.mentionedUsers || []).filter(m => m.name !== name);
|
||||
this.mentionedUsers.push({uid, name});
|
||||
}
|
||||
|
||||
// 4.1 跨话题草稿:话题一切换就去问服务端这个会话有没有存过草稿。原生话题名(P2P 用对方
|
||||
// user_id)直接可用,接口内部自己会做订阅存在性校验,不需要额外归一化。
|
||||
loadDraft(topic) {
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
const topicName = topic.name;
|
||||
getTopicDraft(topicName).then(data => {
|
||||
if (this.state.topic != topicName) {
|
||||
return;
|
||||
}
|
||||
this.setState({draftText: data.content || '', draftVersion: data.version || 0});
|
||||
}).catch(_ => {});
|
||||
}
|
||||
|
||||
// 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的
|
||||
// 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下
|
||||
// SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。
|
||||
handleComposerChange(text) {
|
||||
if (this.state.reply && this.state.reply.editing) {
|
||||
return;
|
||||
}
|
||||
if (this.props.forwardMessage) {
|
||||
return;
|
||||
}
|
||||
const topicName = this.state.topic;
|
||||
if (!topicName) {
|
||||
return;
|
||||
}
|
||||
if (this.draftSaveTimer) {
|
||||
window.clearTimeout(this.draftSaveTimer);
|
||||
}
|
||||
this.draftSaveTimer = window.setTimeout(() => {
|
||||
this.draftSaveTimer = null;
|
||||
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
|
||||
updateTopicDraft(topicName, text, [], version).then(d => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({draftVersion: d.version});
|
||||
}
|
||||
}).catch(_ => {});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。
|
||||
// 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实
|
||||
// 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。
|
||||
clearDraft(topicName) {
|
||||
if (this.draftSaveTimer) {
|
||||
window.clearTimeout(this.draftSaveTimer);
|
||||
this.draftSaveTimer = null;
|
||||
}
|
||||
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
|
||||
updateTopicDraft(topicName, '', [], version).then(d => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({draftText: '', draftVersion: d.version});
|
||||
}
|
||||
}).catch(_ => {});
|
||||
}
|
||||
|
||||
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
|
||||
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
|
||||
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
|
||||
@@ -1234,6 +1322,25 @@ class MessagesView extends React.Component {
|
||||
// Drafty entity array. Since every attachment this app actually sends uses the standard ent[]
|
||||
// format (Drafty.attachFile/insertImage/insertVideo), none of them carry a flat file_id, so without
|
||||
// this hint every image/video/file/voice message silently classifies as plain "text" server-side.
|
||||
// Mutates a parsed Drafty document in place, upgrading any auto-detected "@name" MN entity whose
|
||||
// raw matched text equals a name the user actually picked from the mention dropdown into a real,
|
||||
// uid-bearing mention. Entities that don't match anything tracked (a literal "@word" the user just
|
||||
// typed, or an edited/mismatched name) are left with their raw text value -- Tinode's own renderer
|
||||
// already falls back to displaying that gracefully, this only upgrades the ones it safely can.
|
||||
applyMentionResolution(parsed) {
|
||||
if (!parsed || !Array.isArray(parsed.ent) || !this.mentionedUsers || !this.mentionedUsers.length) {
|
||||
return;
|
||||
}
|
||||
parsed.ent.forEach(e => {
|
||||
if (e.tp === 'MN' && e.data && typeof e.data.val === 'string') {
|
||||
const match = this.mentionedUsers.find(m => m.name === e.data.val);
|
||||
if (match) {
|
||||
e.data.val = match.uid;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(msg, uploadCompletionPromise, uploader, typeHint) {
|
||||
// Captured before the reply/edit branches below possibly rewrite `msg` into a Drafty object
|
||||
// (Drafty.append(...) for a reply-with-quote) -- the URL risk check further down keys off
|
||||
@@ -1261,16 +1368,36 @@ class MessagesView extends React.Component {
|
||||
head = {reply: '' + this.state.reply.seq};
|
||||
if (typeof msg == 'string') {
|
||||
msg = Drafty.parse(msg);
|
||||
this.applyMentionResolution(msg);
|
||||
}
|
||||
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
|
||||
}
|
||||
this.handleCancelReply();
|
||||
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
|
||||
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
|
||||
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
|
||||
// ({val: val.slice(1)})),不是 uid——那是纯文本解析器,不可能凭空知道 uid。真正的 uid
|
||||
// 只在用户从选择器里点选时才知道,记在 this.mentionedUsers 里;这里按显示名回填,把
|
||||
// 自动识别出的占位符换成真的 uid。没有任何选过的提醒时完全不碰 msg,维持字符串原样,
|
||||
// 交给 SDK 自己在更底层 parse(跟这次改动之前完全一样的行为,不引入任何差异)。
|
||||
const parsed = Drafty.parse(msg);
|
||||
this.applyMentionResolution(parsed);
|
||||
msg = parsed;
|
||||
}
|
||||
if (typeHint) {
|
||||
head = {...(head || {}), 'x-im-type': typeHint};
|
||||
}
|
||||
|
||||
const doSend = () => this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||
const topicForDraft = this.state.topic;
|
||||
const doSend = () => {
|
||||
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
|
||||
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
|
||||
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
|
||||
result.then(() => this.clearDraft(topicForDraft));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Pre-send URL risk check (POST /security/urls/check) -- the only Ch.11 "anti-fraud" mechanism
|
||||
// with a real backend behind it. Only plain-text composed messages are checked (not forwards,
|
||||
@@ -2186,14 +2313,20 @@ class MessagesView extends React.Component {
|
||||
// the real Tinode ACS-level restrictions and are unaffected by this.
|
||||
disabled={!this.state.isWriter || this.state.deleted}
|
||||
reply={this.state.reply}
|
||||
initMessage={this.state.contentToEdit}
|
||||
initMessage={this.state.contentToEdit != null ? this.state.contentToEdit : this.state.draftText}
|
||||
sendOnEnter={this.props.sendOnEnter}
|
||||
onKeyPress={this.sendKeyPress}
|
||||
onRecordingProgress={this.sendKeyPress}
|
||||
onMessageChange={this.props.forwardMessage ? null : this.handleComposerChange}
|
||||
onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch}
|
||||
onMentionSelected={this.mentionSelected}
|
||||
onSendMessage={this.sendMessage}
|
||||
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
|
||||
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo}
|
||||
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
|
||||
canStartCall={!this.props.forwardMessage && this.props.canStartCall}
|
||||
onStartAudioCall={this.props.onStartAudioCall}
|
||||
onStartVideoCall={this.props.onStartVideoCall}
|
||||
onError={this.props.onError}
|
||||
onQuoteClick={this.handleQuoteClick}
|
||||
onCancelReply={this.handleCancelReply} />}
|
||||
|
||||
@@ -1995,6 +1995,22 @@ class TinodeWeb extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
// Extracted out of defaultTopicContextMenu so the new 4.9-4.14 attachment-panel call shortcuts
|
||||
// (send-message.jsx) can gate on exactly the same real-world availability check as the existing
|
||||
// "..." menu items, instead of re-deriving (and risking drifting from) the same three conditions.
|
||||
canStartCall(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (!topic || topic._deleted || !Tinode.isP2PTopicName(topicName)) {
|
||||
return false;
|
||||
}
|
||||
const acs = topic.getAccessMode();
|
||||
const writer = !!(acs && acs.isWriter());
|
||||
const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features ||
|
||||
(cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false);
|
||||
return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
}
|
||||
|
||||
defaultTopicContextMenu(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
|
||||
@@ -2006,7 +2022,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
const me = this.tinode.getMeTopic();
|
||||
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false,
|
||||
archived = false, pinned = false, webrtc = false, writer = false, p2p = false, self = false;
|
||||
archived = false, pinned = false, webrtc = false, p2p = false, self = false;
|
||||
if (topic) {
|
||||
subscribed = topic.isSubscribed();
|
||||
archived = topic.isArchived();
|
||||
@@ -2018,20 +2034,16 @@ class TinodeWeb extends React.Component {
|
||||
blocked = !acs.isJoiner();
|
||||
self_blocked = !acs.isJoiner('want');
|
||||
deleter = acs.isDeleter();
|
||||
writer = acs.isWriter();
|
||||
}
|
||||
}
|
||||
|
||||
// 后端今天新增了一个全局(非按租户)的通话总开关:{pub head:webrtc:...} 现在会先过
|
||||
// im_client_feature 这个开关,关闭时整条消息直接被服务端拒绝。这个开关缺失/查询失败时是
|
||||
// fail-closed(当成关闭),比 Tinode 协议层的 iceServers 配置更容易出现"某天突然打不通"的
|
||||
// 情况。这里读一下各页面已经在共享的 bootstrap 缓存,能读到就按 features 门控;读不到就按
|
||||
// 原来的行为放行——按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,
|
||||
// 这里不做的话用户点了按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
|
||||
const cachedBootstrap = this.state.myUserId ? readCachedData('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features ||
|
||||
(cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false);
|
||||
webrtc = writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
// 情况。canStartCall 统一做这几条判断(也是 4.9-4.14 附件面板通话入口在用的同一份逻辑),
|
||||
// 按钮可见性只是一层预判,真正失败时上面 handleCallInvite 已经会报错,这里不做的话用户点了
|
||||
// 按钮才发现打不通,做了至少能提前少显示一个注定失败的入口。
|
||||
webrtc = this.canStartCall(topicName);
|
||||
p2p = Tinode.isP2PTopicName(topicName);
|
||||
self = Tinode.isSelfTopicName(topicName);
|
||||
|
||||
@@ -2782,6 +2794,9 @@ class TinodeWeb extends React.Component {
|
||||
onCallSendOffer={this.handleCallSendOffer}
|
||||
onCallIceCandidate={this.handleCallIceCandidate}
|
||||
onCallSendAnswer={this.handleCallSendAnswer}
|
||||
canStartCall={this.state.topicSelected ? this.canStartCall(this.state.topicSelected) : false}
|
||||
onStartAudioCall={this.handleStartAudioCall}
|
||||
onStartVideoCall={this.handleStartVideoCall}
|
||||
|
||||
errorText={this.state.errorText}
|
||||
errorLevel={this.state.errorLevel}
|
||||
|
||||
@@ -223,6 +223,12 @@ class AudioPlayer extends React.PureComponent {
|
||||
} else if (this.audioPlayer.readyState >= 2) {
|
||||
this.audioPlayer.play();
|
||||
this.setState({playing: true}, this.visualize);
|
||||
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
|
||||
// 有没有被标记已读(原生 Tinode recv/read),跟"这条语音有没有被真的点开听过"是两件事。
|
||||
// 可选 prop,不传不影响任何现有调用点(收藏页/聊天记录检索也在用这个组件,不需要这个信号)。
|
||||
if (this.props.onPlay) {
|
||||
this.props.onPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,9 +237,24 @@ class AudioRecorder extends React.PureComponent {
|
||||
e.preventDefault();
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.stop();
|
||||
// getUserMedia hasn't necessarily resolved yet -- a real, reachable race even outside the new
|
||||
// 4.7-4.9 press-and-hold gesture (send-message.jsx can now call this within moments of mount):
|
||||
// tap record then immediately tap delete, before the permission prompt (or this promise) settles,
|
||||
// and mediaRecorder/stream are still null. handleDone already guarded its own `.stop()` call for
|
||||
// exactly this reason; this one never did, so it 500'd with "Cannot read properties of null
|
||||
// (reading 'stop')" instead of just cancelling a recording that never really started.
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
this.cleanUp();
|
||||
this.setState({recording: false});
|
||||
// onDeleted/onFinished normally only fire from mediaRecorder.onstop (below) -- if there's no
|
||||
// mediaRecorder, that event can never happen, so the parent (send-message.jsx's audioRec state,
|
||||
// driving whether this component is even mounted) would never hear back and stay stuck showing
|
||||
// the recorder UI forever. Nothing was actually captured in this case, so call it directly.
|
||||
if (!this.mediaRecorder && this.props.onDeleted) {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
handleDone(e) {
|
||||
@@ -252,6 +267,11 @@ class AudioRecorder extends React.PureComponent {
|
||||
// Stop recording and return data.
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
} else if (this.props.onDeleted) {
|
||||
// Same never-started race as handleDelete -- nothing was recorded, so there's nothing to send.
|
||||
// Treat it as a cancel (onDeleted) rather than silently leaving the parent's audioRec state
|
||||
// (and this component) stuck open with no way forward.
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,12 +399,29 @@ class AudioRecorder extends React.PureComponent {
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
// Same never-actually-started race as handleDelete's mediaRecorder guard above -- this.stream is
|
||||
// still null until getUserMedia resolves, and componentWillUnmount already knew to check before
|
||||
// calling this; this call site (handleDelete) didn't.
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
// 4.7-4.9 按住说话:send-message.jsx 现在用一个 ref 直接调这个组件的 handleDone/handleDelete
|
||||
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
|
||||
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
|
||||
if (this.props.holdMode) {
|
||||
return (
|
||||
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
|
||||
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
|
||||
<div className="duration">{this.state.duration}</div>
|
||||
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="audio">
|
||||
<a href="#" onClick={this.handleDelete} title={formatMessage(messages.icon_title_delete)}>
|
||||
@@ -416,4 +453,9 @@ class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(AudioRecorder);
|
||||
// forwardRef:true so send-message.jsx's ref (needed to drive handleDone/handleDelete externally
|
||||
// for the 4.7-4.9 press-and-hold gesture) actually reaches the class instance instead of the
|
||||
// injectIntl wrapper -- without this option injectIntl's wrapper is a function component and a
|
||||
// plain ref on it silently resolves to null (React warns "Function components cannot be given
|
||||
// refs"), which was live-caught leaving the recorder UI stuck open with no way to finish the hold.
|
||||
export default injectIntl(AudioRecorder, {forwardRef: true});
|
||||
|
||||
@@ -13,12 +13,37 @@ import { fullFormatter } from '../lib/formatters.js';
|
||||
import { sanitizeUrl } from '../lib/utils.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
// 4.9 语音未读红点:纯客户端概念,没有对应的后端字段——原生 Tinode 的 recv/read 只标记到
|
||||
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
|
||||
// 读过,语音消息可能只是划过去、根本没点开播放)。按话题分别记一份"已播放过的 seq 集合"在
|
||||
// localStorage,跟"最近使用表情"是同一类不需要后端参与的本地小状态。
|
||||
const VOICE_PLAYED_KEY_PREFIX = 'im-voice-played:';
|
||||
function isVoicePlayed(topic, seq) {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
|
||||
return Array.isArray(list) && list.includes(seq);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function markVoicePlayed(topic, seq) {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(VOICE_PLAYED_KEY_PREFIX + topic) || '[]');
|
||||
const set = Array.isArray(list) ? list : [];
|
||||
if (!set.includes(seq)) {
|
||||
// Cap so a very long-lived topic doesn't grow this without bound.
|
||||
localStorage.setItem(VOICE_PLAYED_KEY_PREFIX + topic, JSON.stringify(set.concat(seq).slice(-500)));
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
class BaseChatMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
progress: 0
|
||||
progress: 0,
|
||||
voicePlayed: isVoicePlayed(props.topic, props.seq)
|
||||
};
|
||||
|
||||
if (props.uploader) {
|
||||
@@ -32,13 +57,18 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleDraftyClick = this.handleDraftyClick.bind(this);
|
||||
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
|
||||
|
||||
this.formatterContext = {
|
||||
formatMessage: props.intl.formatMessage.bind(props.intl),
|
||||
viewportWidth: props.viewportWidth,
|
||||
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
|
||||
|
||||
onHandleClick: this.handleDraftyClick
|
||||
onHandleClick: this.handleDraftyClick,
|
||||
// formatters.js's 'AU' case wires this to AudioPlayer's onPlay -- see handleVoicePlayed below
|
||||
// for why this needs to live on the instance (closes over this specific message's seq/topic)
|
||||
// rather than being a static, prop-derived value like the other formatterContext entries.
|
||||
onVoicePlayed: this.handleVoicePlayed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +210,14 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
||||
}
|
||||
|
||||
handleVoicePlayed() {
|
||||
if (this.state.voicePlayed) {
|
||||
return;
|
||||
}
|
||||
markVoicePlayed(this.props.topic, this.props.seq);
|
||||
this.setState({voicePlayed: true});
|
||||
}
|
||||
|
||||
handleQuoteClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -215,6 +253,15 @@ class BaseChatMessage extends React.PureComponent {
|
||||
const isFailed = !this.props.response &&
|
||||
(this.props.received == Tinode.MESSAGE_STATUS_FAILED || this.props.received == Tinode.MESSAGE_STATUS_FATAL);
|
||||
const failReason = this.renderFailReason();
|
||||
// 4.9 语音未读红点:只对收到的(response=true)语音消息生效——自己发的语音不需要提醒自己听。
|
||||
let isVoiceMessage = false;
|
||||
if (this.props.response && this.props.mimeType == Drafty.getContentType() && Drafty.isValid(this.props.content)) {
|
||||
Drafty.entities(this.props.content, (_0, _1, tp) => {
|
||||
if (tp === 'AU') { isVoiceMessage = true; }
|
||||
return isVoiceMessage;
|
||||
});
|
||||
}
|
||||
const showVoiceUnreadDot = isVoiceMessage && !this.state.voicePlayed;
|
||||
let textSizeClass = 'message-content';
|
||||
const fullDisplay = (this.props.isGroup && this.props.response &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
@@ -295,6 +342,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
<i className="material-icons red message-fail-icon">error</i> : null}
|
||||
<div>
|
||||
<div className={bubbleClass}>
|
||||
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
|
||||
<div className="content-meta">
|
||||
<div className={textSizeClass}>
|
||||
{bundleCard || content}
|
||||
|
||||
+399
-14
@@ -1,7 +1,7 @@
|
||||
// Send message form.
|
||||
import React, { Suspense } from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
import { Drafty, TheCard } from 'tinode-sdk';
|
||||
|
||||
// Lazy-loading AudioRecorder because it's quite large due to
|
||||
// a dependency on webm-duration-fix.
|
||||
@@ -10,6 +10,47 @@ const AudioRecorder = React.lazy(_ => import('./audio-recorder.jsx'));
|
||||
import { KEYPRESS_DELAY } from '../config.js';
|
||||
import { filePasted } from '../lib/blob-helpers.js';
|
||||
import { replyFormatter } from '../lib/formatters.js';
|
||||
import { forwardFavorites, getFavorites, getFileUrl, getFriends } from '../lib/im-api.js';
|
||||
|
||||
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
|
||||
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
|
||||
// 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。
|
||||
// 表情本身的具体清单同样没有图片依据,选了一组常见、覆盖面广的 Unicode 表情,不追求详尽分类。
|
||||
const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘',
|
||||
'🥰', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
|
||||
'🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑',
|
||||
'😲', '☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯', '😬', '😰', '😱',
|
||||
'🥵', '🥶', '😳', '🤪', '😵', '😡', '😠', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🥳', '🥴', '🥺', '🤫',
|
||||
'🤭', '🧐', '🤓', '😈', '👿', '👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '❤️', '🧡', '💛', '💚', '💙',
|
||||
'💜', '🖤', '💔', '💕', '💯', '🔥', '🎉', '🎂', '🌹', '☀️', '🌙', '⭐'];
|
||||
// 跟 content-library-view.jsx 的 TYPE_LABEL 是同一套值(那边是 view 层,这边是 widget,不方便
|
||||
// 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段,
|
||||
// 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果
|
||||
// (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。
|
||||
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
|
||||
function favoriteSummary(item) {
|
||||
const content = item.snapshot && item.snapshot.content;
|
||||
if (content && typeof content.txt === 'string' && content.txt) {
|
||||
return content.txt;
|
||||
}
|
||||
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
|
||||
}
|
||||
const RECENT_EMOJI_KEY = 'im-recent-emoji';
|
||||
const MAX_RECENT_EMOJI = 30;
|
||||
function loadRecentEmoji() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) || '[]');
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function pushRecentEmoji(emoji) {
|
||||
try {
|
||||
const list = [emoji].concat(loadRecentEmoji().filter(e => e !== emoji)).slice(0, MAX_RECENT_EMOJI);
|
||||
localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(list));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
messaging_disabled: {
|
||||
@@ -68,6 +109,18 @@ class SendMessage extends React.PureComponent {
|
||||
message: '',
|
||||
audioRec: false,
|
||||
audioAvailable: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
// @提醒选择器:mentionQuery 非 null 时显示面板;mentionStart 是触发这次匹配的"@"在
|
||||
// message 里的字符下标(不是 fmt/grapheme 下标,纯 JS 字符串下标,跟 slice 配对用)。
|
||||
mentionQuery: null,
|
||||
mentionResults: [],
|
||||
mentionHighlight: 0,
|
||||
emojiOpen: false,
|
||||
emojiTab: 'emoji',
|
||||
recentEmoji: loadRecentEmoji(),
|
||||
attachOpen: false,
|
||||
cardPicker: null,
|
||||
favPicker: null,
|
||||
audioCancelling: false,
|
||||
};
|
||||
|
||||
// Timestamp when the previous key press was sent to the server.
|
||||
@@ -83,6 +136,17 @@ class SendMessage extends React.PureComponent {
|
||||
this.handleDropAttach = this.handleDropAttach.bind(this);
|
||||
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
this.selectMention = this.selectMention.bind(this);
|
||||
this.startHoldRecording = this.startHoldRecording.bind(this);
|
||||
this.handleHoldMove = this.handleHoldMove.bind(this);
|
||||
this.endHoldRecording = this.endHoldRecording.bind(this);
|
||||
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
|
||||
this.insertEmoji = this.insertEmoji.bind(this);
|
||||
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
|
||||
this.openCardPicker = this.openCardPicker.bind(this);
|
||||
this.openFavPicker = this.openFavPicker.bind(this);
|
||||
this.sendCard = this.sendCard.bind(this);
|
||||
this.sendFavorite = this.sendFavorite.bind(this);
|
||||
|
||||
this.formatReply = this.formatReply.bind(this);
|
||||
}
|
||||
@@ -103,6 +167,13 @@ class SendMessage extends React.PureComponent {
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
||||
}
|
||||
if (this.mentionSearchTimer) {
|
||||
window.clearTimeout(this.mentionSearchTimer);
|
||||
}
|
||||
window.removeEventListener('mousemove', this.handleHoldMove);
|
||||
window.removeEventListener('mouseup', this.endHoldRecording);
|
||||
window.removeEventListener('touchmove', this.handleHoldMove);
|
||||
window.removeEventListener('touchend', this.endHoldRecording);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -118,7 +189,8 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
if (prevProps.topicName != this.props.topicName) {
|
||||
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null});
|
||||
this.setState({message: this.props.initMessage || '', audioRec: false, quote: null, mentionQuery: null,
|
||||
mentionResults: [], emojiOpen: false, attachOpen: false, cardPicker: null, favPicker: null});
|
||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||
const msg = this.props.initMessage || '';
|
||||
this.setState({message: msg}, _ => {
|
||||
@@ -211,6 +283,27 @@ class SendMessage extends React.PureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.mentionQuery != null) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.setState({mentionQuery: null, mentionResults: []});
|
||||
return;
|
||||
}
|
||||
if (this.state.mentionResults.length > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const count = this.state.mentionResults.length;
|
||||
this.setState({mentionHighlight: (this.state.mentionHighlight + delta + count) % count});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && this.state.mentionResults.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.selectMention(this.state.mentionResults[this.state.mentionHighlight]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter pressed: either send, or enter a new line.
|
||||
if (e.key === 'Enter') {
|
||||
if (this.props.sendOnEnter == 'plain') {
|
||||
@@ -234,6 +327,10 @@ class SendMessage extends React.PureComponent {
|
||||
|
||||
handleMessageTyping(e) {
|
||||
this.setState({message: e.target.value});
|
||||
this.updateMentionState(e.target.value, e.target.selectionStart);
|
||||
if (this.props.onMessageChange) {
|
||||
this.props.onMessageChange(e.target.value);
|
||||
}
|
||||
if (this.props.onKeyPress) {
|
||||
const now = new Date().getTime();
|
||||
if (now - this.keypressTimestamp > KEYPRESS_DELAY) {
|
||||
@@ -243,6 +340,68 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// 4.2-4.3 @提醒:只在群聊里挂 onMentionSearch(messages-view.jsx 只给群话题传这个 prop)。
|
||||
// 触发条件是"光标前最近一个'@','@'前面是空白或整段开头,'@'和光标之间没有空白"——这样
|
||||
// "联系xx@xx.com"这种邮箱场景里的@不会被误当成提醒触发。
|
||||
updateMentionState(text, caret) {
|
||||
if (!this.props.onMentionSearch || caret == null) {
|
||||
return;
|
||||
}
|
||||
const before = text.slice(0, caret);
|
||||
const match = /(?:^|\s)@([^\s@]*)$/.exec(before);
|
||||
if (!match) {
|
||||
if (this.state.mentionQuery != null) {
|
||||
this.setState({mentionQuery: null, mentionResults: []});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const query = match[1];
|
||||
const start = caret - query.length - 1;
|
||||
this.setState({mentionQuery: query, mentionStart: start, mentionHighlight: 0});
|
||||
if (this.mentionSearchTimer) {
|
||||
window.clearTimeout(this.mentionSearchTimer);
|
||||
}
|
||||
this.mentionSearchTimer = window.setTimeout(() => {
|
||||
this.props.onMentionSearch(query).then(results => {
|
||||
// mentionQuery may have moved on (user kept typing, or closed the picker) by the time this
|
||||
// resolves -- only apply it if we're still showing a picker at all, matching the same
|
||||
// stale-response guard used everywhere else responses race state.
|
||||
if (this.state.mentionQuery != null) {
|
||||
this.setState({mentionResults: results || []});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
selectMention(item) {
|
||||
const {message, mentionStart, mentionQuery} = this.state;
|
||||
if (mentionStart == null || mentionQuery == null) {
|
||||
return;
|
||||
}
|
||||
const name = item.group_nickname || item.display_name || item.user_id;
|
||||
const insertion = '@' + name + ' ';
|
||||
const end = mentionStart + 1 + mentionQuery.length;
|
||||
const newMessage = message.slice(0, mentionStart) + insertion + message.slice(end);
|
||||
const caretPos = mentionStart + insertion.length;
|
||||
this.setState({message: newMessage, mentionQuery: null, mentionResults: []});
|
||||
if (this.props.onMentionSelected) {
|
||||
this.props.onMentionSelected(item.user_id, name);
|
||||
}
|
||||
if (this.props.onMessageChange) {
|
||||
this.props.onMessageChange(newMessage);
|
||||
}
|
||||
// Textarea's own DOM value needs the same update -- setState alone re-renders with the right
|
||||
// `value` prop, but the caret placement below needs the element's real value/selection to
|
||||
// already match, and doing it in the same tick (rather than waiting on the render) avoids a
|
||||
// visible flash of the stale text with the caret in the wrong place.
|
||||
requestAnimationFrame(() => {
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.focus();
|
||||
this.messageEditArea.setSelectionRange(caretPos, caretPos);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleQuoteClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -252,6 +411,144 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// 组合面板图标切换规律(核对图片确认):表情/附件两个入口固定在输入框右侧;哪个面板当前
|
||||
// 展开,对应的那个图标就变成"⌨"(点击收起换回键盘),另一个保持原样不变——不是两个一起变。
|
||||
// 这里只做了表情这一侧,附件面板(4.13/4.9-4.14)另外接线时要遵守同一条规则,不要各自发明。
|
||||
toggleEmojiPanel(e) {
|
||||
e.preventDefault();
|
||||
this.setState(s => ({emojiOpen: !s.emojiOpen, attachOpen: false, audioRec: false}));
|
||||
}
|
||||
|
||||
insertEmoji(emoji) {
|
||||
const ta = this.messageEditArea;
|
||||
const start = ta ? ta.selectionStart : this.state.message.length;
|
||||
const end = ta ? ta.selectionEnd : this.state.message.length;
|
||||
const newMessage = this.state.message.slice(0, start) + emoji + this.state.message.slice(end);
|
||||
pushRecentEmoji(emoji);
|
||||
this.setState({message: newMessage, recentEmoji: loadRecentEmoji()});
|
||||
if (this.props.onMessageChange) {
|
||||
this.props.onMessageChange(newMessage);
|
||||
}
|
||||
const caretPosEmoji = start + emoji.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.focus();
|
||||
this.messageEditArea.setSelectionRange(caretPosEmoji, caretPosEmoji);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleAttachPanel(e) {
|
||||
e.preventDefault();
|
||||
this.setState(s => ({attachOpen: !s.attachOpen, emojiOpen: false}));
|
||||
}
|
||||
|
||||
// 3.12/4.9-4.14 发名片:选一个好友,生成一张只含姓名+头像+该好友 uid 的名片消息发到当前会话。
|
||||
// uid 必须用 TheCard.setTinodeID 写进 comm 数组(vCard 的"tinode"协议项),不是随便塞进某个
|
||||
// 字段——widgets/the-card-mini.jsx 点开这张卡片时用 TheCard.getFirstTinodeID(card) 读这个
|
||||
// 协议项来判断"发消息"按钮该指向谁,写错地方那个按钮会永远不出现。
|
||||
openCardPicker(e) {
|
||||
e.preventDefault();
|
||||
this.setState({attachOpen: false, cardPicker: {loading: true}});
|
||||
getFriends().then(data => {
|
||||
this.setState({cardPicker: {loading: false, items: data.items || []}});
|
||||
}).catch(() => this.setState({cardPicker: {loading: false, items: [], error: true}}));
|
||||
}
|
||||
|
||||
sendCard(item) {
|
||||
this.setState({cardPicker: null});
|
||||
const build = avatarUrl => {
|
||||
const card = new TheCard(item.display_name || item.user_id, avatarUrl);
|
||||
TheCard.setTinodeID(card, item.user_id);
|
||||
this.props.onSendMessage(Drafty.appendTheCard(null, card));
|
||||
};
|
||||
if (item.avatar_file_id) {
|
||||
getFileUrl(item.avatar_file_id).then(build).catch(() => build(undefined));
|
||||
} else {
|
||||
build(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏发送:走的是 Ch.7 已有的 forwardFavorites REST 接口(转发收藏的同一个接口,目标只给
|
||||
// 当前会话一个),不经过 onSendMessage/Drafty——收藏本来就是后端已经存好快照的独立资源,
|
||||
// 不是本地新拼一条消息。
|
||||
openFavPicker(e) {
|
||||
e.preventDefault();
|
||||
this.setState({attachOpen: false, favPicker: {loading: true}});
|
||||
getFavorites({type: 'all'}).then(data => {
|
||||
this.setState({favPicker: {loading: false, items: data.items || []}});
|
||||
}).catch(() => this.setState({favPicker: {loading: false, items: [], error: true}}));
|
||||
}
|
||||
|
||||
sendFavorite(item) {
|
||||
this.setState({favPicker: null});
|
||||
if (!this.props.topicName) {
|
||||
return;
|
||||
}
|
||||
// 这个接口恒定 200,真实成败要看 items[].status(跟批量收藏/批量转发是同一套设计,全局代码
|
||||
// 审查那一轮已经在其它几处调用点踩过同一个坑并修过——这是新写的这处,同样的坑要一起避开,
|
||||
// 不能只 .catch 网络层错误就当万事大吉。联调时真的用这条路径测出过 200 OK 但
|
||||
// items[0].status:"failed"、reason:"PERMISSION_DENIED" 的情况(对方是真实好友、这个会话
|
||||
// 本身正常能发消息),看起来是后端这个接口自己的权限判断有问题,不是本地能修的,如实报错
|
||||
// 而不是假装发送成功。
|
||||
forwardFavorites([item.favorite_id], [this.props.topicName]).then(result => {
|
||||
const failed = (result && result.items || []).filter(r => r.status === 'failed');
|
||||
if (failed.length && this.props.onError) {
|
||||
this.props.onError('发送失败:' + (failed[0].reason || '未知原因'), 'err');
|
||||
}
|
||||
}).catch(err => {
|
||||
if (this.props.onError) {
|
||||
this.props.onError((err && err.message) || '发送失败', 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4.7-4.9 按住说话 + 上滑取消:mousedown/touchstart 直接挂载 AudioRecorder(挂载即开始录音,
|
||||
// 这一步跟原来点一下进入录音模式完全一样,没有改 AudioRecorder 自己的录音逻辑),松手时通过
|
||||
// ref 直接调它的 handleDone/handleDelete——这两个方法本来是绑给它自己按钮的 onClick,要求一个
|
||||
// 带 preventDefault 的事件对象,这里传一个最小的假事件满足这个要求,除此之外没有更深地伸手进
|
||||
// 它的内部状态。上滑距离阈值 80px 是参照大多数同类应用(微信等)的常见取值,没有对应的可验证
|
||||
// 数值来源,明确记录不是抄的哪个具体规格。
|
||||
startHoldRecording(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.audioRec) {
|
||||
return;
|
||||
}
|
||||
this.holdStartY = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY;
|
||||
this.setState({audioRec: true, audioCancelling: false, emojiOpen: false, attachOpen: false});
|
||||
window.addEventListener('mousemove', this.handleHoldMove);
|
||||
window.addEventListener('mouseup', this.endHoldRecording);
|
||||
window.addEventListener('touchmove', this.handleHoldMove, {passive: false});
|
||||
window.addEventListener('touchend', this.endHoldRecording);
|
||||
}
|
||||
|
||||
handleHoldMove(e) {
|
||||
const y = 'touches' in e ? (e.touches[0] && e.touches[0].clientY) : e.clientY;
|
||||
if (y == null || this.holdStartY == null) {
|
||||
return;
|
||||
}
|
||||
const cancelling = (this.holdStartY - y) > 80;
|
||||
if (cancelling !== this.state.audioCancelling) {
|
||||
this.setState({audioCancelling: cancelling});
|
||||
}
|
||||
}
|
||||
|
||||
endHoldRecording() {
|
||||
window.removeEventListener('mousemove', this.handleHoldMove);
|
||||
window.removeEventListener('mouseup', this.endHoldRecording);
|
||||
window.removeEventListener('touchmove', this.handleHoldMove);
|
||||
window.removeEventListener('touchend', this.endHoldRecording);
|
||||
this.holdStartY = null;
|
||||
const fakeEvent = {preventDefault: () => {}};
|
||||
if (this.audioRecorderInstance) {
|
||||
if (this.state.audioCancelling) {
|
||||
this.audioRecorderInstance.handleDelete(fakeEvent);
|
||||
} else {
|
||||
this.audioRecorderInstance.handleDone(fakeEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl;
|
||||
const prompt = this.props.disabled ?
|
||||
@@ -271,29 +568,34 @@ class SendMessage extends React.PureComponent {
|
||||
{this.state.quote}
|
||||
</div>) : null;
|
||||
const audioEnabled = this.state.audioAvailable && this.props.onAttachAudio;
|
||||
const mentionPanel = this.state.mentionQuery != null ?
|
||||
(<div id="mention-picker">
|
||||
{this.state.mentionResults.length === 0 ?
|
||||
<div className="mention-picker-empty">无匹配成员</div> :
|
||||
this.state.mentionResults.map((item, i) => (
|
||||
<div key={item.user_id} className={'mention-picker-row' + (i === this.state.mentionHighlight ? ' active' : '')}
|
||||
onMouseDown={e => { e.preventDefault(); this.selectMention(item); }}
|
||||
onMouseEnter={() => this.setState({mentionHighlight: i})}>
|
||||
{item.group_nickname || item.display_name || item.user_id}
|
||||
</div>
|
||||
))}
|
||||
</div>) : null;
|
||||
return (
|
||||
<div id="send-message-wrapper">
|
||||
{!this.props.noInput ? quote : null}
|
||||
{mentionPanel}
|
||||
<div id="send-message-panel">
|
||||
{!this.props.disabled ?
|
||||
<>
|
||||
{this.props.onAttachFile && !this.state.audioRec ?
|
||||
<>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.attachImage.click();}} title={formatMessage(messages.icon_title_add_image)}>
|
||||
<i className="material-icons secondary">photo</i>
|
||||
</a>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.attachFile.click();}} title={formatMessage(messages.icon_title_attach_file)}>
|
||||
<i className="material-icons secondary">attach_file</i>
|
||||
</a>
|
||||
</>
|
||||
:
|
||||
null}
|
||||
{this.props.noInput ?
|
||||
(quote || <div className="hr thin" />) :
|
||||
(this.state.audioRec ?
|
||||
(<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
||||
description="Message shown when component is loading"/></div>}>
|
||||
<AudioRecorder
|
||||
ref={ref => {this.audioRecorderInstance = ref;}}
|
||||
holdMode={true}
|
||||
cancelling={this.state.audioCancelling}
|
||||
onRecordingProgress={_ => this.props.onKeyPress(true)}
|
||||
onDeleted={_ => this.setState({audioRec: false})}
|
||||
onFinished={this.handleAttachAudio}/>
|
||||
@@ -302,12 +604,23 @@ class SendMessage extends React.PureComponent {
|
||||
value={this.state.message} onChange={this.handleMessageTyping}
|
||||
onKeyDown={this.handleKeyPress}
|
||||
ref={ref => {this.messageEditArea = ref;}} />)}
|
||||
{/* 表情/附件两个入口固定在输入框右侧,哪个面板展开了对应图标就变⌨(点了收起换回键盘),
|
||||
跟前面 toggleEmojiPanel 的规则说明是同一条约定,附件那侧照抄。 */}
|
||||
{!this.props.noInput && !this.state.audioRec ?
|
||||
<a href="#" onClick={this.toggleEmojiPanel} title="表情">
|
||||
<i className="material-icons secondary">{this.state.emojiOpen ? 'keyboard' : 'insert_emoticon'}</i>
|
||||
</a> : null}
|
||||
{this.props.onAttachFile && !this.state.audioRec ?
|
||||
<a href="#" onClick={this.toggleAttachPanel} title={formatMessage(messages.icon_title_attach_file)}>
|
||||
<i className="material-icons secondary">{this.state.attachOpen ? 'keyboard' : 'add_circle_outline'}</i>
|
||||
</a> : null}
|
||||
{this.state.message || !audioEnabled ?
|
||||
<a href="#" onClick={this.handleSend} title={formatMessage(messages.icon_title_send)}>
|
||||
<i className="material-icons">{sendIcon}</i>
|
||||
</a> :
|
||||
!this.state.audioRec ?
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({audioRec: true})}} title={formatMessage(messages.icon_title_record_voice)}>
|
||||
<a href="#" onMouseDown={this.startHoldRecording} onTouchStart={this.startHoldRecording}
|
||||
onClick={e => e.preventDefault()} title={formatMessage(messages.icon_title_record_voice)}>
|
||||
<i className="material-icons">mic</i>
|
||||
</a> :
|
||||
null
|
||||
@@ -316,11 +629,83 @@ class SendMessage extends React.PureComponent {
|
||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||
<input type="file" ref={ref => {this.attachImage = ref}} accept="image/*, video/*"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
<input type="file" ref={ref => {this.attachCamera = ref}} accept="image/*" capture="environment"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
</>
|
||||
:
|
||||
<div id="writing-disabled">{prompt}</div>
|
||||
}
|
||||
</div>
|
||||
{this.state.emojiOpen ?
|
||||
<div id="emoji-panel">
|
||||
<div id="emoji-panel-tabs">
|
||||
<a href="#" className={this.state.emojiTab === 'emoji' ? 'active' : ''}
|
||||
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'emoji'});}}>😊</a>
|
||||
<a href="#" className={this.state.emojiTab === 'recent' ? 'active' : ''}
|
||||
onClick={e => {e.preventDefault(); this.setState({emojiTab: 'recent'});}}>♡</a>
|
||||
</div>
|
||||
<div id="emoji-panel-grid">
|
||||
{(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).length === 0 ?
|
||||
<div className="emoji-panel-empty">还没有常用表情</div> :
|
||||
(this.state.emojiTab === 'emoji' ? EMOJI_LIST : this.state.recentEmoji).map((emoji, i) => (
|
||||
<a href="#" key={emoji + i} onClick={e => {e.preventDefault(); this.insertEmoji(emoji);}}>{emoji}</a>
|
||||
))}
|
||||
</div>
|
||||
</div> : null}
|
||||
{this.state.attachOpen ?
|
||||
<div id="attach-panel">
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachImage.click();}}>
|
||||
<i className="material-icons">photo</i><span>照片</span>
|
||||
</a>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachCamera.click();}}>
|
||||
<i className="material-icons">photo_camera</i><span>拍摄</span>
|
||||
</a>
|
||||
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
|
||||
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartAudioCall) this.props.onStartAudioCall();}}>
|
||||
<i className="material-icons">call</i><span>语音通话</span>
|
||||
</a>
|
||||
<a href="#" onClick={this.openCardPicker}>
|
||||
<i className="material-icons">badge</i><span>名片</span>
|
||||
</a>
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); this.attachFile.click();}}>
|
||||
<i className="material-icons">attach_file</i><span>文件</span>
|
||||
</a>
|
||||
<a href="#" onClick={this.openFavPicker}>
|
||||
<i className="material-icons">star_border</i><span>收藏</span>
|
||||
</a>
|
||||
<a href="#" className={this.props.canStartCall ? '' : 'disabled'}
|
||||
onClick={e => {e.preventDefault(); this.setState({attachOpen: false}); if (this.props.canStartCall && this.props.onStartVideoCall) this.props.onStartVideoCall();}}>
|
||||
<i className="material-icons">videocam</i><span>视频通话</span>
|
||||
</a>
|
||||
</div> : null}
|
||||
{this.state.cardPicker ?
|
||||
<div className="send-message-picker-backdrop" onClick={() => this.setState({cardPicker: null})}>
|
||||
<section onClick={e => e.stopPropagation()}>
|
||||
<header>选择联系人<a href="#" onClick={e => {e.preventDefault(); this.setState({cardPicker: null});}}><i className="material-icons">close</i></a></header>
|
||||
<div className="send-message-picker-list">
|
||||
{this.state.cardPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
|
||||
this.state.cardPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.cardPicker.error ? '加载失败' : '暂无好友'}</p> :
|
||||
this.state.cardPicker.items.map(item => (
|
||||
<a href="#" key={item.user_id} onClick={e => {e.preventDefault(); this.sendCard(item);}}>{item.display_name || item.user_id}</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div> : null}
|
||||
{this.state.favPicker ?
|
||||
<div className="send-message-picker-backdrop" onClick={() => this.setState({favPicker: null})}>
|
||||
<section onClick={e => e.stopPropagation()}>
|
||||
<header>我的收藏<a href="#" onClick={e => {e.preventDefault(); this.setState({favPicker: null});}}><i className="material-icons">close</i></a></header>
|
||||
<div className="send-message-picker-list">
|
||||
{this.state.favPicker.loading ? <p className="send-message-picker-empty">加载中…</p> :
|
||||
this.state.favPicker.items.length === 0 ? <p className="send-message-picker-empty">{this.state.favPicker.error ? '加载失败' : '暂无收藏'}</p> :
|
||||
this.state.favPicker.items.map(item => (
|
||||
<a href="#" key={item.favorite_id} onClick={e => {e.preventDefault(); this.sendFavorite(item);}}>
|
||||
{favoriteSummary(item)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+27
-5
@@ -2838,6 +2838,7 @@ function fullFormatter(style, data, values, key, stack) {
|
||||
attr.duration = data.duration > 0 ? data.duration | 0 : undefined;
|
||||
attr.preview = data.preview;
|
||||
attr.loading = 'lazy';
|
||||
attr.onPlay = this.onVoicePlayed;
|
||||
}
|
||||
el = _widgets_audio_player_jsx__WEBPACK_IMPORTED_MODULE_3__["default"];
|
||||
values = null;
|
||||
@@ -3433,6 +3434,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ getRealnameStatus: function() { return /* binding */ getRealnameStatus; },
|
||||
/* harmony export */ getSystemNotifications: function() { return /* binding */ getSystemNotifications; },
|
||||
/* harmony export */ getTenantContext: function() { return /* binding */ getTenantContext; },
|
||||
/* harmony export */ getTopicDraft: function() { return /* binding */ getTopicDraft; },
|
||||
/* harmony export */ getTopicMedia: function() { return /* binding */ getTopicMedia; },
|
||||
/* harmony export */ getUserProfile: function() { return /* binding */ getUserProfile; },
|
||||
/* harmony export */ getWorkbench: function() { return /* binding */ getWorkbench; },
|
||||
@@ -3489,6 +3491,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ updateMeeting: function() { return /* binding */ updateMeeting; },
|
||||
/* harmony export */ updateMonthlyDraft: function() { return /* binding */ updateMonthlyDraft; },
|
||||
/* harmony export */ updateMyGroupProfile: function() { return /* binding */ updateMyGroupProfile; },
|
||||
/* harmony export */ updateTopicDraft: function() { return /* binding */ updateTopicDraft; },
|
||||
/* harmony export */ uploadLargeFile: function() { return /* binding */ uploadLargeFile; },
|
||||
/* harmony export */ uploadPublicFile: function() { return /* binding */ uploadPublicFile; },
|
||||
/* harmony export */ verifyIndependentPassword: function() { return /* binding */ verifyIndependentPassword; },
|
||||
@@ -4100,6 +4103,12 @@ const getMentionableGroupMembers = (topic, query = '', cursor = '') => imGet('/g
|
||||
query,
|
||||
cursor
|
||||
}));
|
||||
const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
|
||||
const updateTopicDraft = (topic, content, entities, version) => imPut('/topics/' + encodeURIComponent(topic) + '/draft', {
|
||||
content,
|
||||
entities,
|
||||
version
|
||||
});
|
||||
async function fetchFileState(fileId, range = '') {
|
||||
const headers = {
|
||||
'X-MeetingIM-APIKey': _config_js__WEBPACK_IMPORTED_MODULE_0__.API_KEY,
|
||||
@@ -19013,6 +19022,17 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
});
|
||||
}
|
||||
canStartCall(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (!topic || topic._deleted || !tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isP2PTopicName(topicName)) {
|
||||
return false;
|
||||
}
|
||||
const acs = topic.getAccessMode();
|
||||
const writer = !!(acs && acs.isWriter());
|
||||
const cachedBootstrap = this.state.myUserId ? (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_19__.readCachedData)('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features || cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false;
|
||||
return writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
}
|
||||
defaultTopicContextMenu(topicName) {
|
||||
const topic = this.tinode.getTopic(topicName);
|
||||
if (topic._deleted) {
|
||||
@@ -19027,7 +19047,6 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
archived = false,
|
||||
pinned = false,
|
||||
webrtc = false,
|
||||
writer = false,
|
||||
p2p = false,
|
||||
self = false;
|
||||
if (topic) {
|
||||
@@ -19040,12 +19059,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
blocked = !acs.isJoiner();
|
||||
self_blocked = !acs.isJoiner('want');
|
||||
deleter = acs.isDeleter();
|
||||
writer = acs.isWriter();
|
||||
}
|
||||
}
|
||||
const cachedBootstrap = this.state.myUserId ? (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_19__.readCachedData)('bootstrap:' + this.state.myUserId) : null;
|
||||
const callFeatureOk = !cachedBootstrap || !cachedBootstrap.features || cachedBootstrap.features.audio_call !== false && cachedBootstrap.features.video_call !== false;
|
||||
webrtc = writer && callFeatureOk && !!this.tinode.getServerParam('iceServers');
|
||||
webrtc = this.canStartCall(topicName);
|
||||
p2p = tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isP2PTopicName(topicName);
|
||||
self = tinode_sdk__WEBPACK_IMPORTED_MODULE_4__.Tinode.isSelfTopicName(topicName);
|
||||
return [subscribed ? {
|
||||
@@ -19693,6 +19709,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
onCallSendOffer: this.handleCallSendOffer,
|
||||
onCallIceCandidate: this.handleCallIceCandidate,
|
||||
onCallSendAnswer: this.handleCallSendAnswer,
|
||||
canStartCall: this.state.topicSelected ? this.canStartCall(this.state.topicSelected) : false,
|
||||
onStartAudioCall: this.handleStartAudioCall,
|
||||
onStartVideoCall: this.handleStartVideoCall,
|
||||
errorText: this.state.errorText,
|
||||
errorLevel: this.state.errorLevel,
|
||||
errorAction: this.state.errorAction,
|
||||
@@ -22265,6 +22284,9 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.setState({
|
||||
playing: true
|
||||
}, this.visualize);
|
||||
if (this.props.onPlay) {
|
||||
this.props.onPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
handleError(err) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -205,11 +205,16 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
e.preventDefault();
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.stop();
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
this.cleanUp();
|
||||
this.setState({
|
||||
recording: false
|
||||
});
|
||||
if (!this.mediaRecorder && this.props.onDeleted) {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
handleDone(e) {
|
||||
e.preventDefault();
|
||||
@@ -222,6 +227,8 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
}
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
} else if (this.props.onDeleted) {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
}
|
||||
initCanvas() {
|
||||
@@ -329,13 +336,29 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
formatMessage
|
||||
} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
if (this.props.holdMode) {
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: 'audio audio-hold' + (this.props.cancelling ? ' cancelling' : ''),
|
||||
children: [this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
|
||||
ref: this.canvasRef
|
||||
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
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 ? '松开手指,取消发送' : '手指上滑,取消发送'
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
}
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: "audio",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("a", {
|
||||
@@ -384,7 +407,9 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
}, void 0, true);
|
||||
}
|
||||
}
|
||||
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(AudioRecorder));
|
||||
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(AudioRecorder, {
|
||||
forwardRef: true
|
||||
}));
|
||||
|
||||
/***/ })
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user