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 @@
.c-message(
- :class='[variant, isSameSender && "same-sender", "is-type-" + type]'
+ :class='[variant, isSameSender && "same-sender", "is-type-" + type, isAlreadyPinned && "pinned"]'
@click='$emit("wrapperAction")'
v-touch:touchhold='longPressHandler'
v-touch:swipe.left='reply'
)
+ .c-pinned-wrapper(v-if='isAlreadyPinned')
+ .c-pinned-icon
+ i.icon-thumbtack
+ span(v-safe-html='pinLabel')
+
.c-message-wrapper
slot(name='image')
profile-card(:contractID='from' direction='top-left')
@@ -85,19 +90,22 @@
:messageHash='messageHash'
:isMsgSender='isMsgSender'
:isGroupCreator='isGroupCreator'
+ :isAlreadyPinned='isAlreadyPinned'
ref='messageAction'
@openEmoticon='openEmoticon($event)'
@editMessage='editMessage'
@deleteMessage='$emit("delete-message")'
@reply='reply'
@retry='$emit("retry")'
+ @pinToChannel='$emit("pin-to-channel")'
+ @unpinFromChannel='$emit("unpin-from-channel")'
)
+
+
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='')