第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>
580 lines
18 KiB
JavaScript
580 lines
18 KiB
JavaScript
import React from 'react';
|
||
import { defineMessages } from 'react-intl';
|
||
|
||
import { Drafty, TheCard } from 'tinode-sdk';
|
||
|
||
import AudioPlayer from '../widgets/audio-player.jsx'
|
||
import CallMessage from '../widgets/call-message.jsx'
|
||
import CallStatus from '../widgets/call-status.jsx';
|
||
import InlineVideo from '../widgets/inline-video.jsx';
|
||
import LazyImage from '../widgets/lazy-image.jsx';
|
||
import TheCardMini from '../widgets/the-card-mini.jsx';
|
||
import UploadingImage from '../widgets/uploading-image.jsx'
|
||
|
||
import { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO,
|
||
REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js';
|
||
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
|
||
import { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js';
|
||
|
||
const messages = defineMessages({
|
||
drafty_form: {
|
||
id: 'drafty_form',
|
||
defaultMessage: 'Form: ',
|
||
description: 'Comment for form in Drafty'
|
||
},
|
||
drafty_attachment: {
|
||
id: 'drafty_attachment',
|
||
defaultMessage: 'Attachment',
|
||
description: 'Comment for attachment in Drafty'
|
||
},
|
||
drafty_image: {
|
||
id: 'drafty_image',
|
||
defaultMessage: 'Picture',
|
||
description: 'Comment for embedded images in Drafty'
|
||
},
|
||
drafty_video: {
|
||
id: 'drafty_video',
|
||
defaultMessage: 'Video recording',
|
||
description: 'Comment for videos embedded in Drafty'
|
||
},
|
||
drafty_unknown: {
|
||
id: 'drafty_unknown',
|
||
defaultMessage: 'Unsupported',
|
||
description: 'Unsupported entity in drafty'
|
||
}
|
||
});
|
||
|
||
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
|
||
// 'this' must contain:
|
||
// viewportWidth:
|
||
// authorizeURL:
|
||
// onHandleClick:
|
||
export function fullFormatter(style, data, values, key, stack) {
|
||
if (stack.includes('QQ')) {
|
||
return quoteFormatter.call(this, style, data, values, key);
|
||
}
|
||
|
||
if (!style) {
|
||
// Unformatted.
|
||
return values;
|
||
}
|
||
|
||
let el = Drafty.tagName(style);
|
||
let attr = Drafty.attrValue(style, data) || {};
|
||
attr.key = key;
|
||
switch (style) {
|
||
case 'AU':
|
||
// Show audio player.
|
||
if (attr.src) {
|
||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));
|
||
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.
|
||
values = null;
|
||
break;
|
||
case 'BR':
|
||
values = null;
|
||
break;
|
||
case 'EX':
|
||
// Ignore.
|
||
break;
|
||
case 'HL':
|
||
// Highlighted text. Assign class name.
|
||
attr.className = 'highlight';
|
||
break;
|
||
case 'HD':
|
||
el = null;
|
||
values = null;
|
||
break;
|
||
case 'IM':
|
||
// Additional processing for images
|
||
el = handleImageData.call(this, el, data, attr);
|
||
// Image element cannot have content.
|
||
values = null;
|
||
break;
|
||
case 'BN':
|
||
// Button
|
||
attr.onClick = (e) => this.onHandleClick(e, 'form_button');
|
||
let inner = React.Children.map(values, (child) => {
|
||
return typeof child == 'string' ? child : undefined;
|
||
});
|
||
if (!inner || inner.length == 0) {
|
||
inner = [attr.name]
|
||
}
|
||
// Get text which will be sent back when the button is clicked.
|
||
attr['data-title'] = inner.join('');
|
||
break;
|
||
case 'LN':
|
||
if (attr) {
|
||
// Ensure only safe URL schemes are clickable.
|
||
attr.href = typeof attr.href == 'string' ? sanitizeUrl(attr.href, CLICKABLE_URL_SCHEMES) : '';
|
||
}
|
||
break;
|
||
case 'MN':
|
||
// Mention
|
||
attr.className = 'mention'
|
||
if (data) {
|
||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||
}
|
||
break;
|
||
case 'FM':
|
||
// Form
|
||
attr.className = 'bot-form';
|
||
break;
|
||
case 'RW':
|
||
// Form element formatting is dependent on element content.
|
||
break;
|
||
case 'QQ':
|
||
// Quote/citation.
|
||
attr.className = 'reply-quote'
|
||
attr.onClick = (e) => this.onHandleClick(e, 'quote');
|
||
break;
|
||
case 'TC':
|
||
// TheCard
|
||
el = TheCardMini;
|
||
attr.content = data;
|
||
attr.authorizeURL = this.authorizeURL;
|
||
attr.onChatClick = (e) => this.onHandleClick(e, 'contact_chat');
|
||
attr.onFindClick = (e) => this.onHandleClick(e, 'contact_find');
|
||
values = null;
|
||
break;
|
||
case 'VC':
|
||
el = CallMessage;
|
||
// Video call messages do not have content.
|
||
values = null;
|
||
if (data) {
|
||
attr.callState = data.state;
|
||
attr.incoming = data.incoming;
|
||
attr.duration = data.duration;
|
||
}
|
||
break;
|
||
case 'VD':
|
||
// Additional processing for videos.
|
||
el = handleVideoData.call(this, el, data, attr);
|
||
// Video element cannot have content.
|
||
values = null;
|
||
break;
|
||
default:
|
||
if (!el) {
|
||
// Unknown element.
|
||
el = React.Fragment;
|
||
attr = {key: key};
|
||
// Generate comment for unknown element.
|
||
let body = values;
|
||
if (!Array.isArray(values) || !values.join('').trim()) {
|
||
body = [React.createElement('span',
|
||
{key: 'x1', className: 'gray'},
|
||
this.formatMessage(messages.drafty_unknown)
|
||
)];
|
||
}
|
||
values = [React.createElement('i',
|
||
{key: 'x0', className: 'material-icons gray'},
|
||
'extension'
|
||
), ' '].concat(body);
|
||
}
|
||
break;
|
||
}
|
||
if (!el) {
|
||
return values;
|
||
}
|
||
return React.createElement(el, attr, values);
|
||
}
|
||
|
||
// Additional processing of image data.
|
||
function handleImageData(el, data, attr) {
|
||
if (!data) {
|
||
attr.src = 'img/broken_image.png';
|
||
attr.style = {
|
||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||
};
|
||
return el;
|
||
}
|
||
|
||
attr.className = 'inline-image';
|
||
const dim = fitImageSize(data.width, data.height,
|
||
this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :
|
||
REM_SIZE * 34.5, REM_SIZE * 24, false) ||
|
||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||
attr.style = {
|
||
width: dim.dstWidth + 'px',
|
||
height: dim.dstHeight + 'px',
|
||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||
minWidth: dim.dstWidth + 'px',
|
||
minHeight: dim.dstHeight + 'px'
|
||
};
|
||
if (!Drafty.isProcessing(data)) {
|
||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||
attr.alt = data.name;
|
||
if (attr.src) {
|
||
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||
// Allow previews for large enough images.
|
||
attr.onClick = (e) => this.onHandleClick(e, 'image');
|
||
attr.className += ' image-clickable';
|
||
}
|
||
attr.loading = 'lazy';
|
||
} else {
|
||
attr.src = null;
|
||
}
|
||
} else {
|
||
// Use custom element instead of <img> or <video>.
|
||
el = UploadingImage;
|
||
}
|
||
|
||
return el;
|
||
}
|
||
|
||
// Additional processing of attached video data.
|
||
function handleVideoData(el, data, attr) {
|
||
if (!data) {
|
||
attr.src = 'img/broken_video.png';
|
||
attr.style = {
|
||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||
};
|
||
return el;
|
||
}
|
||
attr.className = 'inline-image';
|
||
const dim = fitImageSize(data.width, data.height,
|
||
this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :
|
||
REM_SIZE * 34.5, REM_SIZE * 24, false) ||
|
||
{dstWidth: NO_DIMENSIONS_VIDEO, dstHeight: NO_DIMENSIONS_VIDEO};
|
||
attr.style = {
|
||
width: dim.dstWidth + 'px',
|
||
height: dim.dstHeight + 'px',
|
||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||
minWidth: dim.dstWidth + 'px',
|
||
minHeight: dim.dstHeight + 'px'
|
||
};
|
||
if (!Drafty.isProcessing(data)) {
|
||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||
attr.alt = data.name;
|
||
if (data.ref || data.val) {
|
||
attr.onClick = (e) => this.onHandleClick(e, 'video');
|
||
attr.loading = 'lazy';
|
||
}
|
||
el = InlineVideo;
|
||
} else {
|
||
// Use custom element instead of <img> or <video>.
|
||
el = UploadingImage;
|
||
}
|
||
|
||
return el;
|
||
}
|
||
|
||
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
|
||
// 'this' must contain:
|
||
// formatMessage: this.props.intl.formatMessage
|
||
// messages: formatjs messages defined with defineMessages.
|
||
export function previewFormatter(style, data, values, key) {
|
||
if (!style) {
|
||
// Unformatted.
|
||
return values;
|
||
}
|
||
|
||
let el = Drafty.tagName(style);
|
||
const attr = { key: key };
|
||
switch (style) {
|
||
case 'AU':
|
||
// Voicemail as '[mic] 0:00'.
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'au', className: 'material-icons'}, 'mic'),
|
||
' ', secondsToTime(data.duration/1000)];
|
||
break;
|
||
case 'BR':
|
||
// Replace new line with a space.
|
||
el = React.Fragment;
|
||
values = [' '];
|
||
break;
|
||
case 'HL':
|
||
// Make highlight less prominent in preview.
|
||
attr.className = 'highlight preview';
|
||
break;
|
||
case 'LN':
|
||
case 'MN':
|
||
// Disable links in previews.
|
||
el = 'span';
|
||
break;
|
||
case 'IM':
|
||
// Replace image with '[icon] Image'.
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'im', className: 'material-icons'}, 'photo'),
|
||
' ', this.formatMessage(messages.drafty_image)];
|
||
break;
|
||
case 'BN':
|
||
el = 'span';
|
||
attr.className = 'flat-button faux';
|
||
break;
|
||
case 'FM':
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'fm', className: 'material-icons'}, 'dashboard'),
|
||
this.formatMessage(messages.drafty_form)].concat(' ', values || []);
|
||
break;
|
||
case 'RW':
|
||
el = React.Fragment;
|
||
break;
|
||
case 'EX':
|
||
if (data) {
|
||
if (Drafty.isFormResponseType(data.mime)) {
|
||
// Ignore JSON attachments: they are form response payloads.
|
||
return null;
|
||
}
|
||
// Clear payload.
|
||
delete data.val;
|
||
delete data.ref;
|
||
}
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'ex', className: 'material-icons'}, 'attachment'),
|
||
' ', this.formatMessage(messages.drafty_attachment)];
|
||
break;
|
||
case 'VC':
|
||
el = CallStatus;
|
||
if (data) {
|
||
attr.callState = data.state;
|
||
attr.incoming = data.incoming;
|
||
attr.duration = data.duration;
|
||
}
|
||
values = null;
|
||
break;
|
||
case 'QQ':
|
||
case 'HD':
|
||
el = null;
|
||
values = null;
|
||
break;
|
||
case 'TC':
|
||
// TheCard
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'tc', className: 'material-icons'}, 'contact_mail'), ' ',
|
||
TheCard.getFn(data) || this.formatMessage(messages.drafty_unknown)];
|
||
break;
|
||
case 'VD':
|
||
// Replace image with '[icon] Video'.
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'im', className: 'material-icons'}, 'play_circle_outline'),
|
||
' ', this.formatMessage(messages.drafty_video)];
|
||
break;
|
||
default:
|
||
if (!el) {
|
||
// Unknown element.
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'x0', className: 'material-icons gray'}, 'extension'),
|
||
' ', this.formatMessage(messages.drafty_unknown)];
|
||
}
|
||
break;
|
||
}
|
||
if (!el) {
|
||
return values;
|
||
}
|
||
return React.createElement(el, attr, values);
|
||
};
|
||
|
||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||
function inlineImageAttr(attr, data) {
|
||
attr.style = {
|
||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||
maxWidth: IMAGE_THUMBNAIL_DIM + 'px',
|
||
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||
}
|
||
attr.className = 'inline-image';
|
||
attr.alt = this.formatMessage(messages.drafty_image);
|
||
if (!data) {
|
||
attr.src = 'img/broken_image.png';
|
||
} else {
|
||
attr.src = attr.src || 'img/broken_image.png';
|
||
}
|
||
attr.title = attr.alt;
|
||
return attr;
|
||
}
|
||
|
||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||
function inlineVideoAttr(attr, data) {
|
||
const dim = fitImageSize(data.width, data.height, VIDEO_THUMBNAIL_WIDTH, IMAGE_THUMBNAIL_DIM);
|
||
attr.style = {
|
||
width: dim.width + 'px',
|
||
height: dim.height + 'px',
|
||
maxWidth: VIDEO_THUMBNAIL_WIDTH + 'px',
|
||
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||
}
|
||
attr.className = 'inline-image';
|
||
attr.alt = this.formatMessage(messages.drafty_video);
|
||
attr.title = attr.alt;
|
||
if (!data) {
|
||
attr.src = 'img/broken_video.png';
|
||
} else {
|
||
attr.src = attr.src || 'img/broken_video.png';
|
||
}
|
||
return attr;
|
||
}
|
||
|
||
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
|
||
// 'this' must contain:
|
||
// formatMessage: this.props.intl.formatMessage
|
||
// messages: formatjs messages defined with defineMessages.
|
||
// authorizeURL: this.props.tinode.authorizeURL
|
||
// onHandleClick: this.handleDraftyClick
|
||
function quoteFormatter(style, data, values, key) {
|
||
if (['BR', 'EX', 'IM', 'MN', 'VD'].includes(style)) {
|
||
let el = Drafty.tagName(style);
|
||
let attr = Drafty.attrValue(style, data) || {};
|
||
attr.key = key;
|
||
switch(style) {
|
||
case 'BR':
|
||
values = null;
|
||
break;
|
||
case 'IM':
|
||
attr = inlineImageAttr.call(this, attr, data);
|
||
values = [React.createElement('img', attr, null), ' ', attr.alt];
|
||
el = React.Fragment;
|
||
// Fragment attributes.
|
||
attr = {key: key};
|
||
break;
|
||
case 'VD':
|
||
attr = inlineVideoAttr.call(this, attr, data);
|
||
values = [React.createElement('img', attr, null), ' ', attr.alt];
|
||
el = React.Fragment;
|
||
// Fragment attributes.
|
||
attr = {key: key};
|
||
break;
|
||
case 'MN':
|
||
el = 'span';
|
||
attr.className = 'mention'
|
||
if (data) {
|
||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||
}
|
||
break;
|
||
case 'EX':
|
||
let fname;
|
||
if (data) {
|
||
if (Drafty.isFormResponseType(data.mime)) {
|
||
// Ignore JSON attachments: they are form response payloads.
|
||
return null;
|
||
}
|
||
fname = data.name;
|
||
// Clear payload.
|
||
delete data.val;
|
||
delete data.ref;
|
||
}
|
||
el = React.Fragment;
|
||
values = [React.createElement('i', {key: 'ex', className: 'material-icons'}, 'attachment'),
|
||
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
|
||
break;
|
||
}
|
||
return React.createElement(el, attr, values);
|
||
}
|
||
return previewFormatter.call(this, style, data, values, key);
|
||
}
|
||
|
||
// Create image thumbnail suitable for inclusion in a quote.
|
||
function quoteImageOrVideo(data, isVideo) {
|
||
let promise;
|
||
let bits, ref, mime;
|
||
if (isVideo) {
|
||
bits = data.preview;
|
||
mime = data.premime || 'image/jpeg';
|
||
ref = data.preref;
|
||
} else {
|
||
bits = data.val;
|
||
mime = data.mime;
|
||
ref = data.ref;
|
||
}
|
||
// Get the blob from the image data.
|
||
if (bits) {
|
||
const blob = base64ToBlob(bits, mime);
|
||
if (!blob) {
|
||
throw new Error("Invalid image");
|
||
}
|
||
promise = Promise.resolve(blob);
|
||
} else if (ref) {
|
||
promise = fetch(this.authorizeURL(sanitizeUrlForMime(ref, 'image')))
|
||
.then(evt => {
|
||
if (evt.ok) {
|
||
return evt.blob();
|
||
} else {
|
||
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||
}
|
||
});
|
||
} else {
|
||
throw new Error("Missing image data");
|
||
}
|
||
|
||
// Scale the blob.
|
||
return promise
|
||
.then(blob => {
|
||
// If it's an image, cut the square from the center of the image and shrink it.
|
||
// If it's a video, allow it to be rectantular.
|
||
return imageScaled(blob, isVideo ? VIDEO_THUMBNAIL_WIDTH : IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, !isVideo)
|
||
}).then(scaled => {
|
||
if (isVideo) {
|
||
data.premime = scaled.mime;
|
||
} else {
|
||
data.mime = scaled.mime;
|
||
}
|
||
data.size = scaled.blob.size;
|
||
data.width = scaled.width;
|
||
data.height = scaled.height;
|
||
delete data.ref;
|
||
delete data.preref;
|
||
// Keeping the original file name, if provided: ex.data.name;
|
||
|
||
data.src = URL.createObjectURL(scaled.blob);
|
||
return blobToBase64(scaled.blob);
|
||
}).then(b64 => {
|
||
if (isVideo) {
|
||
data.preview = b64.bits;
|
||
} else {
|
||
data.val = b64.bits;
|
||
}
|
||
return data;
|
||
}).catch(err => {
|
||
delete data.val;
|
||
delete data.preview;
|
||
delete data.src;
|
||
data.width = IMAGE_THUMBNAIL_DIM;
|
||
data.height = IMAGE_THUMBNAIL_DIM;
|
||
// Rethrow.
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
// Create a preview of a reply.
|
||
export function replyFormatter(style, data, values, key, stack) {
|
||
if (style == 'IM' || style == 'VD') {
|
||
const isImage = style == 'IM';
|
||
const attr = isImage ? inlineImageAttr.call(this, {key: key}, data) :
|
||
inlineVideoAttr.call(this, {key: key}, data);
|
||
|
||
let loadedPromise;
|
||
try {
|
||
loadedPromise = cancelablePromise(quoteImageOrVideo.call(this, data, style == 'VD'));
|
||
} catch (error) {
|
||
console.warn("Failed to quote image:", error.message);
|
||
loadedPromise = cancelablePromise(error);
|
||
}
|
||
attr.whenDone = loadedPromise;
|
||
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
|
||
return React.createElement(React.Fragment, {key: key}, values);
|
||
} else if (style == 'QQ') {
|
||
if (stack.includes('QQ')) {
|
||
// Quote inside quote when forwarding a message.
|
||
return React.createElement('span', {key: key},
|
||
[React.createElement('i', {key: 'qq', className: 'material-icons'}, 'format_quote'), ' ']);
|
||
}
|
||
|
||
const attr = Drafty.attrValue('QQ', data) || {};
|
||
attr.key = key;
|
||
attr.className = 'reply-quote'
|
||
return React.createElement(Drafty.tagName('QQ'), attr, values);
|
||
}
|
||
return quoteFormatter.call(this, style, data, values, key);
|
||
}
|