snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import type {PostMessageResp} from '@/support/task_commands';
|
||||
import {spyNotificationAs} from '@/support/notification';
|
||||
|
||||
describe('CRT Desktop notifications', () => {
|
||||
let testTeam: Team;
|
||||
let testChannelUrl: string;
|
||||
let testChannelId: string;
|
||||
let testChannelName: string;
|
||||
let receiver: UserProfile;
|
||||
let sender: UserProfile;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
});
|
||||
|
||||
cy.apiCreateUser().then(({user}) => {
|
||||
sender = user;
|
||||
});
|
||||
|
||||
cy.apiInitSetup().then(({team, channel, user, channelUrl}) => {
|
||||
testTeam = team;
|
||||
receiver = user;
|
||||
testChannelUrl = channelUrl;
|
||||
testChannelId = channel.id;
|
||||
testChannelName = channel.display_name;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, sender.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannelId, sender.id);
|
||||
});
|
||||
|
||||
// # Login as receiver
|
||||
cy.apiLogin(receiver);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4417_1 Trigger notifications on all replies when channel setting is checked', () => {
|
||||
// # Visit channel
|
||||
cy.visit(testChannelUrl);
|
||||
|
||||
cy.uiOpenChannelMenu('Notification Preferences');
|
||||
|
||||
// # Click on Mute Channel to Unmute Channel
|
||||
cy.findByText('Mute channel').should('be.visible').click({force: true});
|
||||
|
||||
// * Verify that channel is muted alert is visible
|
||||
cy.findByText('This channel is muted').should('be.visible');
|
||||
|
||||
// # Save the changes
|
||||
cy.findByText('Save').should('be.visible').click();
|
||||
|
||||
// Setup notification spy
|
||||
spyNotificationAs('notifySpy', 'granted');
|
||||
|
||||
// # Set users notification settings
|
||||
cy.uiOpenChannelMenu('Notification Preferences');
|
||||
|
||||
// # Click on Mute Channel to Unmute Channel
|
||||
cy.findByText('Mute channel').should('be.visible').click({force: true});
|
||||
|
||||
// # Click "Desktop Notifications"
|
||||
cy.findByText('Desktop Notifications').should('be.visible');
|
||||
|
||||
cy.get('.channel-notifications-settings-modal__body').scrollTo('center').get('#desktopNotification-all').should('be.visible').click();
|
||||
cy.get('.channel-notifications-settings-modal__body').get('#desktopNotification-all').should('be.checked');
|
||||
|
||||
cy.get('#desktopNotification-mention').should('be.visible').click().then(() => {
|
||||
cy.get('[data-testid="desktopReplyThreads"]').should('be.checked');
|
||||
cy.get('[data-testid="desktopReplyThreads"]').should('be.visible').click();
|
||||
cy.get('[data-testid="desktopReplyThreads"]').should('not.be.checked');
|
||||
});
|
||||
cy.get('.channel-notifications-settings-modal__body').scrollTo('center').get('#desktopNotification-mention').should('be.checked');
|
||||
|
||||
cy.get('.channel-notifications-settings-modal__body').scrollTo('center').get('#desktopNotification-none').should('be.visible').click();
|
||||
cy.get('.channel-notifications-settings-modal__body').get('#desktopNotification-none').should('be.checked');
|
||||
|
||||
// # Save the changes
|
||||
cy.findByText('Save').should('be.visible').click();
|
||||
|
||||
// # Set users notification settings
|
||||
cy.uiOpenChannelMenu('Notification Preferences');
|
||||
cy.get('.channel-notifications-settings-modal__body').scrollTo('center').get('#desktopNotification-none').should('be.checked');
|
||||
cy.get('.channel-notifications-settings-modal__body').get('#desktopNotification-all').scrollIntoView().should('be.visible').click();
|
||||
|
||||
// # Save the changes
|
||||
cy.findByText('Save').should('be.visible').click();
|
||||
|
||||
// # Post a root message as other user
|
||||
cy.postMessageAs({sender, message: 'This is a not followed root message', channelId: testChannelId, rootId: ''}).then(({id: postId}) => {
|
||||
// # Switch to town-square so that unread notifications in test channel may be triggered
|
||||
cy.uiClickSidebarItem('town-square');
|
||||
|
||||
// # Post a message in unfollowed thread as another user
|
||||
cy.postMessageAs({sender, message: 'This is a reply to the unfollowed thread', channelId: testChannelId, rootId: postId});
|
||||
|
||||
// * Verify stub was not called for unfollowed thread
|
||||
cy.get('@notifySpy').should('not.be.called');
|
||||
});
|
||||
|
||||
// # Visit channel
|
||||
cy.visit(testChannelUrl);
|
||||
|
||||
// Setup notification spy
|
||||
spyNotificationAs('notifySpy', 'granted');
|
||||
|
||||
// # Post a message
|
||||
cy.postMessage('Hi there, this is a root message');
|
||||
|
||||
// # Get post id of message
|
||||
cy.getLastPostId().then((postId) => {
|
||||
// # Switch to town-square so that unread notifications in test channel may be triggered
|
||||
cy.uiClickSidebarItem('town-square');
|
||||
|
||||
// # Post a message in original thread as another user
|
||||
const message = 'This is a reply to the root post';
|
||||
cy.postMessageAs({sender, message, channelId: testChannelId, rootId: postId});
|
||||
|
||||
// * Verify stub was called with correct title and body
|
||||
cy.get('@notifySpy').should('have.been.calledWithMatch', `Reply in ${testChannelName}`, (args: {body: string}) => {
|
||||
expect(args.body, `Notification body: "${args.body}" should match: "${message}"`).to.equal(`@${sender.username}: ${message}`);
|
||||
return true;
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(postId);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4417_2 Click on sameMobileSettingsDesktop and check if additional settings still appears', () => {
|
||||
cy.visit(testChannelUrl);
|
||||
|
||||
// # Open channel's notification preferences
|
||||
cy.uiOpenChannelMenu('Notification Preferences');
|
||||
|
||||
// * As per previous conditions the mobile and desktop settings should be the same
|
||||
cy.get('[data-testid="sameMobileSettingsDesktop"]').scrollIntoView().should('be.checked');
|
||||
|
||||
// * Verify that Notify me about section of mobile settings is not visible
|
||||
cy.get('[data-testid="mobile-notify-me-radio-section"]').should('not.exist');
|
||||
|
||||
// # Now uncheck the sameMobileSettingsDesktop so that mobile and desktop settings are different
|
||||
cy.get('[data-testid="sameMobileSettingsDesktop"]').scrollIntoView().should('be.visible').click();
|
||||
|
||||
// * Verify that Notify me about section of mobile settings is visible
|
||||
cy.get('[data-testid="mobile-notify-me-radio-section"]').should('be.visible').scrollIntoView().within(() => {
|
||||
cy.findByText('Notify me about…').should('be.visible');
|
||||
|
||||
// # Click on mentions option
|
||||
cy.get('[data-testid="MobileNotification-mention"]').should('be.visible').click();
|
||||
});
|
||||
|
||||
// * Verify that Thread reply notifications section of mobile settings is visible
|
||||
cy.get('[data-testid="mobile-reply-threads-checkbox-section"]').should('be.visible').scrollIntoView().within(() => {
|
||||
cy.findByText('Notify me about replies to threads I’m following').should('be.visible');
|
||||
});
|
||||
|
||||
// # Close the modal
|
||||
cy.get('body').type('{esc}');
|
||||
});
|
||||
|
||||
it('MM-T4417_3 Trigger notifications only on mention replies when channel setting is unchecked', () => {
|
||||
// # Visit the test channel
|
||||
cy.visit(testChannelUrl);
|
||||
|
||||
// # Setup notification spy
|
||||
spyNotificationAs('notifySpy1', 'granted');
|
||||
|
||||
// # Open channel's notification preferences for test channel
|
||||
cy.uiOpenChannelMenu('Notification Preferences');
|
||||
|
||||
// # Select "Mentions, direct messages, and keywords only" as notify me about option
|
||||
cy.get('#desktopNotification-mention').scrollIntoView().should('be.visible').click();
|
||||
|
||||
// # Unselect "Notify me about replies to threads I’m following"
|
||||
cy.get('[data-testid="desktopReplyThreads"]').scrollIntoView().should('be.visible').then(($el) => {
|
||||
if ($el.is(':checked')) {
|
||||
cy.wrap($el).click();
|
||||
}
|
||||
});
|
||||
|
||||
// # Select notification checkbox active
|
||||
cy.get('[data-testid="desktopNotificationSoundsCheckbox"]').scrollIntoView().should('be.visible').then(($el) => {
|
||||
if (!$el.is(':checked')) {
|
||||
cy.wrap($el).click();
|
||||
}
|
||||
});
|
||||
|
||||
// # Select a notification sound from dropdown
|
||||
cy.get('#desktopNotificationSoundsSelect').scrollIntoView().should('be.visible').click();
|
||||
cy.findByText('Crackle').should('be.visible').click();
|
||||
|
||||
// # Save the changes
|
||||
cy.findByText('Save').should('be.visible').click();
|
||||
|
||||
// # Go to the town-square channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
// # Post a root message as other user in the test channel
|
||||
cy.postMessageAs({sender, message: 'This is the root message which will not have a at-mention in thread', channelId: testChannelId, rootId: ''}).then(({id: postId}) => {
|
||||
// # Post a message in the thread without at-mention
|
||||
cy.postMessageAs({sender, message: 'Reply without at-mention', channelId: testChannelId, rootId: postId}).then(() => {
|
||||
// * Verify Notification stub was not called for threads which does not have at-mention as per channel settings
|
||||
cy.get('@notifySpy1').should('not.be.called');
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(postId);
|
||||
});
|
||||
|
||||
// Setup another notification spy
|
||||
spyNotificationAs('notifySpy2', 'granted');
|
||||
|
||||
// # Post another message in the test channel
|
||||
cy.postMessageAs({sender, message: 'This is another root message which will have a at-mention in thread', channelId: testChannelId, rootId: ''}).then(({id: postId}) => {
|
||||
const message = `Reply with at-mention @${receiver.username}`;
|
||||
|
||||
// # Post a message in the thread with at-mention
|
||||
cy.postMessageAs({sender, message, channelId: testChannelId, rootId: postId});
|
||||
|
||||
// * Verify Notification stub was called with correct title and body with at-mention as per channel settings
|
||||
cy.get('@notifySpy2').should('have.been.calledWithMatch', `Reply in ${testChannelName}`, (args: {body: string}) => {
|
||||
expect(args.body, `Notification body: "${args.body}" should match: "${message}"`).to.equal(`@${sender.username}: ${message}`);
|
||||
return true;
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(postId);
|
||||
});
|
||||
});
|
||||
|
||||
it('When a reply is deleted in open channel, the notification should be cleared', () => {
|
||||
// # Visit channel
|
||||
cy.visit(testChannelUrl);
|
||||
|
||||
// # Post a root message as other user
|
||||
cy.postMessageAs({sender, message: 'a thread', channelId: testChannelId, rootId: ''});
|
||||
|
||||
// # Get post id of message
|
||||
cy.getLastPostId().then((postId) => {
|
||||
// # Post a reply to the thread, which will trigger a follow
|
||||
cy.postMessageAs({sender: receiver, message: 'following the thread', channelId: testChannelId, rootId: postId});
|
||||
|
||||
// # Post a reply to the thread
|
||||
cy.postMessageAs({sender, message: 'a reply', channelId: testChannelId, rootId: postId}).as('reply');
|
||||
|
||||
// # Post a mention reply to the thread
|
||||
cy.postMessageAs({sender, message: `@${receiver.username} mention reply`, channelId: testChannelId, rootId: postId}).
|
||||
as('replyMention');
|
||||
|
||||
// * Verify there is a notification
|
||||
cy.get('#sidebarItem_threads #unreadMentions').should('exist').and('have.text', '1');
|
||||
|
||||
// # Delete the replies
|
||||
const replies = ['@reply', '@replyMention'];
|
||||
for (const reply of replies) {
|
||||
cy.get<PostMessageResp>(reply).then(({id}) => {
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
}
|
||||
|
||||
// * Verify there is no notification
|
||||
cy.get('#sidebarItem_threads #unreadMentions').should('not.exist');
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(postId);
|
||||
});
|
||||
});
|
||||
|
||||
it('When a reply is deleted in DM channel, the notification should be cleared', () => {
|
||||
cy.apiCreateDirectChannel([receiver.id, sender.id]).then(({channel: dmChannel}) => {
|
||||
// # Visit channel
|
||||
cy.visit(`/${testTeam.name}/messages/@${sender.username}`);
|
||||
|
||||
// # Post a root message as other user
|
||||
cy.postMessageAs({sender, message: 'a thread', channelId: dmChannel.id, rootId: ''}).as('rootPost');
|
||||
|
||||
// # Get post id of message
|
||||
cy.get<PostMessageResp>('@rootPost').then(({id: rootId}) => {
|
||||
// # Post a reply to the thread, which will trigger a follow
|
||||
cy.postMessageAs({sender: receiver, message: 'following the thread', channelId: dmChannel.id, rootId});
|
||||
|
||||
// # Post a reply to the thread
|
||||
cy.postMessageAs({sender, message: 'a reply', channelId: dmChannel.id, rootId}).as('reply');
|
||||
|
||||
// # Post a mention reply to the thread
|
||||
cy.postMessageAs({sender, message: `@${receiver.username} mention reply`, channelId: dmChannel.id, rootId}).
|
||||
as('replyMention');
|
||||
|
||||
// * Verify there is a notification
|
||||
cy.get('#sidebarItem_threads #unreadMentions').should('exist');
|
||||
|
||||
// # Delete the replies
|
||||
const replies = ['@reply', '@replyMention'];
|
||||
for (const reply of replies) {
|
||||
cy.get<PostMessageResp>(reply).then(({id}) => {
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
}
|
||||
|
||||
// * Verify there is no notification
|
||||
cy.get('#sidebarItem_threads #unreadMentions').should('not.exist');
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(rootId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and visit channel
|
||||
cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => {
|
||||
testTeam = team;
|
||||
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4140 should be able to toggle CRT on/off', () => {
|
||||
// # Set CRT to off
|
||||
cy.uiChangeCRTDisplaySetting('OFF');
|
||||
|
||||
// * Threads menu in sidebar should not be visible
|
||||
cy.get('.SidebarGlobalThreads').should('not.exist');
|
||||
|
||||
// # Set CRT to on
|
||||
cy.uiChangeCRTDisplaySetting('ON');
|
||||
|
||||
// * Threads menu in sidebar should be visible
|
||||
cy.get('.SidebarGlobalThreads').should('exist');
|
||||
|
||||
// # Visit global threads
|
||||
cy.visit(`/${testTeam.name}/threads`);
|
||||
|
||||
// * should see No followed threads yet in H3 title
|
||||
cy.get('h3.no-results__title').should('have.text', 'No followed threads yet');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads @not_cloud
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {interceptFileUpload} from '../files_and_attachments/helpers';
|
||||
|
||||
import * as MESSAGES from '@/fixtures/messages';
|
||||
import {matterpollPlugin} from '@/utils/plugins';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testChannel: Channel;
|
||||
let user1: UserProfile;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
PluginSettings: {
|
||||
Enable: true,
|
||||
},
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
user1 = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(user1.id, 'on');
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
interceptFileUpload();
|
||||
});
|
||||
|
||||
it('MM-T4776 should display poll text without Markdown in the threads list', () => {
|
||||
cy.shouldNotRunOnCloudEdition();
|
||||
cy.shouldHavePluginUploadEnabled();
|
||||
|
||||
// # Upload and enable "matterpoll" plugin
|
||||
cy.apiUploadAndEnablePlugin(matterpollPlugin);
|
||||
|
||||
// # In center post the following: /poll "Do you like https://mattermost.com?"
|
||||
cy.postMessage('/poll "Do you like https://mattermost.com?"');
|
||||
|
||||
cy.getLastPostId().then((pollId) => {
|
||||
const threadAuthorName = user1.nickname || user1.username;
|
||||
|
||||
// # Post a reply on the POLL to create a thread and follow
|
||||
cy.postMessageAs({sender: user1, message: MESSAGES.SMALL, channelId: testChannel.id, rootId: pollId});
|
||||
|
||||
// # Click "Yes" or "No" on the poll post
|
||||
cy.get(`#post_${pollId}`).within(() => {
|
||||
cy.findByText('Yes').click();
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * Text in ThreadItem should say 'username: Do you like https://mattermost.com?'
|
||||
cy.get('.ThreadItem').last().within(() => {
|
||||
cy.contains(threadAuthorName + ': Do you like https://mattermost.com?').should('be.visible');
|
||||
cy.contains('Total votes: 1').should('be.visible');
|
||||
});
|
||||
|
||||
// # Open the thread
|
||||
cy.get('.ThreadItem').last().click();
|
||||
|
||||
// # Go to channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # End the poll
|
||||
cy.get(`#post_${pollId}`).within(() => {
|
||||
cy.findByText('End Poll').click();
|
||||
});
|
||||
cy.findByText('End').click();
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * Text in ThreadItem should say 'username: Do you like https://mattermost.com?'
|
||||
cy.get('.ThreadItem').last().within(() => {
|
||||
cy.contains(threadAuthorName + ': Do you like https://mattermost.com?').should('be.visible');
|
||||
cy.contains('This poll has ended. The results are:').should('be.visible');
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(pollId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {waitUntilUploadComplete, interceptFileUpload} from '../files_and_attachments/helpers';
|
||||
|
||||
import * as MESSAGES from '@/fixtures/messages';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testChannel: Channel;
|
||||
let user1: UserProfile;
|
||||
|
||||
const files = [
|
||||
{
|
||||
testCase: 'MM-T4777_2',
|
||||
filename: 'word-file.doc',
|
||||
extensions: 'DOC',
|
||||
icon: 'icon-file-word-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_3',
|
||||
filename: 'wordx-file.docx',
|
||||
extensions: 'DOCX',
|
||||
icon: 'icon-file-word-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_4',
|
||||
filename: 'powerpoint-file.ppt',
|
||||
extensions: 'PPT',
|
||||
icon: 'icon-file-powerpoint-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_5',
|
||||
filename: 'powerpointx-file.pptx',
|
||||
extensions: 'PPTX',
|
||||
icon: 'icon-file-powerpoint-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_6',
|
||||
filename: 'mp3-audio-file.mp3',
|
||||
extensions: 'MP3',
|
||||
icon: 'icon-file-audio-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_7',
|
||||
filename: 'mp4-video-file.mp4',
|
||||
extensions: 'MP4',
|
||||
icon: 'icon-file-video-outline',
|
||||
},
|
||||
{
|
||||
testCase: 'MM-T4777_8',
|
||||
filename: 'theme.json',
|
||||
extensions: 'JSON',
|
||||
icon: 'icon-file-code-outline',
|
||||
},
|
||||
];
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
user1 = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(user1.id, 'on');
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
interceptFileUpload();
|
||||
});
|
||||
|
||||
it('MM-T4777_1 should show image thumbnail in thread list item', () => {
|
||||
const image = 'jpg-image-file.jpg';
|
||||
|
||||
cy.get('#advancedTextEditorCell').find('#fileUploadInput').attachFile(image);
|
||||
waitUntilUploadComplete();
|
||||
cy.get('.post-image__thumbnail').should('be.visible');
|
||||
cy.uiGetPostTextBox().clear().type('{enter}');
|
||||
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Post a reply to create a thread and follow
|
||||
cy.postMessageAs({sender: user1, message: MESSAGES.SMALL, channelId: testChannel.id, rootId});
|
||||
|
||||
// # Visit Global Threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * Text should be the filename
|
||||
cy.get('.file_card__name').should('have.text', image);
|
||||
|
||||
// * Image should be shown
|
||||
cy.get('.file_card__image.post-image.small').should('be.visible');
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(rootId);
|
||||
});
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
it(`${file.testCase} should display correct icon for ${file.extensions} on threads list`, () => {
|
||||
// # Post a file
|
||||
cy.get('#advancedTextEditorCell').find('#fileUploadInput').attachFile(file.filename);
|
||||
waitUntilUploadComplete();
|
||||
cy.get('.post-image__thumbnail').should('be.visible');
|
||||
cy.uiGetPostTextBox().clear().type('{enter}');
|
||||
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Post a reply to create a thread and follow
|
||||
cy.postMessageAs({sender: user1, message: MESSAGES.SMALL, channelId: testChannel.id, rootId});
|
||||
|
||||
// # Visit Global Threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * Thread item should display the correct icon
|
||||
cy.get('.file_card__attachment').should('have.class', file.icon);
|
||||
|
||||
// * Thread item should display text
|
||||
cy.get('.file_card__name').should('have.text', file.filename);
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(rootId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import * as TIMEOUTS from '@/fixtures/timeouts';
|
||||
import {isMac} from '@/utils';
|
||||
import type {ChainableT} from '@/types';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testUser: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
let testChannel: Channel;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
testUser = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(testUser.id, 'on');
|
||||
cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
|
||||
otherUser = user1;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, otherUser.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, otherUser.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
|
||||
it('MM-T4141_1 should follow a thread after replying', () => {
|
||||
// # Post message as other user
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: 'Root post,',
|
||||
channelId: testChannel.id,
|
||||
}).then(({id: rootId}) => {
|
||||
// * Thread footer should not be visible
|
||||
cy.get(`#post_${rootId}`).find('.ThreadFooter').should('not.exist');
|
||||
|
||||
// # Click on post to open RHS
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// * Button on header should say Follow as current user is not following
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Follow');
|
||||
|
||||
// # Post a reply as current user
|
||||
cy.postMessageReplyInRHS('Reply!');
|
||||
|
||||
// # Get last root post
|
||||
cy.get(`#post_${rootId}`).
|
||||
|
||||
// * thread footer should exist now
|
||||
get('.ThreadFooter').should('exist').
|
||||
within(() => {
|
||||
// * the button on the footer should say Following
|
||||
cy.get('.FollowButton').should('have.text', 'Following');
|
||||
});
|
||||
|
||||
// * the button on the RHS header should now say Following
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Following');
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * There should be a thread there
|
||||
cy.get('div.ThreadItem').should('have.have.lengthOf', 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4141_2 should follow a thread after marking it as unread', () => {
|
||||
// # Post a root post as other user
|
||||
postMessageWithReply(testChannel.id, otherUser, 'Another interesting post,', otherUser, 'Self reply!').then(({rootId, replyId}) => {
|
||||
// # Get root post
|
||||
cy.get(`#post_${rootId}`).within(() => {
|
||||
// * Thread footer should be visible
|
||||
cy.get('.ThreadFooter').should('exist').
|
||||
|
||||
// * Thread footer button should say 'Follow'
|
||||
find('.FollowButton').should('have.text', 'Follow');
|
||||
});
|
||||
|
||||
// # Click on root post to open thread
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// * RHS header button should say 'Follow'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Follow');
|
||||
|
||||
// # Click on the reply's dot menu and mark as unread
|
||||
cy.uiClickPostDropdownMenu(replyId, 'Mark as Unread', 'RHS_COMMENT');
|
||||
|
||||
// * RHS header button should say 'Following'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Following');
|
||||
|
||||
// # Get root post
|
||||
cy.get(`#post_${rootId}`).within(() => {
|
||||
// * Thread footer should be visible
|
||||
cy.get('.ThreadFooter').should('exist').
|
||||
|
||||
// * Thread footer button should say 'Following'
|
||||
find('.FollowButton').should('have.text', 'Following');
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * There should be 2 threads now
|
||||
cy.get('div.ThreadItem').should('have.have.lengthOf', 2);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4141_3 clicking "Following" button in the footer should unfollow the thread', () => {
|
||||
// # Post a root post as other user
|
||||
postMessageWithReply(testChannel.id, otherUser, 'Another interesting post,', testUser, 'Self reply!');
|
||||
|
||||
// # Get last root post in channel
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Open the thread
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// * RHS header button should say 'Following'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Following');
|
||||
|
||||
// # Get thread footer of last root post
|
||||
cy.get(`#post_${rootId}`).within(() => {
|
||||
// * thread footer should exist
|
||||
cy.get('.ThreadFooter').should('exist');
|
||||
|
||||
// * thread footer button should say 'Following'
|
||||
cy.get('.FollowButton').should('have.text', 'Following');
|
||||
|
||||
// # Click thread footer Following button
|
||||
cy.get('.FollowButton').click({force: true});
|
||||
|
||||
// * thread footer button should say 'Follow'
|
||||
cy.get('.FollowButton').should('have.text', 'Follow');
|
||||
});
|
||||
|
||||
// * RHS header button should say 'Follow'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Follow');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4141_4 clicking "Follow" button in the footer should follow the thread', () => {
|
||||
// # Post a root post as other user
|
||||
postMessageWithReply(testChannel.id, otherUser, 'Another interesting post,', otherUser, 'Self reply!');
|
||||
|
||||
// # Get last root post in channel
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Open the thread
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// * RHS header button should say 'Follow'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Follow');
|
||||
|
||||
// # Get thread footer of last root post
|
||||
cy.get(`#post_${rootId}`).within(() => {
|
||||
// * thread footer should exist
|
||||
cy.get('.ThreadFooter').should('exist');
|
||||
|
||||
// * thread footer button should say 'Follow'
|
||||
cy.get('.FollowButton').should('have.text', 'Follow');
|
||||
|
||||
// # Click thread footer 'Follow' button
|
||||
cy.get('.FollowButton').click({force: true});
|
||||
|
||||
// * thread footer button should say 'Following'
|
||||
cy.get('.FollowButton').should('have.text', 'Following');
|
||||
});
|
||||
|
||||
// * RHS header button should say 'Following'
|
||||
cy.get('#rhsContainer').find('.FollowButton').should('have.text', 'Following');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4682 should show search guidance at the end of the list after scroll loading', () => {
|
||||
// # Create more than 25 threads so we can use scroll loading in the Threads list
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
postMessageWithReply(testChannel.id, otherUser, `Another interesting post ${i}`, testUser, `Another reply ${i}!`).then(({rootId}) => {
|
||||
// # Mark last thread as Unread
|
||||
if (i === 30) {
|
||||
// # Click on root post to open thread
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// # Click on the reply's dot menu and mark as unread
|
||||
cy.uiClickPostDropdownMenu(rootId, 'Mark as Unread', 'RHS_ROOT');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// # Scroll load the threads list to reach the end
|
||||
const maxScrolls = 3;
|
||||
scrollThreadsListToEnd(maxScrolls);
|
||||
|
||||
// * Search guidance item should be shown at the end of the threads list
|
||||
cy.get('.ThreadList .no-results__wrapper').should('be.visible').within(() => {
|
||||
// * Title, subtitle and shortcut keys should be shown
|
||||
cy.findByText('That’s the end of the list').should('be.visible');
|
||||
cy.contains('If you’re looking for older conversations, try searching with ').should('be.visible').within(() => {
|
||||
cy.findByText(isMac() ? '⌘' : 'Ctrl').should('be.visible');
|
||||
cy.findByText('Shift').should('be.visible');
|
||||
cy.findByText('F').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
// # Click Unreads button
|
||||
cy.findByText('Unreads').click();
|
||||
|
||||
// * Search guidance item should not be shown at the end of the Unreads threads list
|
||||
cy.get('.ThreadList .no-results__wrapper').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
function postMessageWithReply(channelId: string, postSender: {username: string; password: string}, postMessage: string, replySender: {username: string; password: string}, replyMessage: string): ChainableT {
|
||||
return cy.postMessageAs({
|
||||
sender: postSender,
|
||||
message: postMessage || 'Another interesting post.',
|
||||
channelId,
|
||||
}).then(({id: rootId}) => {
|
||||
cy.postMessageAs({
|
||||
sender: replySender || postSender,
|
||||
message: replyMessage || 'Another reply!',
|
||||
channelId,
|
||||
rootId,
|
||||
}).then(({id: replyId}) => (Promise.resolve({rootId, replyId})));
|
||||
});
|
||||
}
|
||||
|
||||
function scrollThreadsListToEnd(maxScrolls = 1, scrolls = 0): ChainableT<void> {
|
||||
if (scrolls === maxScrolls) {
|
||||
return cy.wrap(undefined);
|
||||
}
|
||||
|
||||
return cy.get('.ThreadList .virtualized-thread-list').scrollTo('bottom').then(($el) => {
|
||||
const element = $el.find('.no-results__wrapper');
|
||||
|
||||
if (element.length < 1) {
|
||||
return cy.wait(TIMEOUTS.ONE_SEC).then(() => {
|
||||
return scrollThreadsListToEnd(maxScrolls, scrolls + 1);
|
||||
});
|
||||
}
|
||||
|
||||
return cy.wrap(element).scrollIntoView().then(() => undefined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import type {PostMessageResp} from '@/support/task_commands';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testChannel: Channel;
|
||||
let user1: UserProfile;
|
||||
let user2: UserProfile;
|
||||
let user3: UserProfile;
|
||||
let rootPost: PostMessageResp;
|
||||
let replyPost1: PostMessageResp;
|
||||
let replyPost2: PostMessageResp;
|
||||
|
||||
const messages = {
|
||||
ROOT: 'ROOT POST',
|
||||
REPLY1: 'REPLY 1',
|
||||
REPLY2: 'REPLY 2',
|
||||
};
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
user1 = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(user1.id, 'on');
|
||||
cy.apiCreateUser({prefix: 'user2'}).then(({user: newUser}) => {
|
||||
user2 = newUser;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, user2.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, user2.id);
|
||||
});
|
||||
});
|
||||
|
||||
cy.apiCreateUser({prefix: 'user3'}).then(({user: newUser}) => {
|
||||
user3 = newUser;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, user3.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, user3.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # Post a message as other user
|
||||
cy.postMessageAs({sender: user1, message: messages.ROOT, channelId: testChannel.id}).then((post) => {
|
||||
rootPost = post;
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4379 Display: Click to open threads', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Get the root post
|
||||
cy.getLastPost().click();
|
||||
|
||||
// * Verify the post is opened in RHS
|
||||
cy.get(`#rhsPost_${rootPost.id}`).should('be.visible');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
|
||||
// # Open settings modal and disable opening the thread in RHS when being clicked
|
||||
cy.uiOpenSettingsModal('Display');
|
||||
|
||||
// # Open section "click to open threads"
|
||||
cy.get('#click_to_replyTitle').click();
|
||||
|
||||
// # Click radio button with option B ('off')
|
||||
cy.get('#click_to_replyFormatB').click();
|
||||
|
||||
// # Save settings
|
||||
cy.get('#saveSetting').click();
|
||||
|
||||
// # close settings modal
|
||||
cy.uiClose();
|
||||
|
||||
// # (Re-) Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # Get the root post
|
||||
cy.getLastPost().click();
|
||||
|
||||
// * Verify the post is opened in RHS
|
||||
cy.get(`rhsPost_${rootPost.id}`).should('not.exist');
|
||||
|
||||
// # Cleanup for next test
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
});
|
||||
|
||||
it('MM-T4445 CRT - Delete root post', () => {
|
||||
/**
|
||||
* When you delete a post the current behavior in displaying it and the thread replies is incorrect.
|
||||
* Deleting a root post leads to a post showing `(message deleted)`, but still rendering the replies
|
||||
* in the ThreadFooter. Ideally we should remove the root post without removing the replies.
|
||||
*/
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1, channelId: testChannel.id, rootId: rootPost.id});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1);
|
||||
|
||||
// # Delete thread root post
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
|
||||
/**
|
||||
* TODO: this should not be there once the root post is deleted, so remove it once the feature is adjusted
|
||||
*/
|
||||
// * There should be a single thread item showing '(message deleted)'
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).should('contain.text', '(message deleted)');
|
||||
|
||||
// # Refresh the page
|
||||
cy.reload(true);
|
||||
|
||||
// * There should be no thread item anymore
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 0);
|
||||
});
|
||||
|
||||
it('MM-T4446 CRT - Delete single reply post on a thread', () => {
|
||||
/**
|
||||
* When you delete a reply you are still following the thread, so the thread is not being removed
|
||||
* from the global threads view. The test description was written differently as it was assuming the
|
||||
* thread to not be followed after that. The test will be build keeping that in mind, but should
|
||||
* thread to not be followed after that. The test will be build keeping that in mind, but should
|
||||
* probably be adjusted later on.
|
||||
*/
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: user1, message: messages.REPLY1, channelId: testChannel.id, rootId: rootPost.id}).then((post) => {
|
||||
replyPost1 = post;
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).first().click();
|
||||
|
||||
// * Reply should be in RHS
|
||||
cy.get(`#rhsPostMessageText_${replyPost1.id}`).should('be.visible').should('contain.text', messages.REPLY1);
|
||||
|
||||
// # Delete thread reply post
|
||||
cy.apiDeletePost(replyPost1.id);
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1);
|
||||
|
||||
// * The reply should be in RHS showing '(message deleted)'
|
||||
cy.get(`#rhsPost_${replyPost1.id}`).should('be.visible').should('contain.text', '(message deleted)');
|
||||
|
||||
// # Reload to re-render UI
|
||||
cy.reload(true);
|
||||
|
||||
// * There should be a single thread item with no reply
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 0);
|
||||
|
||||
// * The reply post should not exist anymore
|
||||
cy.get(`#rhsPost_${replyPost1.id}`).should('not.exist');
|
||||
|
||||
// # Cleanup for next test
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4447 CRT - Delete single reply post on a multi-reply thread', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1, channelId: testChannel.id, rootId: rootPost.id}).then((post) => {
|
||||
replyPost1 = post;
|
||||
}).then(() => {
|
||||
return cy.postMessageAs({sender: user3, message: messages.REPLY2, channelId: testChannel.id, rootId: rootPost.id});
|
||||
}).then((post) => {
|
||||
replyPost2 = post;
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in Thread Footer should say '2 replies'
|
||||
cy.get('.ReplyButton').should('have.text', '2 replies');
|
||||
|
||||
// * 2 avatars/participants should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 2);
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).first().click().within(() => {
|
||||
// * Activity section in ThreadItem should say '2 replies'
|
||||
cy.get('.activity').should('have.text', '2 replies');
|
||||
|
||||
// * 3 avatars/participants should show in ThreadItem
|
||||
cy.get('.Avatar').should('have.lengthOf', 3);
|
||||
});
|
||||
|
||||
// * The reply should be in RHS showing '(message deleted)'
|
||||
cy.get(`#rhsPost_${replyPost2.id}`).should('be.visible').should('contain.text', messages.REPLY2);
|
||||
|
||||
// # Delete thread reply post
|
||||
cy.apiDeletePost(replyPost2.id);
|
||||
|
||||
// # Reload to re-render UI
|
||||
cy.reload(true);
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).first().click().within(() => {
|
||||
// * Activity section in ThreadItem should say '1 reply'
|
||||
cy.get('.activity').should('have.text', '1 reply');
|
||||
|
||||
// * 2 avatars/participants should show in ThreadItem
|
||||
cy.get('.Avatar').should('have.lengthOf', 2);
|
||||
});
|
||||
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in Thread Footer should say '1 reply'
|
||||
cy.get('.ReplyButton').should('have.text', '1 reply');
|
||||
|
||||
// * 1 avatar/participant should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 1);
|
||||
});
|
||||
|
||||
// # Cleanup for next test
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4448_1 CRT - L16 - Use “Mark all as read” button', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1, channelId: testChannel.id, rootId: rootPost.id}).then((post) => {
|
||||
replyPost1 = post;
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in Thread Footer should say '1 reply'
|
||||
cy.get('.ReplyButton').should('have.text', '1 reply');
|
||||
|
||||
// * 1 avatar/participant should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 1);
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * The unreads tab button has a blue dot (unread indicator)
|
||||
cy.get('#threads-list-unread-button .dot').should('have.lengthOf', 1);
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).within(() => {
|
||||
// * The unread indicator (blue dot) should be present
|
||||
cy.get('.dot-unreads').should('have.lengthOf', 1);
|
||||
|
||||
// * Activity section in ThreadItem should say '1 new reply'
|
||||
cy.get('.activity').should('have.text', '1 new reply');
|
||||
});
|
||||
|
||||
// # Get the "mark all as read" button (the selector is pretty ambiguous here, but should work for now)
|
||||
cy.get('#threads-list__mark-all-as-read').click();
|
||||
|
||||
// * mark_all_threads_as_read_modal is open
|
||||
cy.get('#mark-all-threads-as-read-modal').should('exist');
|
||||
|
||||
// # Click confirm button
|
||||
cy.get('button.confirm').contains('Mark all as read').click();
|
||||
|
||||
// * mark_all_threads_as_read_modal is closed
|
||||
cy.get('#mark-all-threads-as-read-modal').should('not.exist');
|
||||
|
||||
// * The unreads tab button does NOT have a blue dot (unread indicator)
|
||||
cy.get('#threads-list-unread-button .dot').should('not.exist');
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).within(() => {
|
||||
// * The unread indicator (blue dot) should NOT be present
|
||||
cy.get('.dot-unreads').should('have.lengthOf', 0);
|
||||
|
||||
// * Activity section in ThreadItem should say '1 reply'
|
||||
cy.get('.activity').should('have.text', '1 reply');
|
||||
});
|
||||
|
||||
// * Assert that the RHS shows the correct view
|
||||
cy.get('.no-results__holder').should('contain.text', 'Looks like you’re all caught up');
|
||||
|
||||
// # Cleanup for next test
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4448_2 CRT - L16 - Cancel “Mark all as unread” modal', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1, channelId: testChannel.id, rootId: rootPost.id}).then((post) => {
|
||||
replyPost1 = post;
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * The unreads tab button has a blue dot (unread indicator)
|
||||
cy.get('#threads-list-unread-button .dot').should('have.lengthOf', 1);
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).within(() => {
|
||||
// * The unread indicator (blue dot) should be present
|
||||
cy.get('.dot-unreads').should('have.lengthOf', 1);
|
||||
|
||||
// * Activity section in ThreadItem should say '1 new reply'
|
||||
cy.get('.activity').should('have.text', '1 new reply');
|
||||
});
|
||||
|
||||
// # Get the "mark all as read" button
|
||||
cy.get('#threads-list__mark-all-as-read').click();
|
||||
|
||||
// * Verify mark_all_threads_as_read_modal is open
|
||||
cy.get('#mark-all-threads-as-read-modal').should('exist');
|
||||
|
||||
// # Click cancel button
|
||||
cy.get('.btn-tertiary').contains('Cancel').click();
|
||||
|
||||
// * Verify mark_all_threads_as_read_modal is closed
|
||||
cy.get('#mark-all-threads-as-read-modal').should('not.exist');
|
||||
|
||||
// * Verify the unreads tab button still has a blue dot
|
||||
cy.get('#threads-list-unread-button .dot').should('exist');
|
||||
|
||||
// # Click on “Mark all as unread” button again
|
||||
cy.get('#threads-list__mark-all-as-read').click();
|
||||
|
||||
// * Verify mark_all_threads_as_read_modal is open
|
||||
cy.get('#mark-all-threads-as-read-modal').should('exist');
|
||||
|
||||
// # Click close button
|
||||
cy.get('button.close').click();
|
||||
|
||||
// * Verify mark_all_threads_as_read_modal is closed
|
||||
cy.get('#mark-all-threads-as-read-modal').should('not.exist');
|
||||
|
||||
// * Verify the unreads tab button still has a blue dot
|
||||
cy.get('#threads-list-unread-button .dot').should('exist');
|
||||
|
||||
// # Click on “Mark all as unread” button again
|
||||
cy.get('#threads-list__mark-all-as-read').click();
|
||||
|
||||
// # Press ESC key to close the modal
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
// * Verify mark_all_threads_as_read_modal is closed
|
||||
cy.get('#mark-all-threads-as-read-modal').should('not.exist');
|
||||
|
||||
// * Verify the unreads tab button still has a blue dot
|
||||
cy.get('#threads-list-unread-button .dot').should('exist');
|
||||
|
||||
// * There should be a single thread item
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 1).within(() => {
|
||||
// * The unread indicator (blue dot) should be present
|
||||
cy.get('.dot-unreads').should('have.lengthOf', 1);
|
||||
|
||||
// * Activity section in ThreadItem should say '1 new reply'
|
||||
cy.get('.activity').should('have.text', '1 new reply');
|
||||
});
|
||||
});
|
||||
|
||||
// * Assert that the RHS shows the correct view
|
||||
cy.get('.no-results__holder').should('not.contain.text', 'Looks like you’re all caught up');
|
||||
|
||||
// # Cleanup for next test
|
||||
cy.apiDeletePost(rootPost.id);
|
||||
});
|
||||
|
||||
it('CRT - Threads list keyboard navigation', () => {
|
||||
// # Post a few messages as another user and then reply to each one as the current user to follow them
|
||||
let firstRoot: PostMessageResp;
|
||||
let firstReply: PostMessageResp;
|
||||
let secondRoot: PostMessageResp;
|
||||
let secondReply: PostMessageResp;
|
||||
let thirdRoot: PostMessageResp;
|
||||
let thirdReply: PostMessageResp;
|
||||
|
||||
cy.postMessageAs({sender: user1, message: messages.ROOT + '1', channelId: testChannel.id}).then((post) => {
|
||||
firstRoot = post;
|
||||
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1 + '1', channelId: testChannel.id, rootId: firstRoot.id}).then((reply) => {
|
||||
firstReply = reply;
|
||||
});
|
||||
}).then(() => {
|
||||
cy.postMessageAs({sender: user1, message: messages.ROOT + '1', channelId: testChannel.id}).then((post) => {
|
||||
secondRoot = post;
|
||||
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1 + '1', channelId: testChannel.id, rootId: secondRoot.id}).then((reply) => {
|
||||
secondReply = reply;
|
||||
});
|
||||
});
|
||||
}).then(() => {
|
||||
cy.postMessageAs({sender: user1, message: messages.ROOT + '1', channelId: testChannel.id}).then((post) => {
|
||||
thirdRoot = post;
|
||||
|
||||
cy.postMessageAs({sender: user2, message: messages.REPLY1 + '1', channelId: testChannel.id, rootId: thirdRoot.id}).then((reply) => {
|
||||
thirdReply = reply;
|
||||
});
|
||||
});
|
||||
}).then(() => {
|
||||
// # Click on the Threads item in the LHS
|
||||
cy.get('a').contains('Threads').click();
|
||||
|
||||
// * There should be a three threads in the threads list
|
||||
cy.get('div.ThreadItem').should('have.lengthOf', 3);
|
||||
|
||||
// * No thread should be selected at first
|
||||
cy.contains('Catch up on your threads').should('be.visible');
|
||||
|
||||
// # Press the down arrow
|
||||
cy.get('body').type('{downArrow}');
|
||||
|
||||
// * The third thread should be visible since it's the most recent one
|
||||
cy.contains('Catch up on your threads').should('not.exist');
|
||||
cy.get(`#rhsPost_${thirdRoot.id}`).should('be.visible');
|
||||
cy.get(`#rhsPost_${thirdReply.id}`).should('be.visible');
|
||||
|
||||
// * The comment box should not be focused
|
||||
cy.focused().should('not.have.id', 'reply_textbox');
|
||||
|
||||
// # Press the down arrow again
|
||||
cy.get('body').type('{downArrow}');
|
||||
|
||||
// * The second thread should be visible
|
||||
cy.get(`#rhsPost_${secondRoot.id}`).should('be.visible');
|
||||
cy.get(`#rhsPost_${secondReply.id}`).should('be.visible');
|
||||
|
||||
// * The comment box should still not be focused
|
||||
cy.focused().should('not.have.id', 'reply_textbox');
|
||||
|
||||
// # Press the down arrow again
|
||||
cy.get('body').type('{downArrow}');
|
||||
|
||||
// * The first thread should be visible
|
||||
cy.get(`#rhsPost_${firstRoot.id}`).should('be.visible');
|
||||
cy.get(`#rhsPost_${firstReply.id}`).should('be.visible');
|
||||
|
||||
// * The comment box should still not be focused
|
||||
cy.focused().should('not.have.id', 'reply_textbox');
|
||||
|
||||
// # Press the up arrow
|
||||
cy.get('body').type('{upArrow}');
|
||||
|
||||
// * The second thread should be visible
|
||||
cy.get(`#rhsPost_${secondRoot.id}`).should('be.visible');
|
||||
cy.get(`#rhsPost_${secondReply.id}`).should('be.visible');
|
||||
|
||||
// * The comment box should still not be focused
|
||||
cy.focused().should('not.have.id', 'reply_textbox');
|
||||
|
||||
// # Type something
|
||||
cy.get('body').type('something');
|
||||
|
||||
// * The comment box should be focused and have received that input
|
||||
cy.focused().should('have.id', 'reply_textbox').
|
||||
should('contain.text', 'something');
|
||||
|
||||
// # Press the up arrow again
|
||||
cy.get('body').type('{upArrow}');
|
||||
|
||||
// * The second thread should still be visible because focus is now in the comment box
|
||||
cy.get(`#rhsPost_${secondRoot.id}`).should('be.visible');
|
||||
cy.get(`#rhsPost_${secondReply.id}`).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import * as TIMEOUTS from '@/fixtures/timeouts';
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let userA: UserProfile; // Member of team A and B
|
||||
let teamA: Team;
|
||||
let teamB: Team;
|
||||
let offTopicUrlA: string;
|
||||
let testChannel: Channel;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
});
|
||||
|
||||
cy.apiInitSetup().then(({team, user, offTopicUrl: url}) => {
|
||||
userA = user;
|
||||
teamA = team;
|
||||
offTopicUrlA = url;
|
||||
|
||||
cy.apiCreateUser().then(() => {
|
||||
return cy.apiCreateTeam('team', 'Team');
|
||||
}).then(({team: otherTeam}) => {
|
||||
teamB = otherTeam;
|
||||
return cy.apiAddUserToTeam(teamB.id, userA.id);
|
||||
}).then(() => {
|
||||
return cy.apiCreateChannel(teamA.id, 'test', 'Test');
|
||||
}).then(({channel}) => {
|
||||
testChannel = channel;
|
||||
return cy.apiAddUserToChannel(testChannel.id, userA.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Log in to Team A with an account that has joined multiple teams.
|
||||
cy.apiLogin(userA);
|
||||
|
||||
// # On an account on two teams, view Team A
|
||||
cy.visit(offTopicUrlA);
|
||||
});
|
||||
|
||||
it('MM-T4887 should stay on threads view when switching teams', () => {
|
||||
// # Navigate to the new teams town square
|
||||
cy.visit(`/${teamA.name}/channels/town-square`);
|
||||
|
||||
// # Switch to Team B
|
||||
cy.get(`#${teamB.name}TeamButton`, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
|
||||
|
||||
// * Verify team display name changes correctly.
|
||||
cy.uiGetLHSHeader().findByText(teamB.display_name);
|
||||
|
||||
// # Go to the ‘Threads’ view on Team B
|
||||
cy.uiGetSidebarThreadsButton().click();
|
||||
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
cy.url().should('include', `${teamB.name}/threads`);
|
||||
|
||||
// # Switch back to Team A
|
||||
cy.get(`#${teamA.name}TeamButton`, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
|
||||
|
||||
// Verify url is set to landing channel
|
||||
cy.url().should('include', `${teamA.name}/channels/town-square`);
|
||||
});
|
||||
|
||||
it('MM-T4843_1 should go to threads view when switching a team if that was the last view on that team', () => {
|
||||
// # Go to the ‘Threads’ view on Team A
|
||||
cy.uiGetSidebarThreadsButton().click();
|
||||
|
||||
// * Wait for the view to mount, since that is what records threads as the team's last viewed type
|
||||
cy.get('.GlobalThreads').should('be.visible');
|
||||
|
||||
// # Switch to Team B
|
||||
cy.get(`#${teamB.name}TeamButton`, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
|
||||
|
||||
// * Verify team display name changes correctly.
|
||||
cy.uiGetLHSHeader().findByText(teamB.display_name);
|
||||
|
||||
// # Switch back to Team A
|
||||
cy.get(`#${teamA.name}TeamButton`, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
|
||||
|
||||
// Verify url is set up for threads view
|
||||
cy.url().should('include', `${teamA.name}/threads`);
|
||||
});
|
||||
|
||||
it('MM-T4843_2 should go to threads view when threads view is the penultimate view and leave the current channel', () => {
|
||||
// # Go to the ‘Threads’ view on Team A
|
||||
cy.uiGetSidebarThreadsButton().click();
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// # Switch to Test Channel
|
||||
cy.uiClickSidebarItem(testChannel.name);
|
||||
|
||||
// # Leave the current channel
|
||||
cy.uiLeaveChannel();
|
||||
|
||||
// Verify url is set up for threads view when thread view is the penultimate view
|
||||
cy.url().should('include', `${teamA.name}/threads`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testUser: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
EnableTutorial: false,
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, user}) => {
|
||||
testTeam = team;
|
||||
testUser = user;
|
||||
|
||||
cy.apiSaveCRTPreference(testUser.id, 'on');
|
||||
cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
|
||||
otherUser = user1;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, otherUser.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/messages/@${otherUser.username}`);
|
||||
});
|
||||
|
||||
it('should open thread when thread footer reply button is clicked in a DM/GM channel', () => {
|
||||
// # Post a message
|
||||
const msg = 'Root post';
|
||||
cy.postMessage(msg);
|
||||
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Thread with replies
|
||||
cy.clickPostCommentIcon(rootId);
|
||||
cy.uiGetReplyTextBox().type('reply{enter}');
|
||||
cy.uiGetReplyTextBox().type('reply2{enter}');
|
||||
cy.uiCloseRHS();
|
||||
|
||||
// * Check that the RHS is closed
|
||||
cy.get('#rhsContainer').should('not.exist');
|
||||
|
||||
// # Get thread footer of last post and find reply button
|
||||
cy.uiGetPostThreadFooter(rootId).find('button.ReplyButton').click();
|
||||
|
||||
// * Thread should be visible in RHS
|
||||
cy.get(`#rhsPost_${rootId}`).within(() => {
|
||||
cy.get(`#rhsPostMessageText_${rootId}`).should('be.visible').and('have.text', msg);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import type {PostMessageResp} from '@/support/task_commands';
|
||||
import * as TIMEOUTS from '@/fixtures/timeouts';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testUser: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
let testChannel: Channel;
|
||||
let rootPost: PostMessageResp;
|
||||
let postForAvatar: PostMessageResp;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
testUser = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(testUser.id, 'on');
|
||||
cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
|
||||
otherUser = user1;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, otherUser.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, otherUser.id);
|
||||
|
||||
// # Post a message as other user
|
||||
cy.postMessageAs({sender: otherUser, message: 'Root post', channelId: testChannel.id}).then((post) => {
|
||||
rootPost = post;
|
||||
});
|
||||
|
||||
// # Post a message as other user for clicking avatar test
|
||||
cy.postMessageAs({sender: otherUser, message: 'Root post for clicking avatar', channelId: testChannel.id}).then((post) => {
|
||||
postForAvatar = post;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit the channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
|
||||
it('MM-T4142 should show number of replies in thread', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(rootPost.data.message);
|
||||
|
||||
// # Thread footer should not exist
|
||||
cy.uiGetPostThreadFooter(rootPost.id).should('not.exist');
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: testUser, message: 'reply to root post', channelId: testChannel.id, rootId: rootPost.id});
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in Thread Footer should say '1 reply'
|
||||
cy.get('.ReplyButton').should('have.text', '1 reply');
|
||||
|
||||
// * 1 avatar/participant should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 1);
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * The sole thread item should have text in footer saying '1 reply'
|
||||
cy.get('div.ThreadItem').find('.activity').should('have.text', '1 reply');
|
||||
|
||||
// # Visit the channel
|
||||
cy.uiClickSidebarItem(testChannel.name);
|
||||
|
||||
// # Post another reply as current user
|
||||
cy.postMessageAs({sender: testUser, message: 'another reply!', channelId: testChannel.id, rootId: rootPost.id});
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in thread footer should say '2 replies'
|
||||
cy.get('.ReplyButton').should('have.text', '2 replies');
|
||||
|
||||
// * 1 avatar/participant should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 1);
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * The sole thread item should have text in footer saying '2 replies'
|
||||
cy.get('div.ThreadItem').find('.activity').should('have.text', '2 replies');
|
||||
|
||||
// # Visit the channel
|
||||
cy.uiClickSidebarItem(testChannel.name);
|
||||
|
||||
// # Post another reply as other user
|
||||
cy.postMessageAs({sender: otherUser, message: 'other reply!', channelId: testChannel.id, rootId: rootPost.id});
|
||||
|
||||
// # Get thread footer of last post
|
||||
cy.uiGetPostThreadFooter(rootPost.id).within(() => {
|
||||
// * Reply button in thread footer should say '3 replies'
|
||||
cy.get('.ReplyButton').should('have.text', '3 replies');
|
||||
|
||||
// * 2 avatars/participants should show in Thread Footer
|
||||
cy.get('.Avatar').should('have.lengthOf', 2);
|
||||
});
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * The sole thread item should have text in footer saying '1 new reply'
|
||||
cy.get('div.ThreadItem').find('.activity').should('have.text', '1 new reply');
|
||||
});
|
||||
|
||||
it('MM-T4646 should open popover when avatar is clicked', () => {
|
||||
cy.uiWaitUntilMessagePostedIncludes(postForAvatar.data.message);
|
||||
|
||||
// # Post a reply post as current user
|
||||
cy.postMessageAs({sender: testUser, message: 'reply!', channelId: testChannel.id, rootId: postForAvatar.id});
|
||||
|
||||
// # Post another reply as other user
|
||||
cy.postMessageAs({sender: otherUser, message: 'another reply!', channelId: testChannel.id, rootId: postForAvatar.id});
|
||||
|
||||
// # Get thread footer of last post and find avatars
|
||||
cy.uiGetPostThreadFooter(postForAvatar.id).find('.Avatars').find('button').first().click();
|
||||
|
||||
// * Profile popover should be visible and close on ESC
|
||||
cy.get('div.user-profile-popover').first().should('be.visible').find('button.btn-primary.btn-sm').type('{esc}');
|
||||
|
||||
// # Visit global threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// * Find the first avatar and click it
|
||||
cy.get('div.ThreadItem').find('.activity').find('.Avatars').find('button').first().click();
|
||||
|
||||
// * Profile popover should be visible and close on ESC
|
||||
cy.get('div.user-profile-popover').first().should('be.visible').find('button.btn-primary.btn-sm');
|
||||
});
|
||||
|
||||
it('MM-T4143 Emoji reaction - type +:+1:', () => {
|
||||
// # Create a root post
|
||||
cy.postMessage('Hello!');
|
||||
|
||||
cy.getLastPostId().then((postId) => {
|
||||
// # Click on post to open the thread in RHS
|
||||
cy.get(`#post_${postId}`).click();
|
||||
|
||||
// # Type "+:+1:" in comment box to react to the post with a thumbs-up and post
|
||||
cy.postMessageReplyInRHS('+:+1:');
|
||||
|
||||
// * Thumbs-up reaction displays as reaction on post
|
||||
cy.get(`#${postId}_message`).within(() => {
|
||||
cy.findByLabelText('reactions').should('be.visible');
|
||||
cy.findByRole('button', {name: /reacted with :\+1:/i}).should('be.visible');
|
||||
});
|
||||
|
||||
// * Reacting to a root post should not create a thread (thread footer should not exist)
|
||||
cy.uiGetPostThreadFooter(postId).should('not.exist');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5413 should auto-scroll to bottom upon pasting long text in reply', () => {
|
||||
// # Post a root post as current user
|
||||
cy.postMessageAs({
|
||||
sender: testUser,
|
||||
message: 'Another interesting post,',
|
||||
channelId: testChannel.id,
|
||||
}).then(({id: rootId}) => {
|
||||
// # Post multiple replies as other user so that the new messages line is pushed up
|
||||
Cypress._.times(20, (i) => {
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: 'Reply ' + i,
|
||||
channelId: testChannel.id,
|
||||
rootId,
|
||||
});
|
||||
});
|
||||
|
||||
// # Click root post
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// # Wait for RHS to open and scroll to position
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// * RHS should open and the editor's actions should be visible.
|
||||
cy.get('#rhsContainer').findByTestId('SendMessageButton').should('be.visible');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
|
||||
// # Click root post
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// # Paste a multiline string in the RHS textbox.
|
||||
const text = 'word '.repeat(2000);
|
||||
cy.get('#rhsContainer').findByTestId('reply_textbox').clear().invoke('val', text).trigger('input');
|
||||
|
||||
// * RHS should open and the editor should be visible and focused
|
||||
cy.get('#rhsContainer').findByTestId('SendMessageButton').scrollIntoView().should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Group: @channels @collapsed_reply_threads
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import * as TIMEOUTS from '@/fixtures/timeouts';
|
||||
|
||||
describe('Collapsed Reply Threads', () => {
|
||||
let testTeam: Team;
|
||||
let testUser: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
let testChannel: Channel;
|
||||
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create new channel and other user, and add other user to channel
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({team, channel, user}) => {
|
||||
testTeam = team;
|
||||
testUser = user;
|
||||
testChannel = channel;
|
||||
|
||||
cy.apiSaveCRTPreference(testUser.id, 'on');
|
||||
|
||||
cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
|
||||
otherUser = user1;
|
||||
|
||||
cy.apiAddUserToTeam(testTeam.id, otherUser.id).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, otherUser.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Visit channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
|
||||
it('MM-T4144_1 should show a new messages line for an unread thread', () => {
|
||||
// # Post a root post as current user
|
||||
cy.postMessageAs({
|
||||
sender: testUser,
|
||||
message: 'Another interesting post,',
|
||||
channelId: testChannel.id,
|
||||
}).then(({id: rootId}) => {
|
||||
// # Post multiple replies as other user so that the new messages line is pushed up
|
||||
Cypress._.times(20, (i) => {
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: 'Reply ' + i,
|
||||
channelId: testChannel.id,
|
||||
rootId,
|
||||
});
|
||||
});
|
||||
|
||||
// # Click root post
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// # Wait for RHS to open and scroll to position
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// * RHS should open and new messages line should be visible
|
||||
cy.get('#rhsContainer').findByTestId('NotificationSeparator').scrollIntoView().should('be.visible');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T4144_2 should not show a new messages line after viewing the thread', () => {
|
||||
// # Get last message in channel
|
||||
cy.getLastPostId().then((rootId) => {
|
||||
// # Click on message
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// * RHS should open and new messages line should NOT be visible
|
||||
cy.get('#rhsContainer').findByTestId('NotificationSeparator').should('not.exist');
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5671 should handle mention counts correctly when marking a thread as unread and unfollowing it', () => {
|
||||
// # Post a root post as current user
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: `@${testUser.username} Root post for mention test`,
|
||||
channelId: testChannel.id,
|
||||
}).then(({id: rootId}) => {
|
||||
// # Post a reply mentioning the user
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: `Hey @${testUser.username}, check this out!`,
|
||||
channelId: testChannel.id,
|
||||
rootId,
|
||||
}).then(({id: replyId}) => {
|
||||
// # Post another reply mentioning the user
|
||||
cy.postMessageAs({
|
||||
sender: otherUser,
|
||||
message: `Hey @${testUser.username}, check this out too!`,
|
||||
channelId: testChannel.id,
|
||||
rootId,
|
||||
});
|
||||
|
||||
// # Click root post to open RHS
|
||||
cy.get(`#post_${rootId}`).click();
|
||||
|
||||
// # Wait for RHS to open
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// # Mark the thread as unread
|
||||
cy.uiClickPostDropdownMenu(replyId, 'Mark as Unread', 'RHS_COMMENT');
|
||||
|
||||
// # Wait for unread to be marked correctly
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// # Close RHS
|
||||
cy.uiCloseRHS();
|
||||
|
||||
// # Switch to a different team
|
||||
cy.apiCreateTeam('team', 'Team').then(({team: otherTeam}) => {
|
||||
// # Reload the page to ensure newly created team is loaded
|
||||
cy.reload();
|
||||
|
||||
// # Click on the other team button to switch teams
|
||||
cy.get(`#${otherTeam.name}TeamButton`).click();
|
||||
|
||||
// * Verify mention count on the original team
|
||||
cy.get(`#${testTeam.name}TeamButton`).find('.badge').should('be.visible');
|
||||
|
||||
// # Click on the original team button to switch back
|
||||
cy.get(`#${testTeam.name}TeamButton`).click();
|
||||
|
||||
// # Unfollow the thread
|
||||
cy.uiGetPostThreadFooter(rootId).findByText('Following').click();
|
||||
|
||||
// # Switch to a different team and back
|
||||
cy.get(`#${otherTeam.name}TeamButton`).click();
|
||||
cy.get(`#${testTeam.name}TeamButton`).click();
|
||||
cy.get(`#${otherTeam.name}TeamButton`).click();
|
||||
|
||||
// * Verify there is no mention count on the original team
|
||||
cy.get(`#${testTeam.name}TeamButton`).find('.badge').should('not.exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user