diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 559122ae9d..3c034fbe85 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -108,7 +108,7 @@ export default (sbp('sbp/selectors/register', { name: 'group-csk', purpose: ['sig'], ringLevel: 2, - permissions: [GIMessage.OP_ACTION_ENCRYPTED], + permissions: [GIMessage.OP_ATOMIC, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_ENCRYPTED], allowedActions: ['gi.contracts/chatroom/leave'], foreignKey: params.options.groupKeys[0].foreignKey, meta: params.options.groupKeys[0].meta, @@ -119,7 +119,7 @@ export default (sbp('sbp/selectors/register', { name: 'group-cek', purpose: ['enc'], ringLevel: 2, - permissions: [GIMessage.OP_ACTION_ENCRYPTED], + permissions: [GIMessage.OP_ATOMIC, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_ENCRYPTED], allowedActions: ['gi.contracts/chatroom/join', 'gi.contracts/chatroom/leave'], foreignKey: params.options.groupKeys[1].foreignKey, meta: params.options.groupKeys[1].meta, @@ -259,6 +259,25 @@ export default (sbp('sbp/selectors/register', { const hooks = { ...params?.hooks, preSendCheck (msg, state) { + const x = document.createElement('pre') + x.innerText = JSON.stringify({ + c: params.contractID, + m: params.data.member, + s: state.users?.[params.data.member] ?? '---', + sx: state.users, + db: sbp('state/vuex/state').contracts[params.contractID] + }, undefined, 2) + Object.assign(x.style, { + position: 'absolute', + color: '#fff', + background: '#000', + padding: '2px', + border: '1px solid red', + top: '0', + left: '0', + pointerEvents: 'none' + }) + document.body?.appendChild(x) console.error('cL', params.contractID, params.data.member, state.users?.[params.data.member]) // Avoid sending a duplicate action if the person isn't a // chatroom member diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 99e17fe38b..3c8836eed6 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -281,7 +281,7 @@ export default (sbp('sbp/selectors/register', { return deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt + stateEncryptionKeyId) })) - const contractIDs = [] + const contractIDs = Object.create(null) // login can be called when no settings are saved (e.g. from Signup.vue) if (state) { // The retrieved local data might need to be completed in case it was originally saved @@ -289,7 +289,12 @@ export default (sbp('sbp/selectors/register', { sbp('state/vuex/postUpgradeVerification', state) sbp('state/vuex/replace', state) sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state - contractIDs.push(...Object.keys(state.contracts)) + Object.entries(state.contracts).forEach(([id, { type }]) => { + if (!contractIDs[type]) { + contractIDs[type] = [] + } + contractIDs[type].push(id) + }) } await sbp('gi.db/settings/save', SETTING_CURRENT_USER, identityContractID) @@ -334,7 +339,19 @@ export default (sbp('sbp/selectors/register', { throw new Error('Unable to sync identity contract') }).then(() => { - return sbp('chelonia/contract/sync', contractIDs, { force: true }) + // $FlowFixMe[incompatible-call] + return Promise.all(Object.entries(contractIDs).sort(([a], [b]) => { + if (a === b) return 0 + if (a === 'gi.contracts/identity') return -1 + if (b === 'gi.contracts/identity') return 1 + if (a === 'gi.contracts/group') return -1 + if (b === 'gi.contracts/group') return 1 + if (a === 'gi.contracts/chatroom') return -1 + if (b === 'gi.contracts/chatroom') return 1 + return 0 + }).map(([, ids]) => { + return sbp('okTurtles.eventQueue/queueEvent', `login:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }]) + })) .catch((err) => { console.error('Error during contract sync upon login (syncing all contractIDs)', err) }) @@ -347,7 +364,8 @@ export default (sbp('sbp/selectors/register', { // contract sync might've triggered an async call to /remove, so // wait before proceeding - await sbp('chelonia/contract/wait', Array.from(new Set([...groupIds, ...contractIDs]))) + // $FlowFixMe[incompatible-call] + await sbp('chelonia/contract/wait', Array.from(new Set([...groupIds, ...Object.values(contractIDs).flat()]))) // Call 'gi.actions/group/join' on all groups which may need re-joining await Promise.allSettled( diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 171575e800..e8df5c6e48 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -1066,8 +1066,9 @@ 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 }, { state }) { + sideEffect ({ meta, contractID, innerSigningContractID }, { state }) { const { loggedIn } = sbp('state/vuex/state') + console.error('@@@AT inviteAccept for', { contractID, innerSigningContractID, meta }) sbp('chelonia/queueInvocation', contractID, async () => { const rootState = sbp('state/vuex/state') @@ -1082,21 +1083,22 @@ sbp('chelonia/defineContract', { const { profiles = {} } = state if (profiles[meta.username].status !== PROFILE_STATUS.ACTIVE) { + console.error('@@@RETURNING AS PROFILE_STATUS.ACTIVE IS FALSE', { contractID, innerSigningContractID, meta }) return } + const userID = loggedIn.identityContractID + // TODO: per #257 this will ,have to be encompassed in a recoverable transaction // however per #610 that might be handled in handleEvent (?), or per #356 might not be needed - if (meta.username === loggedIn.username) { + if (innerSigningContractID === userID) { // we're the person who just accepted the group invite - - const userID = loggedIn.identityContractID - // Add the group's CSK to our identity contract so that we can receive // DMs. await sbp('gi.actions/identity/addJoinDirectMessageKey', userID, contractID, 'csk') const generalChatRoomId = state.generalChatRoomId + console.error('@@@WILL JOIN GC', { contractID, innerSigningContractID, meta, generalChatRoomId }) if (generalChatRoomId) { // Join the general chatroom if (state.chatRooms[generalChatRoomId]?.users?.[loggedIn.username]?.status !== PROFILE_STATUS.ACTIVE) { @@ -1120,6 +1122,8 @@ sbp('chelonia/defineContract', { alert(L("Couldn't join the #{chatroomName} in the group. An error occurred: #{error}.", { chatroomName: CHATROOM_GENERAL_NAME, error: e?.message || e })) }, 0) }) + } else { + console.error('@@@ALREADY JOINED JC', { contractID, innerSigningContractID, meta, generalChatRoomId }) } } else { // setTimeout to avoid blocking the main thread @@ -1387,7 +1391,14 @@ sbp('chelonia/defineContract', { // Abort the joining action if it's been initiated. This way, calling /remove on the leave action will work if (sbp('okTurtles.data/get', `JOINING_CHATROOM-${data.chatRoomID}-${data.member}`)) { sbp('okTurtles.data/delete', `JOINING_CHATROOM-${data.chatRoomID}-${data.member}`) - sbp('chelonia/contract/remove', data.chatRoomID).catch((e) => { + sbp('chelonia/contract/remove', data.chatRoomID).then(() => { + const rootState = sbp('state/vuex/state') + if (rootState.currentChatRoomIDs[contractID] === data.chatRoomID) { + sbp('state/vuex/commit', 'setCurrentChatRoomId', { + groupId: contractID + }) + } + }).catch((e) => { console.error(`[gi.contracts/group/leaveChatRoom/sideEffect] Error calling remove for ${contractID} on chatroom ${data.chatRoomID}`, e) }) } @@ -1424,7 +1435,7 @@ sbp('chelonia/defineContract', { // If we added someone to the chatroom (including ourselves), we issue // the relevant action to the chatroom contract if (innerSigningContractID === rootState.loggedIn.identityContractID) { - sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, username, rootState.loggedIn.username)).catch((e) => { + sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, username)).catch((e) => { console.error(`[gi.contracts/group/joinChatRoom/sideEffect] Error adding member to group chatroom for ${contractID}`, { e, data }) }) } else if (username === rootState.loggedIn.username) { @@ -1748,12 +1759,12 @@ sbp('chelonia/defineContract', { }) } }, - 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomId, member, loggedInUsername) { + 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomId, member) { const rootState = sbp('state/vuex/state') const state = rootState[contractID] const username = rootState.loggedIn.username - if (loggedInUsername !== username || state?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE || state?.chatRooms?.[chatRoomId]?.users[member]?.status !== PROFILE_STATUS.ACTIVE) { + if (state?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE || state.chatRooms?.[chatRoomId]?.users[member]?.status !== PROFILE_STATUS.ACTIVE) { return } @@ -1761,7 +1772,7 @@ sbp('chelonia/defineContract', { await sbp('chelonia/contract/sync', chatRoomId, { deferredRemove: true }) if (!sbp('chelonia/contract/hasKeysToPerformOperation', chatRoomId, 'gi.contracts/chatroom/join')) { - return + throw new Error(`Missing keys to join chatroom ${chatRoomId}`) } // Using the group's CEK allows for everyone to have an overview of the @@ -1772,14 +1783,17 @@ sbp('chelonia/defineContract', { await sbp('gi.actions/chatroom/join', { contractID: chatRoomId, data: { username: member }, - encryptionKeyId + encryptionKeyId, + ...username === member && { + hooks: { + onprocessed: () => { + sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupId: contractID, chatRoomId }) + } + } + } }).catch(e => { console.error(`Unable to join ${member} to chatroom ${chatRoomId} for group ${contractID}`, e) }) - - if (username === member) { - sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupId: contractID, chatRoomId }) - } } finally { await sbp('chelonia/contract/remove', chatRoomId, { removeIfPending: true }) } diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 4d7e44aa29..58fb30edaf 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,7 +1,7 @@ { "manifests": { "gi.contracts/chatroom": "21XWnNHH2e67b1HMpFupf1EmSWH6ATaEN8yDL73d7nHKyduJru", - "gi.contracts/group": "21XWnNU6B4j7vVX2pYDtDmCTb6U7S3C9q4ijfZkberWfn7dMcT", + "gi.contracts/group": "21XWnNVMvDKnsaAr4Sqo328irkQHF3rSigpLdtxcRpUN8HCgG8", "gi.contracts/identity": "21XWnNK49ogfNcAQfanvYPyEbGHRyekriVhAegxAvnWgZyLL8b" } } diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index 9d48390922..dce430881f 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -12,9 +12,9 @@ emoticons - template(v-for='x in latestEvents') - div - pre(style='overflow-wrap: anywhere; white-space: pre-wrap; font-size: 0.8em') {{ JSON.stringify({...GIMessage.deserializeHEAD(x).head, version: void 0, manifest: void 0 }) }} + div(style='height: 0; overflow: visible') + div(v-for='x in latestEvents') + pre(style='background: #000; color: #fff; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 0.9em') {{ JSON.stringify({...GIMessage.deserializeHEAD(x).head, version: void 0, manifest: void 0 }) }} .c-body .c-body-conversation( diff --git a/frontend/views/containers/chatroom/ChatMembersAllModal.vue b/frontend/views/containers/chatroom/ChatMembersAllModal.vue index f56c2a64e5..50ab94e097 100644 --- a/frontend/views/containers/chatroom/ChatMembersAllModal.vue +++ b/frontend/views/containers/chatroom/ChatMembersAllModal.vue @@ -124,7 +124,7 @@ import ProfileCard from '@components/ProfileCard.vue' import ButtonSubmit from '@components/ButtonSubmit.vue' import DMMixin from './DMMixin.js' import GroupMembersTooltipPending from '@containers/dashboard/GroupMembersTooltipPending.vue' -import { CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' +import { CHATROOM_PRIVACY_LEVEL, PROFILE_STATUS } from '@model/contracts/shared/constants.js' import { uniq } from '@model/contracts/shared/giLodash.js' import { filterByKeyword } from '@view-utils/filters.js' @@ -207,7 +207,7 @@ export default ({ return this.isJoined ? this.chatRoomUsersInSort : this.groupMembersSorted - .filter(member => this.getGroupChatRooms[this.currentChatRoomId].users.includes(member.username)) + .filter(member => this.getGroupChatRooms[this.currentChatRoomId].users[member.username]?.status === PROFILE_STATUS.ACTIVE) .map(member => ({ username: member.username, displayName: member.displayName })) } }, diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 7d279f4a99..f660b933e7 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -361,7 +361,7 @@ export default (sbp('sbp/selectors/register', { // We always call recreateEvent because we may have received new events // in the web socket if (!isFirstMessage) { - return recreateEvent(entry, state) + return recreateEvent(entry, state, rootState.contracts[contractID]) } return entry @@ -401,7 +401,7 @@ export default (sbp('sbp/selectors/register', { // TODO: The [pubsub] code seems to miss events that happened between // a call to sync and the subscription time. This is a temporary measure // to handle this until [pubsub] is updated. - if (entry.height() === lastAttemptedHeight) { + if (!entry.isFirstMessage() && entry.height() === lastAttemptedHeight) { await sbp('chelonia/contract/sync', contractID, { force: true }) } } else { @@ -487,7 +487,14 @@ export default (sbp('sbp/selectors/register', { if (!state._vm) config.reactiveSet(state, '_vm', Object.create(null)) const opFns: { [GIOpType]: (any) => void } = { [GIMessage.OP_ATOMIC] (v: GIOpAtomic) { - v.forEach((u) => opFns[u[0]](u[1])) + v.forEach((u) => { + if (u[0] === GIMessage.OP_ATOMIC) throw new Error('Cannot nest OP_ATOMIC') + if (!validateKeyPermissions(state, signingKeyId, u[0], u[1])) { + throw new Error('Inside OP_ATOMIC: no matching signing key was defined') + } + console.error('@@@OP_ATOMIC', u, state) + opFns[u[0]](u[1]) + }) }, [GIMessage.OP_CONTRACT] (v: GIOpContract) { state._vm.type = v.type @@ -1451,6 +1458,23 @@ export default (sbp('sbp/selectors/register', { try { await handleEvent.processMutation.call(this, message, contractStateCopy, internalSideEffectStack) } catch (e) { + if (document && state.contracts[contractID]?.type === 'gi.contracts/chatroom') { + const x = document.createElement('pre') + x.innerText = JSON.stringify({ c: contractID, cs: state.contracts[contractID], s: state[contractID].users, en: e?.name, d: e?.message }, undefined, 2) + Object.assign(x.style, { + position: 'absolute', + color: '#00f', + background: '#fff', + padding: '2px', + border: '1px solid red', + top: '0', + right: '0', + pointerEvents: 'none', + zIndex: '9999' + }) + document.body?.appendChild(x) + } + if (e?.name === 'ChelErrorDecryptionKeyNotFound') { console.warn(`[chelonia] WARN '${e.name}' in processMutation for ${message.description()}: ${e.message}`, e, message.serialize()) } else { @@ -1488,11 +1512,70 @@ export default (sbp('sbp/selectors/register', { // an inconsistent state if a sudden failure happens while this code // is executing. In particular, everything in between should be synchronous. try { + const state = sbp(this.config.stateSelector) + if (document && state.contracts[contractID]?.type === 'gi.contracts/chatroom') { + const x = document.createElement('pre') + x.innerText = JSON.stringify({ c: contractID, cs: state.contracts[contractID], s: state[contractID].users }, undefined, 2) + Object.assign(x.style, { + position: 'absolute', + color: '#0f0', + background: '#000', + padding: '2px', + border: '1px solid red', + top: '0', + right: '0', + pointerEvents: 'none', + zIndex: '9999' + }) + document.body?.appendChild(x) + } + handleEvent.applyProccessResult.call(this, { message, state, contractState: contractStateCopy, processingErrored, postHandleEvent }) } catch (e) { + const x = document.createElement('pre') + x.innerText = JSON.stringify({ + m: JSON.parse(JSON.parse(rawMessage).head), + s: e.stack, + e: e.message, + c: contractID, + db: sbp('state/vuex/state').contracts[contractID] + }, undefined, 2) + Object.assign(x.style, { + position: 'absolute', + color: '#000', + background: '#fff', + padding: '2px', + border: '1px solid red', + top: '0', + right: '0', + pointerEvents: 'none', + zIndex: '9999' + }) + document.body?.appendChild(x) + console.error(`[chelonia] ERROR '${e.name}' for ${message.description()} marking the event as processed: ${e.message}`, e, { message: message.serialize() }) } } catch (e) { + const x = document.createElement('pre') + x.innerText = JSON.stringify({ + m: rawMessage, + s: e.stack, + e: e.message, + c: contractID, + db: sbp('state/vuex/state').contracts[contractID] + }, undefined, 2) + Object.assign(x.style, { + position: 'absolute', + color: '#fff', + background: '#000', + padding: '2px', + border: '1px solid red', + top: '0', + right: '0', + pointerEvents: 'none' + }) + document.body?.appendChild(x) + console.error(`[chelonia] ERROR in handleEvent: ${e.message || e}`, e) try { handleEventError?.(e, message) diff --git a/shared/domains/chelonia/utils.js b/shared/domains/chelonia/utils.js index f747fbef09..9d9f8160a6 100644 --- a/shared/domains/chelonia/utils.js +++ b/shared/domains/chelonia/utils.js @@ -121,33 +121,6 @@ export const validateKeyPermissions = (state: Object, signingKeyId: string, opT: return false } - if (opT === GIMessage.OP_ATOMIC) { - if ( - ((opV: any): GIOpAtomic).reduce( - (acc, [opT, opV]) => { - if (!acc || !(signingKey: any).permissions.includes(opT)) return false - if ( - opT === GIMessage.OP_ACTION_UNENCRYPTED && - !validateActionPermissions(signingKey, state, opT, (opV: any)) - ) { - return false - } - - if ( - opT === GIMessage.OP_ACTION_ENCRYPTED && - !validateActionPermissions(signingKey, state, opT, (opV: any).valueOf()) - ) { - return false - } - - return true - }, true) - ) { - console.error(`OP_ATOMIC: Signing key ${signingKey.id} is missing permissions for inner operation ${opT}`) - return false - } - } - if ( opT === GIMessage.OP_ACTION_UNENCRYPTED && !validateActionPermissions(signingKey, state, opT, (opV: any)) @@ -401,9 +374,8 @@ export const subscribeToForeignKeyContracts = function (contractID: string, stat // duplicate operations. For operations involving keys, the payload will be // rewritten to eliminate no-longer-relevant keys. In most cases, this would // result in an empty payload, in which case the message is omitted entirely. -export const recreateEvent = async (entry: GIMessage, state: Object): Promise => { - const contractID = entry.contractID() - const { HEAD: previousHEAD, height: previousHeight } = await sbp('chelonia/db/latestHEADinfo', contractID) || {} +export const recreateEvent = (entry: GIMessage, state: Object, contractsState: Object): Promise => { + const { HEAD: previousHEAD, height: previousHeight } = contractsState || {} if (!previousHEAD) { throw new Error('recreateEvent: Giving up because the contract has been removed') } diff --git a/test/cypress/integration/group-chat-message.spec.js b/test/cypress/integration/group-chat-message.spec.js deleted file mode 100644 index cbe232cd8f..0000000000 --- a/test/cypress/integration/group-chat-message.spec.js +++ /dev/null @@ -1,235 +0,0 @@ -import { CHATROOM_GENERAL_NAME } from '../../../frontend/model/contracts/shared/constants.js' - -/** - * Should import from this function from '../../../frontend/model/contracts/shared/functions.js' - * But Cypress doesn't render files using Flowtype annotations - * So copied that function and use it here - * TODO: figure out how to import functions with type annotations in Cypress - */ -function makeMentionFromUsername (username) { - return { - me: `@${username}`, - all: '@all' - } -} - -const groupName = 'Dreamers' -const userId = performance.now().toFixed(20).replace('.', '') -const user1 = `user1${userId}` -const user2 = `user2${userId}` -const user3 = `user3${userId}` -let invitationLinkAnyone -let me - -describe('Send/edit/remove messages & add/remove emoticons inside group chat', () => { - function switchUser (username) { - cy.giSwitchUser(username) - me = username - } - - function sendMessage (message) { - cy.getByDT('messageInputWrapper').within(() => { - cy.get('textarea').clear().type(`${message}{enter}`) - cy.get('textarea').should('be.empty') - }) - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message:last-child .c-who > span:first-child').should('contain', me) - cy.get('.c-message.sent:last-child .c-text').should('contain', message) - }) - } - - function editMessage (nth, message) { - cy.getByDT('conversationWrapper').find(`.c-message:nth-child(${nth})`).within(() => { - cy.get('.c-menu>.c-actions').invoke('attr', 'style', 'display: flex').invoke('show') - cy.get('.c-menu>.c-actions button[aria-label="Edit"]').click() - cy.getByDT('messageInputWrapper').within(() => { - cy.get('textarea').clear().type(`${message}{enter}`) - }) - cy.get('.c-text').should('contain', message) - cy.get('.c-edited').should('contain', '(edited)') - }) - cy.getByDT('conversationWrapper').find(`.c-message.sent:nth-child(${nth})`).should('exist') - } - - function deleteMessage (nth, countAfter) { - cy.getByDT('conversationWrapper').find(`.c-message:nth-child(${nth})`).within(() => { - cy.get('.c-menu>.c-actions').invoke('attr', 'style', 'display: flex').invoke('show') - cy.get('.c-menu').within(() => { - cy.getByDT('menuTrigger').click() - cy.getByDT('menuContent').within(() => { - cy.getByDT('deleteMessage').click() - }) - }) - }) - - cy.getByDT('conversationWrapper').within(() => { - cy.get(`.c-message:nth-child(${nth})`).should('have.class', 'c-disappeared') - }) - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message').should('have.length', countAfter) - }) - } - - function sendEmoticon (nth, emojiCode, emojiCount) { - const emojiWrapperSelector = '.c-picker-wrapper .emoji-mart .vue-recycle-scroller__item-wrapper .vue-recycle-scroller__item-view:first-child .emoji-mart-category' - cy.getByDT('conversationWrapper').find(`.c-message:nth-child(${nth})`).within(() => { - cy.get('.c-menu>.c-actions').invoke('attr', 'style', 'display: flex').invoke('show') - cy.get('.c-menu>.c-actions button[aria-label="Add reaction"]').click() - cy.get('.c-menu>.c-actions').invoke('hide') - }) - cy.get(`${emojiWrapperSelector} span[data-title="${emojiCode}"]`).eq(0).click() - - cy.getByDT('conversationWrapper').within(() => { - cy.get(`.c-message:nth-child(${nth}) .c-emoticons-list`).should('exist') - cy.get(`.c-message:nth-child(${nth}) .c-emoticons-list>.c-emoticon-wrapper`).should('have.length', emojiCount + 1) - }) - } - - function deleteEmotion (nthMesage, nthEmoji, emojiCount) { - const expectedEmojiCount = !emojiCount ? 0 : emojiCount + 1 - cy.getByDT('conversationWrapper').find(`.c-message:nth-child(${nthMesage})`).within(() => { - cy.get('.c-emoticons-list').should('exist') - cy.get('.c-emoticons-list>.c-emoticon-wrapper').should('have.length', emojiCount + 2) - cy.get(`.c-emoticons-list>.c-emoticon-wrapper:nth-child(${nthEmoji})`).click() - }) - - cy.getByDT('conversationWrapper').within(() => { - cy.get(`.c-message:nth-child(${nthMesage}) .c-emoticons-list>.c-emoticon-wrapper`).should('have.length', expectedEmojiCount) - }) - } - - function checkMessageBySender (index, sender, message) { - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message').eq(index).find('.c-who > span:first-child').should('contain', sender) - cy.get('.c-message').eq(index).find('.c-text').should('contain', message) - }) - } - - it(`user1 creats '${groupName}' group and joins "${CHATROOM_GENERAL_NAME}" channel by default`, () => { - cy.visit('/') - cy.giSignup(user1) - me = user1 - - cy.giCreateGroup(groupName, { bypassUI: true }) - cy.giGetInvitationAnyone().then(url => { - invitationLinkAnyone = url - }) - cy.giRedirectToGroupChat() - cy.getByDT('channelName').should('contain', CHATROOM_GENERAL_NAME) - cy.getByDT('channelsList').within(() => { - cy.get('ul').children().should('have.length', 1) - }) - cy.giCheckIfJoinedChatroom(CHATROOM_GENERAL_NAME, me) - - cy.giLogout() - }) - - it(`user2 joins ${groupName} group and sends greetings, asks to have meeting`, () => { - cy.giAcceptGroupInvite(invitationLinkAnyone, { - username: user2, - existingMemberUsername: user1, - groupName: groupName, - shouldLogoutAfter: false, - bypassUI: true - }) - me = user2 - - cy.giRedirectToGroupChat() - sendMessage(`Hello ${user1}. How are you? Thanks for inviting me to this awesome group.`) - sendMessage('Can we have a meeting this morning?') - }) - - it('user1 sends greetings and edits', () => { - switchUser(user1) - cy.giRedirectToGroupChat() - - sendMessage('Hi') - - editMessage(7, `Hi ${user2}. I am fine thanks.`) - }) - - it('user2 edits and deletes message', () => { - switchUser(user2) - cy.giRedirectToGroupChat() - - editMessage(5, 'Can we have a meeting this evening?') - - deleteMessage(4, 4) - }) - - it('user1 sees the edited message but he is not able to see the deleted message', () => { - switchUser(user1) - cy.giRedirectToGroupChat() - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message').should('have.length', 4) - }) - - checkMessageBySender(0, user1, `Joined ${CHATROOM_GENERAL_NAME}`) - checkMessageBySender(1, user2, `Joined ${CHATROOM_GENERAL_NAME}`) - checkMessageBySender(2, user2, 'Can we have a meeting this evening?') - checkMessageBySender(3, user1, `Hi ${user2}. I am fine thanks.`) - }) - - it('user1 adds 4 emojis and removes 1 emoji', () => { - sendEmoticon(4, '+1', 1) - sendEmoticon(4, 'joy', 2) - sendEmoticon(4, 'grinning', 3) - - sendEmoticon(5, 'joy', 1) - - deleteEmotion(4, 2, 2) - }) - - it('user2 sees the emojis user1 created and adds his emoji', () => { - switchUser(user2) - cy.giRedirectToGroupChat() - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message:nth-child(4) .c-emoticons-list>.c-emoticon-wrapper').should('have.length', 3) - cy.get('.c-message:nth-child(5) .c-emoticons-list>.c-emoticon-wrapper').should('have.length', 2) - }) - - sendEmoticon(5, '+1', 2) - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message:nth-child(5) .c-emoticons-list>.c-emoticon-wrapper').should('have.length', 3) - cy.get('.c-message:nth-child(5) .c-emoticons-list>.c-emoticon-wrapper.is-user-emoticon').should('have.length', 1) - }) - cy.giLogout() - }) - - it(`user3 joins ${groupName} group and mentions user1 and all`, () => { - cy.giAcceptGroupInvite(invitationLinkAnyone, { - username: user3, - existingMemberUsername: user1, - groupName: groupName, - shouldLogoutAfter: false, - bypassUI: true - }) - me = user3 - cy.giRedirectToGroupChat() - - sendMessage(`Hi ${makeMentionFromUsername(user1).all}. Hope you are doing well.`) - sendMessage(`I am a friend of ${makeMentionFromUsername(user1).me}. Let's work together.`) - }) - - it('user2 and user1 check mentions for themselves', () => { - // NOTE: test assertions are commented here - // That's because we don't display notifications if user signs in another device - switchUser(user2) - - // cy.getByDT('groupChatLink').get('.c-badge.is-compact[aria-label="1 new notifications"]').contains('1') - cy.giRedirectToGroupChat() - // cy.getByDT(`channel-${CHATROOM_GENERAL_NAME}-in`).get('.c-unreadcount-wrapper').contains('1') - - switchUser(user1) - - // cy.getByDT('groupChatLink').get('.c-badge.is-compact[aria-label="2 new notifications"]').contains('2') - cy.giRedirectToGroupChat() - // cy.getByDT(`channel-${CHATROOM_GENERAL_NAME}-in`).get('.c-unreadcount-wrapper').contains('2') - - cy.giLogout() - }) -}) diff --git a/test/cypress/integration/group-chat-scrolling.spec.js b/test/cypress/integration/group-chat-scrolling.spec.js deleted file mode 100644 index a9166ade74..0000000000 --- a/test/cypress/integration/group-chat-scrolling.spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { CHATROOM_GENERAL_NAME } from '../../../frontend/model/contracts/shared/constants.js' - -const groupName = 'Dreamers' -const additionalChannelName = 'Bulgaria Hackathon' -const userId = performance.now().toFixed(20).replace('.', '') -const user1 = `user1-${userId}` -const user2 = `user2-${userId}` -let invitationLinkAnyone -let me - -describe('Send/edit/remove messages & add/remove emoticons inside group chat', () => { - function switchUser (username) { - cy.giSwitchUser(username) - me = username - } - - function switchChannel (channelName) { - cy.getByDT('channelsList').within(() => { - cy.get('ul > li').each(($el, index, $list) => { - if ($el.text() === channelName) { - cy.wrap($el).click() - return false - } - }) - }) - cy.getByDT('channelName').should('contain', channelName) - - cy.giWaitUntilMessagesLoaded() - } - - function sendMessage (message) { - cy.getByDT('messageInputWrapper').within(() => { - cy.get('textarea').clear().type(`${message}{enter}`) - cy.get('textarea').should('be.empty') - }) - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message:last-child .c-who > span:first-child').should('contain', me) - cy.get('.c-message.sent:last-child .c-text').should('contain', message) - }) - } - - function replyMessage (nth, message) { - cy.getByDT('conversationWrapper').find(`.c-message:nth-child(${nth})`).within(() => { - cy.get('.c-menu>.c-actions') - .invoke('attr', 'style', 'display: flex') - .invoke('show') - .scrollIntoView() - .should('be.visible') - cy.get('.c-menu>.c-actions button[aria-label="Reply"]').click({ force: true }) - cy.get('.c-menu>.c-actions') - .should('be.visible') - .invoke('hide') - .should('be.hidden') - }) - cy.get('.c-tooltip.is-active').invoke('hide') - - cy.getByDT('messageInputWrapper').within(() => { - cy.get('textarea').should('exist') - cy.get('.c-replying-wrapper').should('exist') - }) - - sendMessage(message) - } - - it(`user1 creates '${groupName}' group and joins "${CHATROOM_GENERAL_NAME}" channel by default and sends 15 messages`, () => { - cy.visit('/') - cy.giSignup(user1) - me = user1 - - cy.giCreateGroup(groupName, { bypassUI: true }) - cy.giGetInvitationAnyone().then(url => { - invitationLinkAnyone = url - }) - cy.giRedirectToGroupChat() - cy.getByDT('channelName').should('contain', CHATROOM_GENERAL_NAME) - cy.getByDT('channelsList').within(() => { - cy.get('ul').children().should('have.length', 1) - }) - cy.giCheckIfJoinedChatroom(CHATROOM_GENERAL_NAME, me) - - for (let i = 0; i < 15; i++) { - sendMessage(`Text-${i + 1}`) - } - - cy.giLogout() - }) - - it(`user2 joins ${groupName} group and sends another 15 messages and reply a message`, () => { - cy.giAcceptGroupInvite(invitationLinkAnyone, { - username: user2, - existingMemberUsername: user1, - groupName: groupName, - shouldLogoutAfter: false, - bypassUI: true - }) - me = user2 - cy.giRedirectToGroupChat() - - for (let i = 15; i < 30; i++) { - sendMessage(`Text-${i + 1}`) - } - - replyMessage(5, 'Three') // Message with 'Text-3' - - cy.getByDT('conversationWrapper').within(() => { - cy.contains('Three').should('be.visible') - - cy.get('.c-message').last().should('be.visible').within(() => { - cy.get('.c-replying').should('exist').should('be.visible').click() - }) - - // HACK: scrollIntoView() should not be there - // But cy.get('.c-replying').click() doesn't scroll to the target message - // Because of this can not move forward to the next stages, so just used HACK - cy.get('.c-message:nth-child(5)').should('contain', 'Text-3').scrollIntoView().should('be.visible') - cy.get('.c-replying').should('not.be.visible') - }) - }) - - it('user2 creates a new channel and check how the scroll position is saved for each channel', () => { - cy.giAddNewChatroom(additionalChannelName, '', false) - cy.giCheckIfJoinedChatroom(additionalChannelName, me) - - switchChannel(CHATROOM_GENERAL_NAME) - - cy.getByDT('conversationWrapper').within(() => { - cy.contains('Three').should('not.be.visible') - cy.contains('Text-3').should('be.visible') - }) - - cy.getByDT('messageInputWrapper').within(() => { - cy.get('.c-jump-to-latest').should('exist').click() - }) - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message').last().should('be.visible') - }) - - cy.getByDT('messageInputWrapper').within(() => { - cy.get('.c-jump-to-latest').should('not.exist') - }) - }) - - it('user1 checks how infinite scrolls works', () => { - switchUser(user1) - cy.giRedirectToGroupChat() - - cy.getByDT('conversationWrapper').scrollTo(0) - - cy.getByDT('conversationWrapper').within(() => { - cy.get('.c-message:nth-child(2) .c-who > span:first-child').should('contain', user1) - cy.get('.c-message:nth-child(2) .c-notification').should('contain', `Joined ${CHATROOM_GENERAL_NAME}`) - }) - - cy.giLogout() - }) -})