From eda1bebc5f2a3df0271f063a99ddfc3004b38381 Mon Sep 17 00:00:00 2001 From: Alex Jin <57976479+Silver-IT@users.noreply.github.com> Date: Thu, 20 Jun 2024 11:18:08 +0800 Subject: [PATCH] Implement pinned messages (#2011) * chore: do not open new tab when user is signed in * fix: wrong position of parentheses * feat: implement pinned message tooltip * chore: revert unrelated changes * feat: created PinnedMessages component * chore: removed ProfileCard inside pinned message * chore: optimized codes * feat: add tooltip for unpin button * fix: css space issue between user avatar and sender name in message base component * chore: reverted change which is not related to the current issue * chore: do not open new tab when user is signed in * feat: improved style of pin button * feat: improve css for pinned messages * feat: contract implemented for pinning message * chore: improved getters name to keep consistency * feat: linked pinned messages to the component * chore: do not open new tab when user is signed in * chore: reverted unrelated changes * feat: updated style of Button to list pinned messages * feat: pin message * feat: unpin message & sort pinned messages * feat: scroll to pinned message * feat: pinned messages would be updated if the original message is changed * feat: add poll to pinned messages * feat: only text and poll can be pinned * feat: update pinned messages whenever original message is updated * feat: preview attachments inside pinned messages * feat: stop propagation in download attachment action inside pinned messages * feat: display reactions to the pinned messages * feat: implemented pinned by * chore: do not open new tab when user is signed in * chore: reverted unrelated changes * fix: css styles for responsive * feat: mobile design for pinned messages * feat: implementING cypress test cases * feat: completed test cases for pinned messages * feat: add confirm dialog when unpin message * feat: handle mentions in pinned messages * chore: do not open new tab when user is signed in * chore: improved style removing margin * fix: error in passing params * fix: update pinnedMessages when the original messages are edited * feat: implement markdown and all mentions inside pinned messages * chore: remove useless args in chatroom contract functions and Travis retry * chore: removed useless args and Travis retry * chore: removed useless args * fix: cypress error * fix: errors from cypress and initializing new chatroom state in ChatMain * fix: error in cypress * chore: minor updates and Travis retry * feat: updated position of *N Pinned* in GroupChat * fix: error in using getter * feat: improved pinned style * fix: error in logout and wait invocations from UNREAD_MESSAGES_QUEUE * fix: cypress error * chore: updated comment * fix: Cypress error in logout before waiting for UNREAD_MESSAGES_QUEUE * fix: update readUntilMessage before update contract state in Cypress mode * chore: added comment and Travis retry to check if it passes in a row * chore: fix wrong indent * chore: add comment and Travis retry * chore: Travis passes in a rowgit add .! * chore: reverted unrelated code changes * chore: reverted changes * fix: tiny error * feat: added 'unpin message' in message actions * fix: error to display pinned messages in DM * fix: error while logging out * fix: DRY * fix: syntax errors * fix: error in using variable 'hash' * fix: remove useless ipmort * fix: flow error regarding variable initialization * fix: optimization and added comments * fix: resolved flow lint error * fix: text overflow error inside PinnedMessages * fix: to remove mhash in the query after hightlight it --- frontend/assets/style/_icons.scss | 1 + frontend/controller/actions/chatroom.js | 2 + frontend/controller/actions/identity.js | 3 +- frontend/model/chatroom/vuexModule.js | 2 +- frontend/model/contracts/chatroom.js | 235 +++++++----- frontend/model/contracts/group.js | 38 +- frontend/model/contracts/identity.js | 20 +- frontend/model/contracts/shared/types.js | 13 +- frontend/model/state.js | 2 +- frontend/views/components/ProfileCard.vue | 2 +- frontend/views/components/Tooltip.vue | 10 +- .../group-creation-steps/GroupMincome.vue | 2 +- .../views/containers/chatroom/ChatMain.vue | 60 +++- .../views/containers/chatroom/ChatMembers.vue | 2 +- .../chatroom/ChatMembersAllModal.vue | 10 +- .../views/containers/chatroom/ChatMixin.js | 11 +- .../containers/chatroom/ConversationsList.vue | 6 +- .../chatroom/CreateNewChannelModal.vue | 5 +- .../chatroom/DeleteChannelModal.vue | 4 +- .../chatroom/EditChannelNameModal.vue | 9 +- .../views/containers/chatroom/Message.vue | 3 + .../containers/chatroom/MessageActions.vue | 46 ++- .../views/containers/chatroom/MessageBase.vue | 48 ++- .../views/containers/chatroom/MessagePoll.vue | 36 +- .../containers/chatroom/MessageReactions.vue | 7 +- .../containers/chatroom/PinnedMessages.vue | 333 ++++++++++++++++++ .../views/containers/chatroom/PollMixin.js | 38 ++ .../views/containers/chatroom/SendArea.vue | 8 +- .../file-attachment/ChatAttachmentPreview.vue | 9 +- .../poll-message-content/PollToVote.vue | 22 +- .../poll-message-content/PollVoteResult.vue | 29 +- .../dashboard/GroupMembersTooltipPending.vue | 2 +- .../dashboard/SentenceWithMemberTooltip.vue | 2 +- frontend/views/pages/DesignSystem.vue | 3 +- frontend/views/pages/GroupChat.vue | 141 +++++--- .../group-chat-direct-message.spec.js | 23 +- .../integration/group-chat-message.spec.js | 131 +++++-- test/cypress/support/commands.js | 11 + 38 files changed, 977 insertions(+), 352 deletions(-) create mode 100644 frontend/views/containers/chatroom/PinnedMessages.vue create mode 100644 frontend/views/containers/chatroom/PollMixin.js diff --git a/frontend/assets/style/_icons.scss b/frontend/assets/style/_icons.scss index 2cf52aecd..3ae86cc2e 100644 --- a/frontend/assets/style/_icons.scss +++ b/frontend/assets/style/_icons.scss @@ -59,6 +59,7 @@ $icons: ( tag: "\f02b", times: "\f00d", times-circle: "\f057", + thumbtack: "\f08D", undo: "\f0e2", user: "\f007", user-plus: "\f234", diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 7daa2d92e..4a62e7dc5 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -206,6 +206,8 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/deleteMessage', L('Failed to delete message.')), ...encryptedAction('gi.actions/chatroom/deleteAttachment', L('Failed to delete attachment of message.')), ...encryptedAction('gi.actions/chatroom/makeEmotion', L('Failed to make emotion.')), + ...encryptedAction('gi.actions/chatroom/pinMessage', L('Failed to pin message.')), + ...encryptedAction('gi.actions/chatroom/unpinMessage', L('Failed to unpin message.')), ...encryptedAction('gi.actions/chatroom/join', L('Failed to join chat channel.'), async (sendMessage, params, signingKeyId) => { const rootState = sbp('state/vuex/state') const userID = params.data.memberID || rootState.loggedIn.identityContractID diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index bab3e75fa..4456f3c23 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -798,7 +798,8 @@ export default (sbp('sbp/selectors/register', { } if (deleteResult?.some(r => r.status === 'rejected')) { - console.error('[gi.actions/identity/removeFiles] Some CIDs could not be deleted', deleteResult.map((r, i) => r.status === 'rejected' && toDelete[i]).filter(Boolean)) + console.error('[gi.actions/identity/removeFiles] Some CIDs could not be deleted', + deleteResult?.map((r, i) => r.status === 'rejected' && toDelete[i]).filter(Boolean)) throw new Error('Some CIDs could not be deleted') } }, diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js index 7ee26db34..da884fcad 100644 --- a/frontend/model/chatroom/vuexModule.js +++ b/frontend/model/chatroom/vuexModule.js @@ -146,7 +146,7 @@ const getters = { Object.keys(rootState[cId].chatRooms).includes(chatRoomID)) }, chatRoomsInDetail (state, getters, rootState) { - const chatRoomsInDetail = merge({}, getters.getGroupChatRooms) + const chatRoomsInDetail = merge({}, getters.groupChatRooms) for (const contractID in chatRoomsInDetail) { const chatRoom = rootState[contractID] if (chatRoom && chatRoom.attributes && diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index ed07f67cf..a47bceae3 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -4,7 +4,7 @@ import { L, Vue } from '@common/common.js' import sbp from '@sbp/sbp' -import { objectOf, optional, number, string, arrayOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' +import { objectOf, optional, object, number, string, arrayOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' import { ChelErrorGenerator } from '~/shared/domains/chelonia/errors.js' import { findForeignKeysByContractID, findKeyIdByName } from '~/shared/domains/chelonia/utils.js' import { @@ -142,8 +142,11 @@ sbp('chelonia/defineContract', { chatRoomMembers (state, getters) { return getters.currentChatRoomState.members || {} }, - chatRoomLatestMessages (state, getters) { + chatRoomRecentMessages (state, getters) { return getters.currentChatRoomState.messages || [] + }, + chatRoomPinnedMessages (state, getters) { + return (getters.currentChatRoomState.pinnedMessages || []).sort((a, b) => a.height < b.height ? 1 : -1) } }, actions: { @@ -163,7 +166,8 @@ sbp('chelonia/defineContract', { deletedDate: null }, members: {}, - messages: [] + messages: [], + pinnedMessages: [] }, data) for (const key in initialState) { Vue.set(state, key, initialState[key]) @@ -381,7 +385,7 @@ sbp('chelonia/defineContract', { addMessage(state, createMessage({ meta, data, hash, height, state, pending, innerSigningContractID })) } else if (direction !== 'outgoing') { // If an existing message is found, it's no longer pending. - delete existingMsg.pending + Vue.delete(existingMsg, 'pending') } }, sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state, getters }) { @@ -413,19 +417,27 @@ sbp('chelonia/defineContract', { createdHeight: number, text: string })), - process ({ data, meta, innerSigningContractID }, { state }) { - const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0 && innerSigningContractID === state.messages[msgIndex].from) { - state.messages[msgIndex].text = data.text - state.messages[msgIndex].updatedDate = meta.createdDate - if (state.renderingContext && state.messages[msgIndex].pending) { + process ({ data, meta }, { state }) { + const { hash, text } = data + const fnEditMessage = (message) => { + Vue.set(message, 'text', text) + Vue.set(message, 'updatedDate', meta.createdDate) + + if (state.renderingContext && message.pending) { // NOTE: 'pending' message attribute is not the original message attribute // and it is only set and used in Chat page - delete state.messages[msgIndex].pending + Vue.delete(message, 'pending') } } + + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(hash, messageArray) + if (msgIndex >= 0) { + fnEditMessage(messageArray[msgIndex]) + } + }) }, - sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { getters }) { + sideEffect ({ contractID, data, innerSigningContractID }, { getters }) { const rootState = sbp('state/vuex/state') const me = rootState.loggedIn.identityContractID if (me === innerSigningContractID || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { @@ -460,7 +472,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/deleteMessage': { - validate: actionRequireInnerSignature((data, { state, meta, message: { innerSigningContractID }, contractID }) => { + validate: actionRequireInnerSignature((data, { state, message: { innerSigningContractID }, contractID }) => { objectOf({ hash: string, // NOTE: manifestCids of the attachments which belong to the message @@ -480,22 +492,25 @@ sbp('chelonia/defineContract', { } } }), - process ({ data, meta, innerSigningContractID }, { state }) { - const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0) { - state.messages.splice(msgIndex, 1) - } - // filter replied messages and check if the current message is original - for (const message of state.messages) { - if (message.replyingMessage?.hash === data.hash) { - message.replyingMessage.hash = null - message.replyingMessage.text = L('Original message was removed by {user}', { - user: makeMentionFromUserID(innerSigningContractID).me - }) + process ({ data, innerSigningContractID }, { state }) { + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(data.hash, messageArray) + if (msgIndex >= 0) { + messageArray.splice(msgIndex, 1) } - } + + // filter replied messages and check if the current message is original + for (const message of messageArray) { + if (message.replyingMessage?.hash === data.hash) { + message.replyingMessage.hash = null + message.replyingMessage.text = L('Original message was removed by {user}', { + user: makeMentionFromUserID(innerSigningContractID).me + }) + } + } + }) }, - sideEffect ({ data, contractID, hash, height, meta, innerSigningContractID }) { + sideEffect ({ data, contractID, innerSigningContractID }) { const rootState = sbp('state/vuex/state') const me = rootState.loggedIn.identityContractID @@ -530,19 +545,25 @@ sbp('chelonia/defineContract', { manifestCid: string, messageSender: string })), - process ({ data, innerSigningContractID }, { state }) { - const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0) { - const oldAttachments = state.messages[msgIndex].attachments + process ({ data }, { state }) { + const fnDeleteAttachment = (message) => { + const oldAttachments = message.attachments if (Array.isArray(oldAttachments)) { const newAttachments = oldAttachments.filter(attachment => { return attachment.downloadData.manifestCid !== data.manifestCid }) - Vue.set(state.messages[msgIndex], 'attachments', newAttachments) + Vue.set(message, 'attachments', newAttachments) } } + + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(data.hash, messageArray) + if (msgIndex >= 0) { + fnDeleteAttachment(messageArray[msgIndex]) + } + }) }, - sideEffect ({ data, contractID, hash, meta, innerSigningContractID }) { + sideEffect ({ data, contractID, innerSigningContractID }) { const me = sbp('state/vuex/state').loggedIn.identityContractID const option = { shouldDeleteFile: me === innerSigningContractID, @@ -560,9 +581,9 @@ sbp('chelonia/defineContract', { })), process ({ data, innerSigningContractID }, { state }) { const { hash, emoticon } = data - const msgIndex = findMessageIdx(hash, state.messages) - if (msgIndex >= 0) { - let emoticons = cloneDeep(state.messages[msgIndex].emoticons || {}) + + const fnMakeEmotion = (message) => { + let emoticons = cloneDeep(message.emoticons || {}) if (emoticons[emoticon]) { const alreadyAdded = emoticons[emoticon].indexOf(innerSigningContractID) if (alreadyAdded >= 0) { @@ -580,11 +601,18 @@ sbp('chelonia/defineContract', { emoticons[emoticon] = [innerSigningContractID] } if (emoticons) { - Vue.set(state.messages[msgIndex], 'emoticons', emoticons) + Vue.set(message, 'emoticons', emoticons) } else { - Vue.delete(state.messages[msgIndex], 'emoticons') + Vue.delete(message, 'emoticons') } } + + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(hash, messageArray) + if (msgIndex >= 0) { + fnMakeEmotion(messageArray[msgIndex]) + } + }) } }, 'gi.contracts/chatroom/voteOnPoll': { @@ -594,36 +622,41 @@ sbp('chelonia/defineContract', { votesAsString: string })), process ({ data, meta, hash, height, innerSigningContractID }, { state }) { - const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0) { + let shouldHideVoters = false + + const fnVoteOnPoll = (message) => { const myVotes = data.votes - const pollData = state.messages[msgIndex].pollData + const pollData = message.pollData const optsCopy = cloneDeep(pollData.options) - const votedOptNames = [] myVotes.forEach(optId => { - const foundOpt = optsCopy.find(x => x.id === optId) - - if (foundOpt) { - foundOpt.voted.push(innerSigningContractID) - votedOptNames.push(`"${foundOpt.value}"`) - } + optsCopy.find(x => x.id === optId)?.voted.push(innerSigningContractID) }) - Vue.set(state.messages[msgIndex], 'pollData', { ...pollData, options: optsCopy }) + Vue.set(message, 'pollData', { ...pollData, options: optsCopy }) - if (pollData.hideVoters) { return } + // TODO: https://github.com/okTurtles/group-income/issues/2010 + shouldHideVoters = shouldHideVoters || message.pollData.hideVoters } - // create & add a notification-message for user having voted. - const notificationData = createNotificationData( - MESSAGE_NOTIFICATIONS.VOTE_ON_POLL, - { - votedOptions: data.votesAsString, - pollMessageHash: data.hash + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(data.hash, messageArray) + if (msgIndex >= 0) { + fnVoteOnPoll(messageArray[msgIndex]) } - ) - addMessage(state, createMessage({ meta, hash, height, state, data: notificationData, innerSigningContractID })) + }) + + if (!shouldHideVoters) { + // create & add a notification-message for user having voted. + const notificationData = createNotificationData( + MESSAGE_NOTIFICATIONS.VOTE_ON_POLL, + { + votedOptions: data.votesAsString, + pollMessageHash: data.hash + } + ) + addMessage(state, createMessage({ meta, hash, height, state, data: notificationData, innerSigningContractID })) + } } }, 'gi.contracts/chatroom/changeVoteOnPoll': { @@ -633,42 +666,46 @@ sbp('chelonia/defineContract', { votesAsString: string })), process ({ data, meta, hash, height, innerSigningContractID }, { state }) { - const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0) { - const me = innerSigningContractID + let shouldHideVoters = false + + const fnChangeVoteOnPoll = (message) => { const myUpdatedVotes = data.votes - const pollData = state.messages[msgIndex].pollData + const pollData = message.pollData const optsCopy = cloneDeep(pollData.options) - const votedOptNames = [] // remove all the previous votes of the user before update. optsCopy.forEach(opt => { - opt.voted = opt.voted.filter(votername => votername !== me) + opt.voted = opt.voted.filter(votername => votername !== innerSigningContractID) }) myUpdatedVotes.forEach(optId => { - const foundOpt = optsCopy.find(x => x.id === optId) - - if (foundOpt) { - foundOpt.voted.push(me) - votedOptNames.push(`"${foundOpt.value}"`) - } + optsCopy.find(x => x.id === optId)?.voted.push(innerSigningContractID) }) - Vue.set(state.messages[msgIndex], 'pollData', { ...pollData, options: optsCopy }) + Vue.set(message, 'pollData', { ...pollData, options: optsCopy }) - if (pollData.hideVoters) { return } + // TODO: https://github.com/okTurtles/group-income/issues/2010 + shouldHideVoters = shouldHideVoters || message.pollData.hideVoters } - // create & add a notification-message for user having update his/her votes. - const notificationData = createNotificationData( - MESSAGE_NOTIFICATIONS.CHANGE_VOTE_ON_POLL, - { - votedOptions: data.votesAsString, - pollMessageHash: data.hash + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(data.hash, messageArray) + if (msgIndex >= 0) { + fnChangeVoteOnPoll(messageArray[msgIndex]) } - ) - addMessage(state, createMessage({ meta, hash, height, state, data: notificationData, innerSigningContractID })) + }) + + if (!shouldHideVoters) { + // create & add a notification-message for user having update his/her votes. + const notificationData = createNotificationData( + MESSAGE_NOTIFICATIONS.CHANGE_VOTE_ON_POLL, + { + votedOptions: data.votesAsString, + pollMessageHash: data.hash + } + ) + addMessage(state, createMessage({ meta, hash, height, state, data: notificationData, innerSigningContractID })) + } } }, 'gi.contracts/chatroom/closePoll': { @@ -676,9 +713,45 @@ sbp('chelonia/defineContract', { hash: string })), process ({ data }, { state }) { + const fnClosePoll = (message) => { + Vue.set(message.pollData, 'status', POLL_STATUS.CLOSED) + } + + [state.messages, state.pinnedMessages].forEach(messageArray => { + const msgIndex = findMessageIdx(data.hash, messageArray) + if (msgIndex >= 0) { + fnClosePoll(messageArray[msgIndex]) + } + }) + } + }, + 'gi.contracts/chatroom/pinMessage': { + validate: actionRequireInnerSignature(objectOf({ + message: object + })), + process ({ data, innerSigningContractID }, { state }) { + const { message } = data + state.pinnedMessages.unshift(message) + + const msgIndex = findMessageIdx(message.hash, state.messages) + if (msgIndex >= 0) { + Vue.set(state.messages[msgIndex], 'pinnedBy', innerSigningContractID) + } + } + }, + 'gi.contracts/chatroom/unpinMessage': { + validate: actionRequireInnerSignature(objectOf({ + hash: string + })), + process ({ data }, { state }) { + const pinnedMsgIndex = findMessageIdx(data.hash, state.pinnedMessages) + if (pinnedMsgIndex >= 0) { + state.pinnedMessages.splice(pinnedMsgIndex, 1) + } + const msgIndex = findMessageIdx(data.hash, state.messages) if (msgIndex >= 0) { - Vue.set(state.messages[msgIndex].pollData, 'status', POLL_STATUS.CLOSED) + Vue.delete(state.messages[msgIndex], 'pinnedBy') } } } diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 4751638b6..52bb2d038 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -611,10 +611,10 @@ sbp('chelonia/defineContract', { // bound to the UI in some location. return getters.groupCurrency?.displayWithCurrency }, - getGroupChatRooms (state, getters) { + groupChatRooms (state, getters) { return getters.currentGroupState.chatRooms }, - generalChatRoomId (state, getters) { + groupGeneralChatRoomId (state, getters) { return getters.currentGroupState.generalChatRoomId }, // getter is named haveNeedsForThisPeriod instead of haveNeedsForPeriod because it uses @@ -882,7 +882,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/proposal': { - validate: actionRequireActiveMember((data, { state, meta }) => { + validate: actionRequireActiveMember((data, { state }) => { objectOf({ proposalType: proposalType, proposalData: object, // data for Vue widgets @@ -1025,14 +1025,14 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/group/notifyExpiringProposals': { validate: actionRequireActiveMember(arrayOf(string)), - process ({ data, meta, contractID }, { state }) { + process ({ data }, { state }) { for (const proposalId of data) { Vue.set(state.proposals[proposalId], 'notifiedBeforeExpire', true) } } }, 'gi.contracts/group/removeMember': { - validate: actionRequireActiveMember((data, { state, getters, meta, message: { innerSigningContractID, proposalHash } }) => { + validate: actionRequireActiveMember((data, { state, getters, message: { innerSigningContractID, proposalHash } }) => { objectOf({ memberID: optional(string), // member to remove reason: optional(string), @@ -1090,7 +1090,7 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/group/invite': { validate: actionRequireActiveMember(inviteType), - process ({ data, meta }, { state }) { + process ({ data }, { state }) { Vue.set(state.invites, data.inviteKeyId, data) } }, @@ -1110,7 +1110,7 @@ sbp('chelonia/defineContract', { // They MUST NOT call 'commit'! // They should only coordinate the actions of outside contracts. // Otherwise `latestContractState` and `handleEvent` will not produce same state! - sideEffect ({ meta, contractID, height, innerSigningContractID }, { state }) { + sideEffect ({ meta, contractID, height, innerSigningContractID }) { const { loggedIn } = sbp('state/vuex/state') sbp('chelonia/queueInvocation', contractID, async () => { @@ -1213,7 +1213,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/inviteRevoke': { - validate: actionRequireActiveMember((data, { state, meta }) => { + validate: actionRequireActiveMember((data, { state }) => { objectOf({ inviteKeyId: string })(data) @@ -1330,7 +1330,7 @@ sbp('chelonia/defineContract', { ruleThreshold: number, expires_ms: number })), - process ({ data, meta }, { state }) { + process ({ data }, { state }) { // Update all types of proposal settings for simplicity if (data.ruleName && data.ruleThreshold) { for (const proposalSettings in state.settings.proposals) { @@ -1369,7 +1369,7 @@ sbp('chelonia/defineContract', { } } }, - process ({ data, meta, contractID, innerSigningContractID }, { state }) { + process ({ data, contractID, innerSigningContractID }, { state }) { const { name, type, privacyLevel, description } = data.attributes // XOR: has(innerSigningContractID) XOR #General if (!!innerSigningContractID === (data.attributes.name === CHATROOM_GENERAL_NAME)) { @@ -1399,14 +1399,14 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/deleteChatRoom': { - validate: actionRequireActiveMember((data, { getters, meta, message: { innerSigningContractID } }) => { + validate: actionRequireActiveMember((data, { getters, message: { innerSigningContractID } }) => { objectOf({ chatRoomID: string })(data) - if (getters.getGroupChatRooms[data.chatRoomID].creatorID !== innerSigningContractID) { + if (getters.groupChatRooms[data.chatRoomID].creatorID !== innerSigningContractID) { throw new TypeError(L('Only the channel creator can delete channel.')) } }), - process ({ data, meta }, { state }) { + process ({ data }, { state }) { Vue.delete(state.chatRooms, data.chatRoomID) } }, @@ -1425,7 +1425,7 @@ sbp('chelonia/defineContract', { } removeGroupChatroomProfile(state, data.chatRoomID, memberID) }, - sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { + sideEffect ({ data, contractID, innerSigningContractID }, { state }) { const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID if (innerSigningContractID === rootState.loggedIn.identityContractID) { @@ -1445,7 +1445,7 @@ sbp('chelonia/defineContract', { memberID: optional(string), chatRoomID: string })), - process ({ data, meta, innerSigningContractID }, { state }) { + process ({ data, innerSigningContractID }, { state }) { const memberID = data.memberID || innerSigningContractID const { chatRoomID } = data @@ -1471,7 +1471,7 @@ sbp('chelonia/defineContract', { // a part of. Vue.set(state.chatRooms[chatRoomID].members, memberID, { status: PROFILE_STATUS.ACTIVE }) }, - sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { + sideEffect ({ data, contractID, innerSigningContractID }) { const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID @@ -1514,7 +1514,7 @@ sbp('chelonia/defineContract', { chatRoomID: string, name: string })), - process ({ data, meta }, { state, getters }) { + process ({ data }, { state }) { Vue.set(state.chatRooms[data.chatRoomID], 'name', data.name) } }, @@ -1523,7 +1523,7 @@ sbp('chelonia/defineContract', { chatRoomID: string, description: string })), - process ({ data, meta }, { state, getters }) { + process ({ data }, { state }) { Vue.set(state.chatRooms[data.chatRoomID], 'description', data.description) } }, @@ -1542,7 +1542,7 @@ sbp('chelonia/defineContract', { ...((process.env.NODE_ENV === 'development' || process.env.CI) && { 'gi.contracts/group/forceDistributionDate': { validate: optional, - process ({ meta, contractID }, { state, getters }) { + process ({ meta }, { getters }) { getters.groupSettings.distributionDate = dateToPeriodStamp(meta.createdDate) } }, diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index c6483cf2e..f7bcd27dc 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -87,7 +87,7 @@ sbp('chelonia/defineContract', { }, actions: { 'gi.contracts/identity': { - validate: (data, { state }) => { + validate: (data) => { objectMaybeOf({ attributes: attributesType })(data) @@ -153,7 +153,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/identity/createDirectMessage': { - validate: (data, { state, getters }) => { + validate: (data) => { objectOf({ contractID: string // NOTE: chatroom contract id })(data) @@ -164,7 +164,7 @@ sbp('chelonia/defineContract', { visible: true // NOTE: this attr is used to hide/show direct message }) }, - sideEffect ({ contractID, data }) { + sideEffect ({ data }) { sbp('chelonia/contract/retain', data.contractID).catch((e) => { console.error('[gi.contracts/identity/createDirectMessage/sideEffect] Error calling retain', e) }) @@ -199,7 +199,7 @@ sbp('chelonia/defineContract', { inviteSecret: string, creatorID: optional(boolean) }), - process ({ hash, data, meta }, { state }) { + process ({ hash, data }, { state }) { const { groupContractID, inviteSecret } = data if (has(state.groups, groupContractID)) { throw new Error(`Cannot join already joined group ${groupContractID}`) @@ -275,7 +275,7 @@ sbp('chelonia/defineContract', { validate: objectOf({ groupContractID: string }), - process ({ data, meta }, { state }) { + process ({ data }, { state }) { const { groupContractID } = data if (!has(state.groups, groupContractID)) { @@ -284,7 +284,7 @@ sbp('chelonia/defineContract', { Vue.delete(state.groups, groupContractID) }, - sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { + sideEffect ({ data, contractID }) { sbp('chelonia/queueInvocation', contractID, () => { const rootState = sbp('state/vuex/state') const state = rootState[contractID] @@ -352,7 +352,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/identity/setDirectMessageVisibility': { - validate: (data, { state, getters }) => { + validate: (data, { getters }) => { objectOf({ contractID: string, visible: boolean @@ -361,7 +361,7 @@ sbp('chelonia/defineContract', { throw new TypeError(L('Not existing direct message.')) } }, - process ({ data }, { state, getters }) { + process ({ data }, { state }) { Vue.set(state.chatRooms[data.contractID], 'visible', data.visible) } }, @@ -372,7 +372,7 @@ sbp('chelonia/defineContract', { token: string })) }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { for (const { manifestCid, token } of data.tokensByManifestCid) { Vue.set(state.fileDeleteTokens, manifestCid, token) } @@ -382,7 +382,7 @@ sbp('chelonia/defineContract', { validate: objectOf({ manifestCids: arrayOf(string) }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { for (const manifestCid of data.manifestCids) { Vue.delete(state.fileDeleteTokens, manifestCid) } diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index 35f4d6423..b2e27fbc0 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -1,12 +1,12 @@ 'use strict' import { - objectOf, objectMaybeOf, arrayOf, unionOf, + objectOf, objectMaybeOf, arrayOf, unionOf, boolean, object, string, optional, number, mapOf, literalOf } from '~/frontend/model/contracts/misc/flowTyper.js' import { CHATROOM_TYPES, CHATROOM_PRIVACY_LEVEL, - MESSAGE_TYPES, MESSAGE_NOTIFICATIONS, PROPOSAL_VARIANTS + MESSAGE_TYPES, MESSAGE_NOTIFICATIONS, PROPOSAL_VARIANTS, POLL_TYPES } from './constants.js' // group.js related @@ -60,7 +60,12 @@ export const messageType: any = objectMaybeOf({ hash: string, // scroll to the original message and highlight text: string // display text(if too long, truncate) }), - emoticons: mapOf(string, arrayOf(string)), // mapping of emoticons and usernames + pollData: objectOf({ + question: string, + options: arrayOf(objectOf({ id: string, value: string })), + expires_date_ms: number, + hideVoters: boolean, + pollType: unionOf(...Object.values(POLL_TYPES).map(v => literalOf(v))) + }), onlyVisibleTo: arrayOf(string) // list of usernames, only necessary when type is NOTIFICATION - // TODO: need to consider POLL and add more down here }) diff --git a/frontend/model/state.js b/frontend/model/state.js index 6f4f81328..0591d0cd1 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -405,7 +405,7 @@ const getters = { profilesByGroup (state, getters) { return groupID => { const profiles = {} - if (state.contracts[groupID].type !== 'gi.contracts/group') { + if (state.contracts[groupID]?.type !== 'gi.contracts/group') { return profiles } const groupProfiles = state[groupID].profiles || {} diff --git a/frontend/views/components/ProfileCard.vue b/frontend/views/components/ProfileCard.vue index ccf404df2..58dd71499 100644 --- a/frontend/views/components/ProfileCard.vue +++ b/frontend/views/components/ProfileCard.vue @@ -99,7 +99,7 @@ export default ({ contractID: String, direction: { type: String, - validator: (value) => ['left', 'top-left'].includes(value), + validator: (value) => ['left', 'top-left', 'bottom'].includes(value), default: 'left' }, deactivated: { diff --git a/frontend/views/components/Tooltip.vue b/frontend/views/components/Tooltip.vue index f856f6c94..293d0e483 100644 --- a/frontend/views/components/Tooltip.vue +++ b/frontend/views/components/Tooltip.vue @@ -46,7 +46,7 @@ export default ({ }, direction: { type: String, - validator: (value) => ['bottom', 'bottom-end', 'right', 'left', 'top', 'top-left'].includes(value), + validator: (value) => ['bottom', 'bottom-left', 'bottom-right', 'right', 'left', 'top', 'top-left'].includes(value), default: 'bottom' }, opacity: { @@ -132,7 +132,11 @@ export default ({ case 'left': x = scrollX + left - spacing - this.tooltipWidth break - case 'bottom-end': + case 'bottom-left': + x = scrollX + left + y = scrollY + top + height + spacing + break + case 'bottom-right': x = scrollX + left + width - this.tooltipWidth y = scrollY + top + height + spacing break @@ -263,7 +267,7 @@ export default ({ } &.manual { - max-width: auto; + max-width: unset; } &.is-dark-theme { diff --git a/frontend/views/components/group-creation-steps/GroupMincome.vue b/frontend/views/components/group-creation-steps/GroupMincome.vue index 79bcdccd1..cb99dafa9 100644 --- a/frontend/views/components/group-creation-steps/GroupMincome.vue +++ b/frontend/views/components/group-creation-steps/GroupMincome.vue @@ -41,7 +41,7 @@ fieldset.field .label.c-label-tooltip i18n On what day should the first payment distribution be calculated? - tooltip(direction='bottom-end') + tooltip(direction='bottom-right') span.button.is-icon-small i.icon-question-circle template(slot='tooltip') diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index 7bfea85db..f4e4a924f 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -79,6 +79,7 @@ :currentUserID='currentUserAttr.id' :avatar='avatar(message.from)' :variant='variant(message)' + :pinnedBy='message.pinnedBy' :isSameSender='isSameSender(index)' :isMsgSender='isMsgSender(message.from)' :isGroupCreator='isGroupCreator' @@ -87,6 +88,8 @@ @reply='replyMessage(message)' @scroll-to-replying-message='scrollToMessage(message.replyingMessage.hash)' @edit-message='(newMessage) => editMessage(message, newMessage)' + @pin-to-channel='pinToChannel(message)' + @unpin-from-channel='unpinFromChannel(message.hash)' @delete-message='deleteMessage(message)' @delete-attachment='manifestCid => deleteAttachment(message, manifestCid)' @add-emoticon='addEmoticon(message, $event)' @@ -136,7 +139,7 @@ import { MESSAGE_TYPES, MESSAGE_VARIANTS, CHATROOM_ACTIONS_PER_PAGE } from '@mod import { CHATROOM_EVENTS } from '@utils/events.js' import { findMessageIdx, createMessage } from '@model/contracts/shared/functions.js' import { proximityDate, MINS_MILLIS } from '@model/contracts/shared/time.js' -import { cloneDeep, debounce, throttle } from '@model/contracts/shared/giLodash.js' +import { cloneDeep, debounce, throttle, delay } from '@model/contracts/shared/giLodash.js' import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' const collectEventStream = async (s: ReadableStream) => { @@ -302,7 +305,6 @@ export default ({ 'chatRoomSettings', 'chatRoomAttributes', 'chatRoomMembers', - 'chatRoomLatestMessages', 'ourIdentityContractId', 'currentIdentityState', 'isJoinedChatRoom', @@ -621,6 +623,36 @@ export default ({ console.error(`Error while editing message(${message.hash}) in chatroom(${contractID})`, e) }) }, + pinToChannel (message) { + const contractID = this.renderingChatRoomId + sbp('gi.actions/chatroom/pinMessage', { + contractID, + data: { message } + }).catch((e) => { + console.error(`Error while pinning message(${message.hash}) in chatroom(${contractID})`, e) + }) + }, + async unpinFromChannel (hash) { + const contractID = this.renderingChatRoomId + + const promptConfig = { + heading: L('Remove pinned message'), + question: L('Are you sure you want to remove this pinned message?'), + primaryButton: L('Yes'), + secondaryButton: L('Cancel') + } + + const primaryButtonSelected = await sbp('gi.ui/prompt', promptConfig) + + if (primaryButtonSelected) { + sbp('gi.actions/chatroom/unpinMessage', { + contractID, + data: { hash } + }).catch((e) => { + console.error(`Error while un-pinning message(${hash}) in chatroom(${contractID})`, e) + }) + } + }, async deleteMessage (message) { const contractID = this.renderingChatRoomId const manifestCids = (message.attachments || []).map(attachment => attachment.downloadData.manifestCid) @@ -699,6 +731,7 @@ export default ({ members: state.members || {}, _vm: state._vm, messages: shouldClearMessages ? [] : state.messages, + pinnedMessages: [], // NOTE: We don't use this pinnedMessages, but initialize so that the process functions won't break renderingContext: true // NOTE: DO NOT RENAME THIS OR CHATROOM WOULD BREAK } }, @@ -773,17 +806,17 @@ export default ({ if (events.length) { // NOTE: if 'messageHashToScroll' was not there in the messages of the contract state // we need to retrieve more events, and render to scroll to that message - this.updateScroll(messageHashToScroll, Boolean(mhash)).then(() => { - // NOTE: delete mhash in the query after scroll and highlight the message with mhash - if (mhash) { - const newQuery = { ...this.$route.query } - delete newQuery.mhash - this.$router.replace({ query: newQuery }) - } - }) + this.updateScroll(messageHashToScroll, Boolean(mhash)) } else { // NOTE: we need to scroll to the message first in order to no more infiniteHandler is called await this.updateScroll(messageHashToScroll, Boolean(mhash)) + + if (mhash) { + // NOTE: delete mhash in the query after scroll and highlight the message with mhash + const newQuery = { ...this.$route.query } + delete newQuery.mhash + this.$router.replace({ query: newQuery }) + } } } @@ -943,10 +976,9 @@ export default ({ if (msgIndex !== -1) { document.querySelectorAll('.c-body-conversation > .c-message')[msgIndex]?.classList.add('c-disappeared') - // NOTE: waiting for the animation is done - // it's duration is 500ms described in MessageBase.vue - await new Promise(resolve => setTimeout(resolve, 500)) - if (!this.checkEventSourceConsistency(contractID)) return + // NOTE: waiting for the animation to be completed with the duration of 500ms + // .c-disappeared class is defined in MessageBase.vue + await delay(500) } } diff --git a/frontend/views/containers/chatroom/ChatMembers.vue b/frontend/views/containers/chatroom/ChatMembers.vue index 7ff1b1f5a..b6264d94b 100644 --- a/frontend/views/containers/chatroom/ChatMembers.vue +++ b/frontend/views/containers/chatroom/ChatMembers.vue @@ -59,7 +59,7 @@ export default ({ props: { title: { type: String, - default: L('Members') + default: L('Direct Messages') }, action: { type: String, diff --git a/frontend/views/containers/chatroom/ChatMembersAllModal.vue b/frontend/views/containers/chatroom/ChatMembersAllModal.vue index 00e2fa356..a443f4335 100644 --- a/frontend/views/containers/chatroom/ChatMembersAllModal.vue +++ b/frontend/views/containers/chatroom/ChatMembersAllModal.vue @@ -151,9 +151,9 @@ export default ({ computed: { ...mapGetters([ 'currentChatRoomState', - 'currentGroupState', + 'groupGeneralChatRoomId', 'groupMembersSorted', - 'getGroupChatRooms', + 'groupChatRooms', 'chatRoomMembers', 'chatRoomMembersInSort', 'globalProfile', @@ -204,13 +204,13 @@ export default ({ // NOTE: Do not consider to get attributes of private chatroom which the user is not part of // because it couldn't be happened // TODO: remove 'users', 'deletedDate' to keep consistency when this.isJoined === false - return this.isJoined ? this.currentChatRoomState.attributes : this.getGroupChatRooms[this.currentChatRoomId] + return this.isJoined ? this.currentChatRoomState.attributes : this.groupChatRooms[this.currentChatRoomId] }, chatRoomMembersInOrder () { return this.isJoined ? this.chatRoomMembersInSort : this.groupMembersSorted - .filter(member => this.getGroupChatRooms[this.currentChatRoomId].members[member.username]?.status === PROFILE_STATUS.ACTIVE) + .filter(member => this.groupChatRooms[this.currentChatRoomId].members[member.username]?.status === PROFILE_STATUS.ACTIVE) .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) } }, @@ -267,7 +267,7 @@ export default ({ return false } const { creatorID } = this.chatRoomAttribute - if (this.currentGroupState.generalChatRoomId === this.currentChatRoomId) { + if (this.groupGeneralChatRoomId === this.currentChatRoomId) { return false } else if (this.ourIdentityContractId === creatorID) { return true diff --git a/frontend/views/containers/chatroom/ChatMixin.js b/frontend/views/containers/chatroom/ChatMixin.js index 15d23cbf5..4834ba3ed 100644 --- a/frontend/views/containers/chatroom/ChatMixin.js +++ b/frontend/views/containers/chatroom/ChatMixin.js @@ -39,8 +39,8 @@ const ChatMixin: Object = { 'groupIdFromChatRoomId', 'ourGroupDirectMessages', 'chatRoomMembers', - 'generalChatRoomId', - 'getGroupChatRooms', + 'groupGeneralChatRoomId', + 'groupChatRooms', 'globalProfile', 'isJoinedChatRoom', 'ourContactProfilesById', @@ -65,7 +65,7 @@ const ChatMixin: Object = { picture, attributes: Object.assign({}, this.currentChatRoomState.attributes), isPrivate: this.currentChatRoomState.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE, - isGeneral: this.generalChatRoomId === this.currentChatRoomId, + isGeneral: this.groupGeneralChatRoomId === this.currentChatRoomId, isJoined: true, members: Object.fromEntries(Object.keys(this.currentChatRoomState.members).map(memberID => { const { displayName, picture, email } = this.globalProfile(memberID) || {} @@ -81,7 +81,7 @@ const ChatMixin: Object = { const name = 'GroupChatConversation' // Temporarily blocked the chatrooms which the user is not part of // Need to open it later and display messages just like Slack - chatRoomID = chatRoomID || (this.isJoinedChatRoom(this.currentChatRoomId) ? this.currentChatRoomId : this.generalChatRoomId) + chatRoomID = chatRoomID || (this.isJoinedChatRoom(this.currentChatRoomId) ? this.currentChatRoomId : this.groupGeneralChatRoomId) this.$router.push({ name, @@ -93,7 +93,7 @@ const ChatMixin: Object = { document.title = title || this.summary.title }, loadLatestState (chatRoomID: string): void { - const summarizedAttr = this.getGroupChatRooms[chatRoomID] + const summarizedAttr = this.groupChatRooms[chatRoomID] if (summarizedAttr) { const { creator, name, description, type, privacyLevel, members } = summarizedAttr const activeMembers = Object @@ -110,6 +110,7 @@ const ChatMixin: Object = { title: name, attributes: { creator, name, description, type, privacyLevel }, members: activeMembers, + pinnedMessages: [], numberOfMembers: activeMembers.length, participants: this.ourContactProfilesById // TODO: return only historical contributors } diff --git a/frontend/views/containers/chatroom/ConversationsList.vue b/frontend/views/containers/chatroom/ConversationsList.vue index 875cf4f16..50ca07b72 100644 --- a/frontend/views/containers/chatroom/ConversationsList.vue +++ b/frontend/views/containers/chatroom/ConversationsList.vue @@ -41,6 +41,7 @@ import { OPEN_MODAL } from '@utils/events.js' import ListItem from '@components/ListItem.vue' import Avatar from '@components/Avatar.vue' import { CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' +import { L } from '@common/common.js' export default ({ name: 'ConversationsList', @@ -49,7 +50,10 @@ export default ({ Avatar }, props: { - title: String, + title: { + type: String, + default: L('Channels') + }, /** List of channels - shape: { order: [] - group channels in order, channels: - group channels diff --git a/frontend/views/containers/chatroom/CreateNewChannelModal.vue b/frontend/views/containers/chatroom/CreateNewChannelModal.vue index 9a47ff331..ada59c8cf 100644 --- a/frontend/views/containers/chatroom/CreateNewChannelModal.vue +++ b/frontend/views/containers/chatroom/CreateNewChannelModal.vue @@ -128,7 +128,7 @@ export default ({ }, computed: { ...mapState(['currentGroupId']), - ...mapGetters(['groupSettings', 'getGroupChatRooms']), + ...mapGetters(['groupSettings', 'groupChatRooms']), maxNameCharacters () { return CHATROOM_NAME_LIMITS_IN_CHARS }, @@ -169,8 +169,7 @@ export default ({ created () { // HACK: using rootGetters inside validator makes `Duplicate channel name` error // as soon as a new channel is created - this.form.existingNames = Object.keys(this.getGroupChatRooms) - .map(cId => this.getGroupChatRooms[cId].name) + this.form.existingNames = Object.keys(this.groupChatRooms).map(cId => this.groupChatRooms[cId].name) }, mounted () { this.$refs.name.focus() diff --git a/frontend/views/containers/chatroom/DeleteChannelModal.vue b/frontend/views/containers/chatroom/DeleteChannelModal.vue index 395fd2acb..3c731b5ce 100644 --- a/frontend/views/containers/chatroom/DeleteChannelModal.vue +++ b/frontend/views/containers/chatroom/DeleteChannelModal.vue @@ -63,7 +63,7 @@ export default ({ } }, computed: { - ...mapGetters(['currentChatRoomId', 'chatRoomAttributes', 'generalChatRoomId']), + ...mapGetters(['currentChatRoomId', 'chatRoomAttributes', 'groupGeneralChatRoomId']), ...mapState(['currentGroupId']) }, methods: { @@ -80,7 +80,7 @@ export default ({ data: { chatRoomID }, hooks: { postpublish: (message) => { - const contractID = this.generalChatRoomId + const contractID = this.groupGeneralChatRoomId // TODO: This should be moved to a side-effect sbp('gi.actions/chatroom/addMessage', { contractID, diff --git a/frontend/views/containers/chatroom/EditChannelNameModal.vue b/frontend/views/containers/chatroom/EditChannelNameModal.vue index 9206775e9..b76eea18c 100644 --- a/frontend/views/containers/chatroom/EditChannelNameModal.vue +++ b/frontend/views/containers/chatroom/EditChannelNameModal.vue @@ -55,7 +55,7 @@ export default ({ }, computed: { ...mapState(['currentGroupId']), - ...mapGetters(['currentChatRoomId', 'currentChatRoomState', 'generalChatRoomId', 'getGroupChatRooms']), + ...mapGetters(['currentChatRoomId', 'currentChatRoomState', 'groupGeneralChatRoomId', 'groupChatRooms']), maxNameCharacters () { return this.currentChatRoomState.settings.maxNameLength } @@ -72,11 +72,10 @@ export default ({ }, created () { this.form.name = this.currentChatRoomState.attributes.name - this.form.existingNames = Object.keys(this.getGroupChatRooms) - .map(cId => this.getGroupChatRooms[cId].name) + this.form.existingNames = Object.keys(this.groupChatRooms).map(cId => this.groupChatRooms[cId].name) }, mounted () { - if (this.generalChatRoomId === this.currentChatRoomId) { + if (this.groupGeneralChatRoomId === this.currentChatRoomId) { this.close() } this.$refs.name.focus() @@ -92,7 +91,7 @@ export default ({ if (this.currentChatRoomState.attributes.name === this.form.name) { // TODO: No need to update chatroom name. Display message box or toast or sth else console.log('TODO: Channel name is not changed') - } else if (this.currentChatRoomId === this.generalChatRoomId) { + } else if (this.currentChatRoomId === this.groupGeneralChatRoomId) { // TODO: display warning message '"General" chatroom can not be renamed' console.log('TODO: "General" chatroom can not be renamed') } else { diff --git a/frontend/views/containers/chatroom/Message.vue b/frontend/views/containers/chatroom/Message.vue index 564c8467c..7bc408648 100644 --- a/frontend/views/containers/chatroom/Message.vue +++ b/frontend/views/containers/chatroom/Message.vue @@ -6,6 +6,8 @@ message-base( @retry='$emit("retry")' @reply-message-clicked='$emit("scroll-to-replying-message")' @message-edited='editMessage' + @pin-to-channel='$emit("pin-to-channel")' + @unpin-from-channel='$emit("unpin-from-channel")' @delete-attachment='deleteAttachment' @delete-message='$emit("delete-message")' :shouldRenderMarkdown='true' @@ -49,6 +51,7 @@ export default ({ return null } }, + pinnedBy: String, isSameSender: Boolean, isGroupCreator: Boolean, isMsgSender: Boolean, diff --git a/frontend/views/containers/chatroom/MessageActions.vue b/frontend/views/containers/chatroom/MessageActions.vue index 182a4bed3..3de90e59d 100644 --- a/frontend/views/containers/chatroom/MessageActions.vue +++ b/frontend/views/containers/chatroom/MessageActions.vue @@ -7,7 +7,7 @@ menu-parent(ref='menu') ) button.hide-touch.is-icon-small( :aria-label='L("Add reaction")' - @click='action("openEmoticon", $event)' + @click.stop='action("openEmoticon", $event)' ) i.icon-smile-beam @@ -18,7 +18,7 @@ menu-parent(ref='menu') ) button.hide-touch.is-icon-small( :aria-label='L("Edit")' - @click='action("editMessage")' + @click.stop='action("editMessage")' ) i.icon-pencil-alt @@ -29,7 +29,7 @@ menu-parent(ref='menu') ) button.hide-touch.is-icon-small( :aria-label='L("Reply")' - @click='action("reply")' + @click.stop='action("reply")' ) i.icon-reply @@ -40,7 +40,7 @@ menu-parent(ref='menu') ) button.hide-touch.is-icon-small( :aria-label='L("Retry")' - @click='action("retry")' + @click.stop='action("retry")' ) i.icon-undo @@ -53,7 +53,7 @@ menu-parent(ref='menu') ul menu-item.hide-desktop.is-icon-small( tag='button' - @click='action("openEmoticon", $event)' + @click.stop='action("openEmoticon", $event)' ) i.icon-smile-beam i18n Add reaction @@ -61,7 +61,7 @@ menu-parent(ref='menu') menu-item.hide-desktop.is-icon-small( tag='button' v-if='isEditable' - @click='action("editMessage")' + @click.stop='action("editMessage")' ) i.icon-pencil-alt i18n Edit @@ -69,7 +69,7 @@ menu-parent(ref='menu') menu-item.hide-desktop.is-icon-small( tag='button' v-if='isText' - @click='action("reply")' + @click.stop='action("reply")' ) i.icon-reply i18n Reply @@ -77,7 +77,7 @@ menu-parent(ref='menu') menu-item.hide-desktop.is-icon-small( tag='button' v-if='variant==="failed"' - @click='action("retry")' + @click.stop='action("retry")' ) i.icon-undo i18n Add emoticons @@ -85,23 +85,41 @@ menu-parent(ref='menu') menu-item.is-icon-small( v-if='isText' tag='button' - @click='action("copyMessageText")' + @click.stop='action("copyMessageText")' ) i.icon-copy i18n Copy message text menu-item.is-icon-small( tag='button' - @click='action("copyMessageLink")' + @click.stop='action("copyMessageLink")' ) i.icon-link i18n Copy message link + menu-item.is-icon-small( + v-if='!isAlreadyPinned && isPinnable' + tag='button' + data-test='pinMessage' + @click.stop='action("pinToChannel")' + ) + i.icon-thumbtack + i18n Pin to channel + + menu-item.is-icon-small( + v-if='isAlreadyPinned' + tag='button' + data-test='unpinMessage' + @click.stop='action("unpinFromChannel")' + ) + i.icon-thumbtack + i18n Unpin from channel + menu-item.is-icon-small.is-danger( tag='button' data-test='deleteMessage' v-if='isDeletable' - @click='action("deleteMessage")' + @click.stop='action("deleteMessage")' ) i.icon-trash-alt i18n Delete message @@ -132,7 +150,8 @@ export default ({ text: String, type: String, isMsgSender: Boolean, - isGroupCreator: Boolean + isGroupCreator: Boolean, + isAlreadyPinned: Boolean }, computed: { isText () { @@ -141,6 +160,9 @@ export default ({ isPoll () { return this.type === MESSAGE_TYPES.POLL }, + isPinnable () { + return this.isText || this.isPoll + }, isEditable () { return this.isMsgSender && (this.isText || this.isPoll) }, diff --git a/frontend/views/containers/chatroom/MessageBase.vue b/frontend/views/containers/chatroom/MessageBase.vue index b394dcfde..e1524de08 100644 --- a/frontend/views/containers/chatroom/MessageBase.vue +++ b/frontend/views/containers/chatroom/MessageBase.vue @@ -1,10 +1,15 @@ + + diff --git a/frontend/views/containers/chatroom/PollMixin.js b/frontend/views/containers/chatroom/PollMixin.js new file mode 100644 index 000000000..149e04e46 --- /dev/null +++ b/frontend/views/containers/chatroom/PollMixin.js @@ -0,0 +1,38 @@ +import { mapGetters } from 'vuex' +import { POLL_STATUS, POLL_TYPES } from '@model/contracts/shared/constants.js' +import { humanDate } from '@model/contracts/shared/time.js' + +const PollMixin: Object = { + computed: { + ...mapGetters([ + 'ourIdentityContractId', + 'currentChatRoomId' + ]), + votesFlattened () { + return this.pollData.options.reduce((accu, opt) => [...accu, ...opt.voted], []) + }, + totalVoteCount () { + return this.votesFlattened.length + }, + hasVoted () { // checks if the current user has voted on this poll or not + return this.votesFlattened.includes(this.ourIdentityContractId) + }, + isPollEditable () { // If the current user is the creator of the poll and no one has voted yet, poll can be editted. + return this.isMsgSender && this.votesFlattened.length === 0 + }, + isPollExpired () { + return this.pollData.status === POLL_STATUS.CLOSED + }, + isAnonymousPoll () { + return !!this.pollData.hideVoters + }, + allowMultipleChoices () { + return this.pollData.pollType === POLL_TYPES.MULTIPLE_CHOICES + }, + pollExpiryDate () { + return humanDate(new Date(this.pollData.expires_date_ms)) + } + } +} + +export default PollMixin diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index 37caa32c3..baad62557 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -49,8 +49,8 @@ button.is-icon-small i.icon-arrow-down - .c-replying-wrapper - .c-replying(v-if='replyingMessage') + .c-reply-wrapper + .c-reply(v-if='replyingMessage') i18n(:args='{ replyingTo, text: replyingMessage.text }') Replying to {replyingTo}: "{text}" button.c-clear.is-icon-small( :aria-label='L("Stop replying")' @@ -1067,7 +1067,7 @@ export default ({ box-shadow: 0 0.5rem 1.25rem rgba(54, 54, 54, 0.3); } -.c-replying-wrapper { +.c-reply-wrapper { display: table; table-layout: fixed; width: 100%; @@ -1075,7 +1075,7 @@ export default ({ top: -2.1rem; } -.c-replying { +.c-reply { display: table-cell; background-color: $general_2; padding: 0.4rem 2rem 0.5rem 0.5rem; diff --git a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue index 2e18c7566..41cae14cb 100644 --- a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue +++ b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue @@ -37,7 +37,7 @@ ) button.is-icon-small( :aria-label='L("Download")' - @click='downloadAttachment(entryIndex)' + @click.stop='downloadAttachment(entryIndex)' ) i.icon-download tooltip( @@ -176,6 +176,13 @@ export default { aTag.setAttribute('href', url) aTag.setAttribute('download', attachment.name) + + aTag.addEventListener('click', function (event) { + // NOTE: should call stopPropagation here to keep showing the PinnedMessages dialog + // when user trys to download attachment inside the dialog + event.stopPropagation() + }) + aTag.click() } catch (err) { console.error('error caught while downloading a file: ', err) diff --git a/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue b/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue index 1b2c97110..0b1116a36 100644 --- a/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue +++ b/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue @@ -39,16 +39,15 @@ form(@submit.prevent='')