diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss index 69109a58..1a35234e 100644 --- a/src/app/molecules/message/Message.scss +++ b/src/app/molecules/message/Message.scss @@ -166,6 +166,16 @@ } .message__reactions { display: flex; + flex-wrap: wrap; + + & .ic-btn-surface { + display: none; + padding: var(--sp-ultra-tight); + margin-top: var(--sp-extra-tight); + } + &:hover .ic-btn-surface { + display: block; + } } .msg__reaction { margin: var(--sp-extra-tight) var(--sp-extra-tight) 0 0; diff --git a/src/app/organisms/channel/ChannelViewContent.jsx b/src/app/organisms/channel/ChannelViewContent.jsx index 7a8a2a31..2725f0e2 100644 --- a/src/app/organisms/channel/ChannelViewContent.jsx +++ b/src/app/organisms/channel/ChannelViewContent.jsx @@ -7,10 +7,11 @@ import dateFormat from 'dateformat'; import initMatrix from '../../../client/initMatrix'; import cons from '../../../client/state/cons'; -import { redact } from '../../../client/action/room'; +import { redactEvent, sendReaction } from '../../../client/action/roomTimeline'; import { getUsername, doesRoomHaveUnread } from '../../../util/matrixUtil'; import colorMXID from '../../../util/colorMXID'; import { diffMinutes, isNotInSameDay } from '../../../util/common'; +import { openEmojiBoard } from '../../../client/action/navigation'; import Divider from '../../atoms/divider/Divider'; import Avatar from '../../atoms/avatar/Avatar'; @@ -30,12 +31,325 @@ import ChannelIntro from '../../molecules/channel-intro/ChannelIntro'; import TimelineChange from '../../molecules/message/TimelineChange'; import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg'; +import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg'; import BinIC from '../../../../public/res/ic/outlined/bin.svg'; import { parseReply, parseTimelineChange } from './common'; const MAX_MSG_DIFF_MINUTES = 5; +function genPlaceholders() { + return ( + <> + + + + + ); +} + +function isMedia(mE) { + return ( + mE.getContent()?.msgtype === 'm.file' + || mE.getContent()?.msgtype === 'm.image' + || mE.getContent()?.msgtype === 'm.audio' + || mE.getContent()?.msgtype === 'm.video' + ); +} + +function genMediaContent(mE) { + const mx = initMatrix.matrixClient; + const mContent = mE.getContent(); + let mediaMXC = mContent.url; + let thumbnailMXC = mContent?.info?.thumbnail_url; + const isEncryptedFile = typeof mediaMXC === 'undefined'; + if (isEncryptedFile) mediaMXC = mContent.file.url; + + switch (mE.getContent()?.msgtype) { + case 'm.file': + return ( + + ); + case 'm.image': + return ( + + ); + case 'm.audio': + return ( + + ); + case 'm.video': + if (typeof thumbnailMXC === 'undefined') { + thumbnailMXC = mContent.info?.thumbnail_file?.url || null; + } + return ( + + ); + default: + return 'Unable to attach media file!'; + } +} + +function genChannelIntro(mEvent, roomTimeline) { + const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; + return ( + + ); +} + +function getMyEmojiEventId(emojiKey, eventId, roomTimeline) { + const mx = initMatrix.matrixClient; + const rEvents = roomTimeline.reactionTimeline.get(eventId); + let rEventId = null; + rEvents?.find((rE) => { + if (rE.getRelation() === null) return false; + if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) { + rEventId = rE.getId(); + return true; + } + return false; + }); + return rEventId; +} + +function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) { + const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline); + if (typeof myAlreadyReactEventId === 'string') { + if (myAlreadyReactEventId.indexOf('~') === 0) return; + redactEvent(roomId, myAlreadyReactEventId); + return; + } + sendReaction(roomId, eventId, emojiKey); +} + +function pickEmoji(e, roomId, eventId, roomTimeline) { + openEmojiBoard({ + x: e.detail ? e.clientX : '50%', + y: e.detail ? e.clientY : '50%', + detail: e.detail, + }, (emoji) => { + toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline); + e.target.click(); + }); +} + +function genMessage(roomId, prevMEvent, mEvent, roomTimeline, viewEvent) { + const mx = initMatrix.matrixClient; + const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; + const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); + + const isContentOnly = ( + prevMEvent !== null + && prevMEvent.getType() !== 'm.room.member' + && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES + && prevMEvent.getSender() === mEvent.getSender() + ); + + let content = mEvent.getContent().body; + if (typeof content === 'undefined') return null; + let reply = null; + let reactions = null; + let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html'; + const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; + const isEdited = roomTimeline.editedTimeline.has(mEvent.getId()); + const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId()); + + if (isReply) { + const parsedContent = parseReply(content); + + if (parsedContent !== null) { + const username = getUsername(parsedContent.userId); + reply = { + userId: parsedContent.userId, + color: colorMXID(parsedContent.userId), + to: username, + content: parsedContent.replyContent, + }; + content = parsedContent.content; + } + } + + if (isEdited) { + const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); + const latestEdited = editedList[editedList.length - 1]; + if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null; + const latestEditBody = latestEdited.getContent()['m.new_content'].body; + const parsedEditedContent = parseReply(latestEditBody); + isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html'; + if (parsedEditedContent === null) { + content = latestEditBody; + } else { + content = parsedEditedContent.content; + } + } + + if (haveReactions) { + reactions = []; + roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => { + if (rEvent.getRelation() === null) return; + function alreadyHaveThisReaction(rE) { + for (let i = 0; i < reactions.length; i += 1) { + if (reactions[i].key === rE.getRelation().key) return true; + } + return false; + } + if (alreadyHaveThisReaction(rEvent)) { + for (let i = 0; i < reactions.length; i += 1) { + if (reactions[i].key === rEvent.getRelation().key) { + reactions[i].users.push(rEvent.getSender()); + if (reactions[i].isActive !== true) { + const myUserId = initMatrix.matrixClient.getUserId(); + reactions[i].isActive = rEvent.getSender() === myUserId; + if (reactions[i].isActive) reactions[i].id = rEvent.getId(); + } + break; + } + } + } else { + reactions.push({ + id: rEvent.getId(), + key: rEvent.getRelation().key, + users: [rEvent.getSender()], + isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()), + }); + } + }); + } + + const senderMXIDColor = colorMXID(mEvent.sender.userId); + const userAvatar = isContentOnly ? null : ( + + ); + const userHeader = isContentOnly ? null : ( + + ); + const userReply = reply === null ? null : ( + + ); + const userContent = ( + + ); + const userReactions = reactions === null ? null : ( + + { + reactions.map((reaction) => ( + { + toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline); + }} + /> + )) + } + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + + ); + const userOptions = ( + + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + { + viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); + }} + src={ReplyArrowIC} + size="extra-small" + tooltip="Reply" + /> + {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( + { + if (window.confirm('Are you sure you want to delete this event')) { + redactEvent(roomId, mEvent.getId()); + } + }} + src={BinIC} + size="extra-small" + tooltip="Delete" + /> + )} + + ); + + const myMessageEl = ( + + ); + return myMessageEl; +} + let wasAtBottom = true; function ChannelViewContent({ roomId, roomTimeline, timelineScroll, viewEvent, @@ -121,86 +435,7 @@ function ChannelViewContent({ let prevMEvent = null; function renderMessage(mEvent) { - function isMedia(mE) { - return ( - mE.getContent()?.msgtype === 'm.file' - || mE.getContent()?.msgtype === 'm.image' - || mE.getContent()?.msgtype === 'm.audio' - || mE.getContent()?.msgtype === 'm.video' - ); - } - function genMediaContent(mE) { - const mContent = mE.getContent(); - let mediaMXC = mContent.url; - let thumbnailMXC = mContent?.info?.thumbnail_url; - const isEncryptedFile = typeof mediaMXC === 'undefined'; - if (isEncryptedFile) mediaMXC = mContent.file.url; - - switch (mE.getContent()?.msgtype) { - case 'm.file': - return ( - - ); - case 'm.image': - return ( - - ); - case 'm.audio': - return ( - - ); - case 'm.video': - if (typeof thumbnailMXC === 'undefined') { - thumbnailMXC = mContent.info?.thumbnail_file?.url || null; - } - return ( - - ); - default: - return 'Unable to attach media file!'; - } - } - - if (mEvent.getType() === 'm.room.create') { - const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; - return ( - - ); - } + if (mEvent.getType() === 'm.room.create') return genChannelIntro(mEvent, roomTimeline); if ( mEvent.getType() !== 'm.room.message' && mEvent.getType() !== 'm.room.encrypted' @@ -217,173 +452,16 @@ function ChannelViewContent({ } if (mEvent.getType() !== 'm.room.member') { - const isContentOnly = ( - prevMEvent !== null - && prevMEvent.getType() !== 'm.room.member' - && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES - && prevMEvent.getSender() === mEvent.getSender() - ); - - const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; - const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); - - let content = mEvent.getContent().body; - if (typeof content === 'undefined') return null; - let reply = null; - let reactions = null; - let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html'; - const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; - const isEdited = roomTimeline.editedTimeline.has(mEvent.getId()); - const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId()); - - if (isReply) { - const parsedContent = parseReply(content); - - if (parsedContent !== null) { - const username = getUsername(parsedContent.userId); - reply = { - userId: parsedContent.userId, - color: colorMXID(parsedContent.userId), - to: username, - content: parsedContent.replyContent, - }; - content = parsedContent.content; - } - } - - if (isEdited) { - const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); - const latestEdited = editedList[editedList.length - 1]; - if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null; - const latestEditBody = latestEdited.getContent()['m.new_content'].body; - const parsedEditedContent = parseReply(latestEditBody); - isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html'; - if (parsedEditedContent === null) { - content = latestEditBody; - } else { - content = parsedEditedContent.content; - } - } - - if (haveReactions) { - reactions = []; - roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => { - if (rEvent.getRelation() === null) return; - function alreadyHaveThisReaction(rE) { - for (let i = 0; i < reactions.length; i += 1) { - if (reactions[i].key === rE.getRelation().key) return true; - } - return false; - } - if (alreadyHaveThisReaction(rEvent)) { - for (let i = 0; i < reactions.length; i += 1) { - if (reactions[i].key === rEvent.getRelation().key) { - reactions[i].users.push(rEvent.getSender()); - if (reactions[i].isActive !== true) { - const myUserId = initMatrix.matrixClient.getUserId(); - reactions[i].isActive = rEvent.getSender() === myUserId; - } - break; - } - } - } else { - reactions.push({ - id: rEvent.getId(), - key: rEvent.getRelation().key, - users: [rEvent.getSender()], - isActive: (rEvent.getSender() === initMatrix.matrixClient.getUserId()), - }); - } - }); - } - - const senderMXIDColor = colorMXID(mEvent.sender.userId); - const userAvatar = isContentOnly ? null : ( - - ); - const userHeader = isContentOnly ? null : ( - - ); - const userReply = reply === null ? null : ( - - ); - const userContent = ( - - ); - const userReactions = reactions === null ? null : ( - - { - reactions.map((reaction) => ( - alert('Sending reactions is yet to be implemented.')} - /> - )) - } - - ); - const userOptions = ( - - { - viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); - }} - src={ReplyArrowIC} - size="extra-small" - tooltip="Reply" - /> - {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( - { - if (window.confirm('Are you sure you want to delete this event')) { - redact(roomId, mEvent.getId()); - } - }} - src={BinIC} - size="extra-small" - tooltip="Delete" - /> - )} - - ); - - const myMessageEl = ( - - ); - + const messageComp = genMessage(roomId, prevMEvent, mEvent, roomTimeline, viewEvent); prevMEvent = mEvent; - return myMessageEl; + return ( + + {divider} + {messageComp} + + ); } + prevMEvent = mEvent; const timelineChange = parseTimelineChange(mEvent); if (timelineChange === null) return null; @@ -400,30 +478,11 @@ function ChannelViewContent({ ); } - const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; return (
- { - roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && ( - <> - - - - - ) - } - { - roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && ( - - ) - } + { roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && genPlaceholders() } + { roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && genChannelIntro(undefined, roomTimeline)} { roomTimeline.timeline.map(renderMessage) }
@@ -432,14 +491,7 @@ function ChannelViewContent({ ChannelViewContent.propTypes = { roomId: PropTypes.string.isRequired, roomTimeline: PropTypes.shape({}).isRequired, - timelineScroll: PropTypes.shape({ - reachBottom: PropTypes.func, - autoReachBottom: PropTypes.func, - tryRestoringScroll: PropTypes.func, - enableSmoothScroll: PropTypes.func, - disableSmoothScroll: PropTypes.func, - isScrollable: PropTypes.func, - }).isRequired, + timelineScroll: PropTypes.shape({}).isRequired, viewEvent: PropTypes.shape({}).isRequired, }; diff --git a/src/app/organisms/channel/ChannelViewInput.jsx b/src/app/organisms/channel/ChannelViewInput.jsx index 12b6e6be..d22cd464 100644 --- a/src/app/organisms/channel/ChannelViewInput.jsx +++ b/src/app/organisms/channel/ChannelViewInput.jsx @@ -16,10 +16,8 @@ import colorMXID from '../../../util/colorMXID'; import Text from '../../atoms/text/Text'; import RawIcon from '../../atoms/system-icons/RawIcon'; import IconButton from '../../atoms/button/IconButton'; -import ContextMenu from '../../atoms/context-menu/ContextMenu'; import ScrollView from '../../atoms/scroll/ScrollView'; import { MessageReply } from '../../molecules/message/Message'; -import EmojiBoard from '../emoji-board/EmojiBoard'; import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; @@ -303,9 +301,9 @@ function ChannelViewInput({ { openEmojiBoard({ - x: e.detail ? e.clientX + 40 : '10%', - y: e.detail ? e.clientY - 240 : 300, - isReverse: !e.detail, + x: '10%', + y: 300, + isReverse: true, detail: e.detail, }, addEmoji); }} diff --git a/src/app/organisms/emoji-board/EmojiBoardOpener.jsx b/src/app/organisms/emoji-board/EmojiBoardOpener.jsx index a8328324..b2ecc0c4 100644 --- a/src/app/organisms/emoji-board/EmojiBoardOpener.jsx +++ b/src/app/organisms/emoji-board/EmojiBoardOpener.jsx @@ -61,8 +61,9 @@ function EmojiBoardOpener() { onClick={toggleMenu} type="button" style={{ - width: '0', - height: '0', + width: '32px', + height: '32px', + backgroundColor: 'transparent', position: 'absolute', top: 0, left: 0, diff --git a/src/client/action/room.js b/src/client/action/room.js index 5bd6777a..ecf58a79 100644 --- a/src/client/action/room.js +++ b/src/client/action/room.js @@ -189,19 +189,7 @@ async function invite(roomId, userId) { } } -async function redact(roomId, eventId, reason) { - const mx = initMatrix.matrixClient; - - try { - await mx.redactEvent(roomId, eventId, undefined, typeof reason === 'undefined' ? undefined : { reason }); - return true; - } catch (e) { - throw new Error(e); - } -} - export { join, leave, create, invite, - redact, }; diff --git a/src/client/action/roomTimeline.js b/src/client/action/roomTimeline.js new file mode 100644 index 00000000..8297bf03 --- /dev/null +++ b/src/client/action/roomTimeline.js @@ -0,0 +1,33 @@ +import initMatrix from '../initMatrix'; + +async function redactEvent(roomId, eventId, reason) { + const mx = initMatrix.matrixClient; + + try { + await mx.redactEvent(roomId, eventId, undefined, typeof reason === 'undefined' ? undefined : { reason }); + return true; + } catch (e) { + throw new Error(e); + } +} + +async function sendReaction(roomId, toEventId, reaction) { + const mx = initMatrix.matrixClient; + + try { + await mx.sendEvent(roomId, 'm.reaction', { + 'm.relates_to': { + event_id: toEventId, + key: reaction, + rel_type: 'm.annotation', + }, + }); + } catch (e) { + throw new Error(e); + } +} + +export { + redactEvent, + sendReaction, +};