message-bubble.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. <template>
  2. <div class="message-bubble" :class="[message.flow === 'in' ? '' : 'reverse']" ref="htmlRefHook">
  3. <img class="avatar" :src="message?.avatar || 'https://oss.dayaedu.com/news-info/07/1690787574969.png'" onerror="this.src='https://oss.dayaedu.com/news-info/07/1690787574969.png'" />
  4. <main class="message-area">
  5. <label class="name" v-if="message.flow === 'in' && message.conversationType === 'GROUP'">
  6. {{ message.nameCard || message.nick || message.from }}
  7. </label>
  8. <div :class="handleImageOrVideoBubbleStyle(message)" @click.prevent.right="toggleDialog">
  9. <div class="message-replie-area" :class="[message?.flow === 'in' ? '' : 'message-replies-area-reverse']" v-if="message?.cloudCustomData && referenceMessage && referenceMessage?.messageRootID" @click="showRepliesDialog(message, false)">
  10. <MessageReference :message="message" :referenceMessage="referenceMessage" :referenceForShow="referenceForShow" :url="url" :face="face" :allMessageID="allMessageID" type="reply" />
  11. </div>
  12. <slot />
  13. <div v-if="dropdown" ref="dropdownRef" class="dropdown-inner">
  14. <div class="dialog" :class="[message.flow === 'in' ? '' : 'dialog-right']" @click="dropdown = false">
  15. <slot name="dialog" />
  16. </div>
  17. </div>
  18. <MessageEmojiReact :message="message" type="content" v-if="needEmojiReact && isEmojiReactionInMessage(message)" />
  19. </div>
  20. </main>
  21. <label class="message-label fail" v-if="message.status === 'fail'" @click="resendMessage(message)"> ! </label>
  22. <!-- <label
  23. class="message-label"
  24. :class="readReceiptStyle(message)"
  25. v-if="showReadReceiptTag(message)"
  26. @click="showReadReceiptDialog(message)"
  27. >
  28. <span>{{ readReceiptCont(message) }}</span>
  29. </label> -->
  30. </div>
  31. <div class="message-reference-area" :class="[message.flow === 'in' ? '' : 'message-reference-area-reverse']" v-if="message?.cloudCustomData && referenceMessage && !referenceMessage?.messageRootID" @click="jumpToAim(referenceMessage)">
  32. <MessageReference :message="message" :referenceMessage="referenceMessage" :referenceForShow="referenceForShow" :url="url" :face="face" :allMessageID="allMessageID" type="reference" />
  33. </div>
  34. <label class="message-replies" :class="[message.flow === 'in' ? '' : 'message-replies-reverse']" v-if="replies?.length" @click="showRepliesDialog(message, true)">
  35. <i class="icon icon-msg-replies"></i>
  36. <span>{{ replies?.length + $t("TUIChat.条回复") }}</span>
  37. </label>
  38. </template>
  39. <script lang="ts">
  40. import { decodeText } from "../utils/decodeText";
  41. import constant from "../../constant";
  42. import { defineComponent, watchEffect, reactive, toRefs, ref, nextTick, watch } from "vue";
  43. import { onClickOutside, onLongPress, useElementBounding } from "@vueuse/core";
  44. import { deepCopy, JSONToObject } from "../utils/utils";
  45. import { handleErrorPrompts } from "../../utils";
  46. import TUIChat from "../index.vue";
  47. import MessageReference from "./message-reference.vue";
  48. import { Message } from "../interface";
  49. import { TUIEnv } from "../../../../TUIPlugin";
  50. import MessageEmojiReact from "./message-emoji-react.vue";
  51. import TIM from "../../../../TUICore/tim/index";
  52. const messageBubble = defineComponent({
  53. props: {
  54. data: {
  55. type: Object,
  56. default: () => ({}),
  57. },
  58. messagesList: {
  59. type: Array,
  60. default: () => [],
  61. },
  62. isH5: {
  63. type: Boolean,
  64. default: false,
  65. },
  66. needGroupReceipt: {
  67. type: Boolean,
  68. default: false,
  69. },
  70. needReplies: {
  71. type: Boolean,
  72. default: true,
  73. },
  74. flow: {
  75. type: String,
  76. default: "",
  77. },
  78. needEmojiReact: {
  79. type: Boolean,
  80. default: false,
  81. },
  82. },
  83. emits: ["jumpID", "resendMessage", "showReadReceiptDialog", "showRepliesDialog", "dropDownOpen"],
  84. components: {
  85. MessageReference,
  86. MessageEmojiReact,
  87. },
  88. setup(props: any, ctx: any) {
  89. const { t } = (window as any).TUIKitTUICore.config.i18n.useI18n();
  90. const { TUIServer } = TUIChat;
  91. const data = reactive({
  92. env: TUIEnv(),
  93. message: {} as Message,
  94. messagesList: [],
  95. show: false,
  96. type: {},
  97. referenceMessage: {},
  98. referenceForShow: {},
  99. allMessageID: "",
  100. needGroupReceipt: false,
  101. needReplies: true,
  102. replies: [],
  103. face: [],
  104. url: "",
  105. needEmojiReact: false,
  106. });
  107. watchEffect(() => {
  108. data.type = constant;
  109. data.messagesList = props.messagesList;
  110. data.needEmojiReact = props.needEmojiReact;
  111. data.message = deepCopy(data.messagesList?.find((item: any) => (item as any)?.ID === props.message?.ID) || props.data);
  112. data.needGroupReceipt = props.needGroupReceipt;
  113. data.needReplies = props.needReplies;
  114. if ((data.message as any).cloudCustomData) {
  115. const messageIDList: any[] = [];
  116. const cloudCustomData = JSONToObject((data.message as any).cloudCustomData);
  117. data.replies = cloudCustomData?.messageReplies?.replies || [];
  118. data.referenceMessage = cloudCustomData.messageReply ? cloudCustomData.messageReply : "";
  119. for (let index = 0; index < (data.messagesList as any).length; index++) {
  120. // To determine whether the referenced message is still in the message list, the corresponding field of the referenced message is displayed if it is in the message list. Otherwise, messageabstract/messagesender is displayed
  121. messageIDList.push((data.messagesList as any)[index].ID);
  122. (data as any).allMessageID = JSON.stringify(messageIDList);
  123. if ((data.messagesList as any)[index].ID === (data.referenceMessage as any)?.messageID) {
  124. data.referenceForShow = (data.messagesList as any)[index];
  125. if ((data.referenceMessage as any).messageType === constant.typeText) {
  126. (data as any).face = decodeText((data.referenceForShow as any).payload);
  127. }
  128. if ((data.referenceMessage as any).messageType === constant.typeFace) {
  129. (data as any).url = `https://web.sdk.qcloud.com/im/assets/face-elem/${(data.referenceForShow as any).payload.data}@2x.png`;
  130. }
  131. }
  132. }
  133. } else {
  134. data.replies = [];
  135. }
  136. });
  137. const htmlRefHook = ref<HTMLElement | null>(null);
  138. const dropdown = ref(false);
  139. const dropdownRef = ref(null);
  140. const toggleDialog = (e: any) => {
  141. dropdown.value = !dropdown.value;
  142. if (dropdown.value) {
  143. ctx.emit("dropDownOpen", dropdownRef);
  144. nextTick(() => {
  145. const dialogDom = (dropdownRef as any)?.value?.children[0];
  146. const dialogElement = document.getElementsByClassName("dialog-item")[0] as HTMLElement;
  147. const parentDom = (dropdownRef as any)?.value?.offsetParent;
  148. const parentBound = useElementBounding(parentDom);
  149. const messageListDom = document.getElementById("messageEle") as HTMLElement;
  150. const messageListBound = useElementBounding(messageListDom);
  151. const leftRange = messageListBound?.left?.value;
  152. const rightRange = messageListBound?.left?.value + (messageListDom as any).clientWidth - dialogDom.clientWidth + 76;
  153. const topRange = messageListBound?.top?.value;
  154. const bottomRange = messageListBound?.top?.value + (messageListDom as any).clientHeight - dialogDom.clientHeight;
  155. const { clientX, clientY } = e;
  156. if (data?.env?.isH5) {
  157. if (parentBound?.top?.value <= dialogElement?.clientHeight) {
  158. dialogDom.style.bottom = `-${dialogElement?.clientHeight}Px`;
  159. } else {
  160. if (data?.message?.flow === "in") {
  161. dialogDom.style.top = `-${dialogElement?.clientHeight - 20}Px`;
  162. } else {
  163. dialogDom.style.top = `-${dialogElement?.clientHeight}Px`;
  164. }
  165. }
  166. const centerWidth = parentBound?.left?.value + parentBound?.width?.value / 2;
  167. if (centerWidth > dialogElement.clientWidth / 2 && centerWidth < messageListDom?.clientWidth - dialogElement.clientWidth / 2) {
  168. dialogDom.style.left = "calc(50% - 135Px)";
  169. } else if (centerWidth <= dialogElement.clientWidth / 2) {
  170. dialogDom.style.left = "-20Px";
  171. } else {
  172. dialogDom.style.left = `-${dialogElement.clientWidth / 2 + 10}Px`;
  173. }
  174. return;
  175. }
  176. switch (true) {
  177. case clientX > leftRange && clientX < rightRange:
  178. dialogDom.style.left = `${Math.max(e.clientX - parentBound?.left?.value - 76, -40)}Px`;
  179. break;
  180. case clientX <= leftRange:
  181. dialogDom.style.left = "20Px";
  182. break;
  183. case clientX >= rightRange:
  184. dialogDom.style.right = `${Math.max(parentBound?.left?.value + parentDom?.clientWidth - e.clientX - 256, -10)}Px`;
  185. break;
  186. }
  187. switch (true) {
  188. case clientY > topRange && clientY < bottomRange:
  189. dialogDom.style.top = `${e.clientY - parentBound?.top?.value}Px`;
  190. dialogDom.style.cssText = dialogDom.style.cssText.replace("align-items:end;", "");
  191. break;
  192. case clientY <= topRange:
  193. dialogDom.style.top = "0Px";
  194. dialogDom.style.cssText = dialogDom.style.cssText.replace("align-items:end;", "");
  195. break;
  196. case clientY >= bottomRange:
  197. dialogDom.style.bottom = `${parentBound?.top?.value + parentDom?.clientHeight - e.clientY}Px`;
  198. dialogDom.style.cssText += "align-items:end;";
  199. break;
  200. }
  201. });
  202. }
  203. };
  204. const jumpToAim = (message: any) => {
  205. if ((data.referenceMessage as any)?.messageID && data.allMessageID.includes((data.referenceMessage as any)?.messageID)) {
  206. ctx.emit("jumpID", (data.referenceMessage as any).messageID);
  207. } else {
  208. const message = t("TUIChat.无法定位到原消息");
  209. handleErrorPrompts(message, props);
  210. }
  211. };
  212. onClickOutside(dropdownRef, () => {
  213. dropdown.value = false;
  214. });
  215. const toggleDialogH5 = (e: any) => {
  216. if (data?.env?.isH5) toggleDialog(e);
  217. return;
  218. };
  219. onLongPress(htmlRefHook, toggleDialogH5);
  220. const resendMessage = (message: any) => {
  221. ctx.emit("resendMessage", message);
  222. };
  223. const showReadReceiptTag = (message: any) => {
  224. if (message.flow === "out" && message.status === "success" && message.needReadReceipt) {
  225. return true;
  226. }
  227. return false;
  228. };
  229. const readReceiptStyle = (message: any) => {
  230. if (message?.readReceiptInfo?.isPeerRead || (message?.readReceiptInfo?.isPeerRead === undefined && message?.isPeerRead) || message?.readReceiptInfo?.unreadCount === 0) {
  231. return "";
  232. }
  233. return "unRead";
  234. };
  235. const readReceiptCont = (message: any) => {
  236. switch (message.conversationType) {
  237. case TUIServer.TUICore.TIM.TYPES.CONV_C2C:
  238. if (message?.readReceiptInfo?.isPeerRead || (message?.readReceiptInfo?.isPeerRead === undefined && message?.isPeerRead)) {
  239. return t("TUIChat.已读");
  240. }
  241. return t("TUIChat.未读");
  242. case TUIServer.TUICore.TIM.TYPES.CONV_GROUP:
  243. if (message.readReceiptInfo.unreadCount === 0) {
  244. return t("TUIChat.全部已读");
  245. }
  246. if (message.readReceiptInfo.readCount === 0 || (message.readReceiptInfo.unreadCount === undefined && message.readReceiptInfo.readCount === undefined)) {
  247. return t("TUIChat.未读");
  248. }
  249. return `${message.readReceiptInfo.readCount + t("TUIChat.人已读")}`;
  250. default:
  251. return "";
  252. }
  253. };
  254. const showReadReceiptDialog = (message: any) => {
  255. ctx.emit("showReadReceiptDialog", message, "receipt");
  256. };
  257. const showRepliesDialog = (message: any, isRoot: boolean) => {
  258. if (isRoot) {
  259. ctx.emit("showRepliesDialog", message, "replies");
  260. return;
  261. }
  262. if ((data.referenceMessage as any)?.messageRootID) {
  263. const message = data.messagesList?.find((item: Message) => item.ID === (data.referenceMessage as any)?.messageRootID);
  264. if (message) {
  265. ctx.emit("showRepliesDialog", message, "replies");
  266. return;
  267. } else {
  268. const message = t("TUIChat.无法定位到原消息");
  269. handleErrorPrompts(message, props);
  270. }
  271. }
  272. };
  273. const handleImageOrVideoBubbleStyle = (message: Message) => {
  274. const classNameList = ["content"];
  275. if (!message) return classNameList;
  276. classNameList.push(`content-${data.message.flow}`);
  277. if (data.message.type === TIM.TYPES.MSG_IMAGE && !isEmojiReactionInMessage(message)) {
  278. classNameList.push("content-image");
  279. }
  280. if (data.message.type === TIM.TYPES.MSG_VIDEO && !isEmojiReactionInMessage(message)) {
  281. classNameList.push("content-video");
  282. }
  283. return classNameList;
  284. };
  285. const isEmojiReactionInMessage = (message: Message) => {
  286. try {
  287. if (!message?.cloudCustomData) return;
  288. const reactList = JSONToObject(message?.cloudCustomData)?.messageReact?.reacts;
  289. if (!reactList || Object.keys(reactList).length === 0) return false;
  290. return true;
  291. } catch (err) {
  292. console.warn(err);
  293. return false;
  294. }
  295. };
  296. return {
  297. ...toRefs(data),
  298. toggleDialog,
  299. htmlRefHook,
  300. jumpToAim,
  301. dropdown,
  302. dropdownRef,
  303. resendMessage,
  304. showReadReceiptTag,
  305. readReceiptStyle,
  306. readReceiptCont,
  307. showReadReceiptDialog,
  308. showRepliesDialog,
  309. handleImageOrVideoBubbleStyle,
  310. isEmojiReactionInMessage,
  311. TIM,
  312. };
  313. },
  314. });
  315. export default messageBubble;
  316. </script>
  317. <style lang="scss" scoped>
  318. @import url("../../../styles/common.scss");
  319. @import url("../../../styles/icon.scss");
  320. .reverse {
  321. flex-direction: row-reverse;
  322. justify-content: flex-start;
  323. }
  324. .avatar {
  325. width: 36px;
  326. height: 36px;
  327. border-radius: 5px;
  328. }
  329. .message-bubble {
  330. width: 100%;
  331. display: flex;
  332. padding-bottom: 5px;
  333. }
  334. .line-left {
  335. border: 1px solid rgba(0, 110, 255, 0.5);
  336. }
  337. .message-reference-area {
  338. display: flex;
  339. background: #f2f2f2;
  340. border-radius: 0.5rem;
  341. border-radius: 0.63rem;
  342. align-self: start;
  343. margin-left: 44px;
  344. margin-right: 8px;
  345. &-show {
  346. width: 100%;
  347. display: flex;
  348. flex-direction: inherit;
  349. justify-content: center;
  350. padding: 6px;
  351. p {
  352. font-family: PingFangSC-Regular;
  353. font-weight: 400;
  354. font-size: 0.88rem;
  355. color: #999999;
  356. letter-spacing: 0;
  357. word-break: keep-all;
  358. padding-right: 5px;
  359. }
  360. span {
  361. height: 1.25rem;
  362. font-family: PingFangSC-Regular;
  363. font-weight: 400;
  364. font-size: 0.88rem;
  365. color: #999999;
  366. letter-spacing: 0;
  367. display: inline-block;
  368. }
  369. }
  370. }
  371. .message-replies {
  372. display: flex;
  373. align-self: start;
  374. margin-left: 44px;
  375. margin-right: 8px;
  376. padding: 2px;
  377. color: #999999;
  378. font-size: 10px;
  379. i {
  380. margin: 4px;
  381. }
  382. span {
  383. line-height: 20px;
  384. }
  385. }
  386. .message-reference-area-reverse,
  387. .message-replies-reverse {
  388. align-self: end;
  389. margin-right: 44px;
  390. margin-left: 8px;
  391. }
  392. .message-img {
  393. max-width: min(calc(100vw - 180px), 300px);
  394. max-height: min(calc(100vw - 180px), 300px);
  395. }
  396. .message-video-cover {
  397. display: inline-block;
  398. position: relative;
  399. &::before {
  400. position: absolute;
  401. z-index: 1;
  402. content: "";
  403. width: 0px;
  404. height: 0px;
  405. border: 15px solid transparent;
  406. border-left: 20px solid #ffffff;
  407. top: 0;
  408. left: 0;
  409. bottom: 0;
  410. right: 0;
  411. margin: auto;
  412. }
  413. }
  414. .message-videoimg {
  415. max-width: min(calc(100vw - 160px), 300px);
  416. max-height: min(calc(100vw - 160px), 300px);
  417. }
  418. .face-box {
  419. display: flex;
  420. align-items: center;
  421. }
  422. .text-img {
  423. width: 20px;
  424. height: 20px;
  425. }
  426. .message-audio {
  427. padding-left: 10px;
  428. display: flex;
  429. align-items: center;
  430. position: relative;
  431. .icon {
  432. margin: 0 4px;
  433. }
  434. audio {
  435. width: 0;
  436. height: 0;
  437. }
  438. }
  439. .reserve {
  440. flex-direction: row-reverse;
  441. }
  442. .message-area {
  443. max-width: calc(100% - 54px);
  444. position: relative;
  445. display: flex;
  446. flex-direction: column;
  447. padding: 0 8px;
  448. .name {
  449. padding-bottom: 4px;
  450. font-weight: 400;
  451. font-size: 0.8rem;
  452. color: #999999;
  453. letter-spacing: 0;
  454. }
  455. .reference-content {
  456. padding: 12px;
  457. font-weight: 400;
  458. font-size: 14px;
  459. color: burlywood;
  460. letter-spacing: 0;
  461. word-wrap: break-word;
  462. word-break: break-all;
  463. animation: reference 800ms;
  464. }
  465. @-webkit-keyframes reference {
  466. from {
  467. opacity: 1;
  468. }
  469. 50% {
  470. background-color: #ff9c19;
  471. }
  472. to {
  473. opacity: 1;
  474. }
  475. }
  476. @keyframes reference {
  477. from {
  478. opacity: 1;
  479. }
  480. 50% {
  481. background-color: #ff9c19;
  482. }
  483. to {
  484. opacity: 1;
  485. }
  486. }
  487. .content {
  488. padding: 12px;
  489. font-weight: 400;
  490. font-size: 14px;
  491. color: #000000;
  492. letter-spacing: 0;
  493. word-wrap: break-word;
  494. word-break: break-all;
  495. width: fit-content;
  496. &-in {
  497. background: #fbfbfb;
  498. border-radius: 10px 10px 10px 10px;
  499. }
  500. &-out {
  501. background: #FFF;
  502. border-radius: 10px 0px 10px 10px;
  503. }
  504. &-image {
  505. padding: 0px;
  506. height: fit-content;
  507. border-radius: 10px 0px 10px 10px;
  508. }
  509. &-video {
  510. padding: 0px;
  511. height: fit-content;
  512. background: transparent;
  513. border-radius: 10px;
  514. }
  515. }
  516. }
  517. .message-label {
  518. align-self: flex-end;
  519. font-family: PingFangSC-Regular;
  520. font-weight: 400;
  521. font-size: 12px;
  522. color: #b6b8ba;
  523. word-break: keep-all;
  524. }
  525. .fail {
  526. width: 15px;
  527. height: 15px;
  528. border-radius: 15px;
  529. background: red;
  530. color: #ffffff;
  531. display: flex;
  532. justify-content: center;
  533. align-items: center;
  534. }
  535. .unRead {
  536. color: #679ce1;
  537. }
  538. .dropdown-inner {
  539. position: absolute;
  540. width: 100%;
  541. height: 100%;
  542. left: 0;
  543. top: 0;
  544. }
  545. .dialog {
  546. position: absolute;
  547. z-index: 1;
  548. display: flex;
  549. flex-direction: row;
  550. width: fit-content;
  551. &-right {
  552. right: 0;
  553. }
  554. }
  555. </style>