diff --git a/e2e/specs/startup/app.test.js b/e2e/specs/startup/app.test.js index d530fd80..21ea049e 100644 --- a/e2e/specs/startup/app.test.js +++ b/e2e/specs/startup/app.test.js @@ -7,6 +7,7 @@ const robot = require('robotjs'); const env = require('../../modules/environment'); +const {asyncSleep} = require('../../modules/utils'); describe('startup/app', function desc() { this.timeout(30000); @@ -15,6 +16,11 @@ describe('startup/app', function desc() { env.createTestUserDataDir(); env.cleanTestConfig(); this.app = await env.getApp(); + + // Skip welcome screen modal + const welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen')); + welcomeScreenModal.click('.WelcomeScreen .WelcomeScreen__button'); + await asyncSleep(500); }); afterEach(async () => { @@ -41,10 +47,10 @@ describe('startup/app', function desc() { existingModal.should.not.be.null; }); - it('MM-T4399_2 should show no servers configured in dropdown when no servers exist', async () => { + it('MM-T4985 should show app name in title bar when no servers exist', async () => { const mainWindow = this.app.windows().find((window) => window.url().includes('index')); - const dropdownButtonText = await mainWindow.innerText('.TeamDropdownButton'); - dropdownButtonText.should.equal('No servers configured'); + const titleBarText = await mainWindow.innerText('.app-title'); + titleBarText.should.equal('Mattermost'); }); it('MM-T4400 should be stopped when the app instance already exists', (done) => { @@ -62,4 +68,20 @@ describe('startup/app', function desc() { done(new Error('Second app instance exists')); }); }); + + it('MM-T4975 should show the welcome screen modal when no servers exist', async () => { + if (this.app) { + await this.app.close(); + } + await env.clearElectronInstances(); + env.createTestUserDataDir(); + env.cleanTestConfig(); + this.app = await env.getApp(); + + await asyncSleep(500); + + const welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen')); + const modalButton = await welcomeScreenModal.innerText('.WelcomeScreen .WelcomeScreen__button'); + modalButton.should.equal('Get Started'); + }); }); diff --git a/e2e/specs/startup/welcome_screen_modal.test.js b/e2e/specs/startup/welcome_screen_modal.test.js new file mode 100644 index 00000000..892012b0 --- /dev/null +++ b/e2e/specs/startup/welcome_screen_modal.test.js @@ -0,0 +1,142 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +const env = require('../../modules/environment'); +const {asyncSleep} = require('../../modules/utils'); + +describe('Welcome Screen Modal', function desc() { + this.timeout(30000); + + beforeEach(async () => { + env.createTestUserDataDir(); + env.cleanTestConfig(); + await asyncSleep(1000); + + this.app = await env.getApp(); + + welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen')); + }); + + afterEach(async () => { + if (this.app) { + await this.app.close(); + } + await env.clearElectronInstances(); + }); + + let welcomeScreenModal; + + it('MM-T4976 should show the slides in the expected order', async () => { + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const channelSlideClass = await welcomeScreenModal.getAttribute('#channels', 'class'); + channelSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const playbooksSlideClass = await welcomeScreenModal.getAttribute('#playbooks', 'class'); + playbooksSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const boardsSlideClass = await welcomeScreenModal.getAttribute('#boards', 'class'); + boardsSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4977 should be able to move through slides clicking the navigation buttons', async () => { + let welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const channelSlideClass = await welcomeScreenModal.getAttribute('#channels', 'class'); + channelSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#prevCarouselButton'); + + welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4978 should be able to move through slides clicking the pagination indicator', async () => { + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#PaginationIndicator3'); + + const boardsSlideClass = await welcomeScreenModal.getAttribute('#boards', 'class'); + boardsSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#PaginationIndicator2'); + + const playbooksSlideClass = await welcomeScreenModal.getAttribute('#playbooks', 'class'); + playbooksSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4979 should be able to move forward through slides automatically every 5 seconds', async () => { + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await asyncSleep(5500); + + const channelSlideClass = await welcomeScreenModal.getAttribute('#channels', 'class'); + channelSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4980 should show the slides in the expected order', async () => { + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const channelSlideClass = await welcomeScreenModal.getAttribute('#channels', 'class'); + channelSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const playbooksSlideClass = await welcomeScreenModal.getAttribute('#playbooks', 'class'); + playbooksSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const boardsSlideClass = await welcomeScreenModal.getAttribute('#boards', 'class'); + boardsSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4981 should be able to move from last to first slide', async () => { + await welcomeScreenModal.click('#PaginationIndicator3'); + + const boardsSlideClass = await welcomeScreenModal.getAttribute('#boards', 'class'); + boardsSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#nextCarouselButton'); + + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4982 should be able to move from first to last slide', async () => { + const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class'); + welcomeSlideClass.should.contain('Carousel__slide-current'); + + await welcomeScreenModal.click('#prevCarouselButton'); + + const boardsSlideClass = await welcomeScreenModal.getAttribute('#boards', 'class'); + boardsSlideClass.should.contain('Carousel__slide-current'); + }); + + it('MM-T4983 should be able to click the get started button and be redirected to new server modal', async () => { + await welcomeScreenModal.click('#getStartedWelcomeScreen'); + + const newServerModal = await this.app.waitForEvent('window', { + predicate: (window) => window.url().includes('newServer'), + }); + const modalTitle = await newServerModal.innerText('#newServerModal .modal-title'); + modalTitle.should.equal('Add Server'); + }); +}); diff --git a/e2e/utils/artifacts.js b/e2e/utils/artifacts.js index 67fd476a..467f758c 100644 --- a/e2e/utils/artifacts.js +++ b/e2e/utils/artifacts.js @@ -1,7 +1,6 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. - /* eslint-disable no-console,consistent-return */ const fs = require('fs'); diff --git a/e2e/utils/test_cases.js b/e2e/utils/test_cases.js index f9a953a7..cb329487 100644 --- a/e2e/utils/test_cases.js +++ b/e2e/utils/test_cases.js @@ -1,7 +1,6 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. - /* eslint-disable no-console */ // See reference: https://support.smartbear.com/tm4j-cloud/api-docs/ diff --git a/i18n/en.json b/i18n/en.json index 4bd4b40a..fd3be2c2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -131,6 +131,7 @@ "renderer.components.extraBar.back": "Back", "renderer.components.mainPage.contextMenu.ariaLabel": "Context menu", "renderer.components.mainPage.downloadingUpdate": "Downloading update. {percentDone}% of {total} @ {speed}/s", + "renderer.components.mainPage.titleBar": "Mattermost", "renderer.components.mainPage.updateAvailable": "Update available", "renderer.components.mainPage.updateReady": "Update ready to install", "renderer.components.newTeamModal.error.nameRequired": "Name is required.", @@ -213,6 +214,15 @@ "renderer.components.showCertificateModal.serialNumber": "Serial Number", "renderer.components.showCertificateModal.subjectName": "Subject Name", "renderer.components.teamDropdownButton.noServersConfigured": "No servers configured", + "renderer.components.welcomeScreen.button.getStarted": "Get Started", + "renderer.components.welcomeScreen.slides.boards.subtitle": "Ship on time, every time, with a project and task management solution built for digital operations.", + "renderer.components.welcomeScreen.slides.boards.title": "Boards", + "renderer.components.welcomeScreen.slides.channels.subtitle": "All of your team’s communication in one place.

Secure collaboration, built for developers.", + "renderer.components.welcomeScreen.slides.channels.title": "Channels", + "renderer.components.welcomeScreen.slides.palybooks.subtitle": "Move faster and make fewer mistakes with checklists, automations, and tool integrations that power your team’s workflows.", + "renderer.components.welcomeScreen.slides.playbooks.title": "Playbooks", + "renderer.components.welcomeScreen.slides.welcome.subtitle": "Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with the tools you love.", + "renderer.components.welcomeScreen.slides.welcome.title": "Welcome", "renderer.dropdown.addAServer": "Add a server", "renderer.dropdown.servers": "Servers", "renderer.modals.certificate.certificateModal.certInfoButton": "Certificate Information", diff --git a/src/assets/fonts/Metropolis-Light.woff b/src/assets/fonts/Metropolis-Light.woff new file mode 100644 index 00000000..6a6bde74 Binary files /dev/null and b/src/assets/fonts/Metropolis-Light.woff differ diff --git a/src/assets/fonts/Metropolis-LightItalic.woff b/src/assets/fonts/Metropolis-LightItalic.woff new file mode 100644 index 00000000..6f67b9a0 Binary files /dev/null and b/src/assets/fonts/Metropolis-LightItalic.woff differ diff --git a/src/assets/fonts/Metropolis-Regular.woff b/src/assets/fonts/Metropolis-Regular.woff new file mode 100644 index 00000000..258ae60f Binary files /dev/null and b/src/assets/fonts/Metropolis-Regular.woff differ diff --git a/src/assets/fonts/Metropolis-RegularItalic.woff b/src/assets/fonts/Metropolis-RegularItalic.woff new file mode 100644 index 00000000..861bfdeb Binary files /dev/null and b/src/assets/fonts/Metropolis-RegularItalic.woff differ diff --git a/src/assets/fonts/Metropolis-SemiBold.woff b/src/assets/fonts/Metropolis-SemiBold.woff new file mode 100644 index 00000000..1500a267 Binary files /dev/null and b/src/assets/fonts/Metropolis-SemiBold.woff differ diff --git a/src/assets/fonts/Metropolis-SemiBoldItalic.woff b/src/assets/fonts/Metropolis-SemiBoldItalic.woff new file mode 100644 index 00000000..dca153bb Binary files /dev/null and b/src/assets/fonts/Metropolis-SemiBoldItalic.woff differ diff --git a/src/common/communication.ts b/src/common/communication.ts index 3465b76f..c3b7dd6b 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -35,6 +35,7 @@ export const DOUBLE_CLICK_ON_WINDOW = 'double_click'; export const SHOW_NEW_SERVER_MODAL = 'show_new_server_modal'; export const SHOW_EDIT_SERVER_MODAL = 'show-edit-server-modal'; export const SHOW_REMOVE_SERVER_MODAL = 'show-remove-server-modal'; +export const MAIN_WINDOW_SHOWN = 'main-window-shown'; export const RETRIEVE_MODAL_INFO = 'retrieve-modal-info'; export const MODAL_INFO = 'modal-info'; diff --git a/src/main/ParseArgs.ts b/src/main/ParseArgs.ts index b1b6300e..c68aa7bb 100644 --- a/src/main/ParseArgs.ts +++ b/src/main/ParseArgs.ts @@ -55,7 +55,7 @@ function parseArgs(args: string[]) { // build. As such, we provide the version manually. version(app.getVersion()). help('help'). - parse(args); + parse(args) as Args; } function validateArgs(args: Args) { diff --git a/src/main/app/config.test.js b/src/main/app/config.test.js index d49c51d7..945041b0 100644 --- a/src/main/app/config.test.js +++ b/src/main/app/config.test.js @@ -7,7 +7,7 @@ import {RELOAD_CONFIGURATION} from 'common/communication'; import Config from 'common/config'; import {handleConfigUpdate} from 'main/app/config'; -import {addNewServerModalWhenMainWindowIsShown} from 'main/app/intercom'; +import {handleMainWindowIsShown} from 'main/app/intercom'; import {setLoggingLevel} from 'main/app/utils'; import WindowManager from 'main/windows/windowManager'; @@ -32,7 +32,7 @@ jest.mock('main/app/utils', () => ({ setLoggingLevel: jest.fn(), })); jest.mock('main/app/intercom', () => ({ - addNewServerModalWhenMainWindowIsShown: jest.fn(), + handleMainWindowIsShown: jest.fn(), })); jest.mock('main/AutoLauncher', () => ({ enable: jest.fn(), @@ -98,7 +98,7 @@ describe('main/app/config', () => { Config.registryConfigData = {}; handleConfigUpdate({teams: []}); - expect(addNewServerModalWhenMainWindowIsShown).toHaveBeenCalled(); + expect(handleMainWindowIsShown).toHaveBeenCalled(); Object.defineProperty(process, 'platform', { value: originalPlatform, diff --git a/src/main/app/config.ts b/src/main/app/config.ts index 20997536..1b74f974 100644 --- a/src/main/app/config.ts +++ b/src/main/app/config.ts @@ -14,7 +14,7 @@ import {setUnreadBadgeSetting} from 'main/badge'; import {refreshTrayImages} from 'main/tray/tray'; import WindowManager from 'main/windows/windowManager'; -import {addNewServerModalWhenMainWindowIsShown} from './intercom'; +import {handleMainWindowIsShown} from './intercom'; import {handleUpdateMenuEvent, setLoggingLevel, updateServerInfos, updateSpellCheckerLocales} from './utils'; let didCheckForAddServerModal = false; @@ -61,7 +61,7 @@ export function handleConfigUpdate(newConfig: CombinedConfig) { updateServerInfos(newConfig.teams); WindowManager.initializeCurrentServerName(); if (newConfig.teams.length === 0) { - addNewServerModalWhenMainWindowIsShown(); + handleMainWindowIsShown(); } } diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 00c64fdb..4009e2a5 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -98,7 +98,7 @@ jest.mock('main/app/config', () => ({ handleConfigUpdate: jest.fn(), })); jest.mock('main/app/intercom', () => ({ - addNewServerModalWhenMainWindowIsShown: jest.fn(), + handleMainWindowIsShown: jest.fn(), })); jest.mock('main/app/utils', () => ({ clearAppCache: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 1a697266..34be5451 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -34,6 +34,7 @@ import { START_UPGRADE, START_DOWNLOAD, PING_DOMAIN, + MAIN_WINDOW_SHOWN, } from 'common/communication'; import Config from 'common/config'; import urlUtils from 'common/utils/url'; @@ -68,7 +69,7 @@ import { } from './app'; import {handleConfigUpdate, handleDarkModeChange} from './config'; import { - addNewServerModalWhenMainWindowIsShown, + handleMainWindowIsShown, handleAppVersion, handleCloseTab, handleEditServerModal, @@ -247,6 +248,7 @@ function initializeInterCommunicationEventListeners() { ipcMain.on(SHOW_NEW_SERVER_MODAL, handleNewServerModal); ipcMain.on(SHOW_EDIT_SERVER_MODAL, handleEditServerModal); ipcMain.on(SHOW_REMOVE_SERVER_MODAL, handleRemoveServerModal); + ipcMain.on(MAIN_WINDOW_SHOWN, handleMainWindowIsShown); ipcMain.on(WINDOW_CLOSE, WindowManager.close); ipcMain.on(WINDOW_MAXIMIZE, WindowManager.maximize); ipcMain.on(WINDOW_MINIMIZE, WindowManager.minimize); @@ -423,7 +425,7 @@ function initializeAfterAppReady() { // only check for non-Windows, as with Windows we have to wait for GPO teams if (process.platform !== 'win32' || typeof Config.registryConfigData !== 'undefined') { if (Config.teams.length === 0) { - addNewServerModalWhenMainWindowIsShown(); + handleMainWindowIsShown(); } } } diff --git a/src/main/app/intercom.test.js b/src/main/app/intercom.test.js index 41fe847a..a78066eb 100644 --- a/src/main/app/intercom.test.js +++ b/src/main/app/intercom.test.js @@ -14,6 +14,7 @@ import { handleNewServerModal, handleEditServerModal, handleRemoveServerModal, + handleWelcomeScreenModal, } from './intercom'; jest.mock('common/config', () => ({ @@ -235,4 +236,24 @@ describe('main/app/intercom', () => { })); }); }); + + describe('handleWelcomeScreenModal', () => { + beforeEach(() => { + getLocalURLString.mockReturnValue('/some/index.html'); + getLocalPreload.mockReturnValue('/some/preload.js'); + WindowManager.getMainWindow.mockReturnValue({}); + + Config.set.mockImplementation((name, value) => { + Config[name] = value; + }); + }); + + it('should show welcomeScreen modal', async () => { + const promise = Promise.resolve({}); + ModalManager.addModal.mockReturnValue(promise); + + handleWelcomeScreenModal(); + expect(ModalManager.addModal).toHaveBeenCalledWith('welcomeScreen', '/some/index.html', '/some/preload.js', {}, {}, true); + }); + }); }); diff --git a/src/main/app/intercom.ts b/src/main/app/intercom.ts index 9c856a9d..3648f996 100644 --- a/src/main/app/intercom.ts +++ b/src/main/app/intercom.ts @@ -85,15 +85,26 @@ export function handleOpenTab(event: IpcMainEvent, serverName: string, tabName: Config.set('teams', teams); } -export function addNewServerModalWhenMainWindowIsShown() { +export function handleMainWindowIsShown() { + const showWelcomeScreen = !Config.teams.length; const mainWindow = WindowManager.getMainWindow(); + if (mainWindow) { if (mainWindow.isVisible()) { - handleNewServerModal(); + if (showWelcomeScreen) { + handleWelcomeScreenModal(); + } else { + handleNewServerModal(); + } } else { mainWindow.once('show', () => { - log.debug('Intercom.addNewServerModalWhenMainWindowIsShown.show'); - handleNewServerModal(); + if (showWelcomeScreen) { + log.debug('Intercom.handleMainWindowIsShown.show.welcomeScreenModal'); + handleWelcomeScreenModal(); + } else { + log.debug('Intercom.handleMainWindowIsShown.show.newServerModal'); + handleNewServerModal(); + } }); } } @@ -213,6 +224,32 @@ export function handleRemoveServerModal(e: IpcMainEvent, name: string) { } } +export function handleWelcomeScreenModal() { + log.debug('Intercom.handleWelcomeScreenModal'); + + const html = getLocalURLString('welcomeScreen.html'); + + const modalPreload = getLocalPreload('modalPreload.js'); + + const mainWindow = WindowManager.getMainWindow(); + if (!mainWindow) { + return; + } + const modalPromise = ModalManager.addModal('welcomeScreen', html, modalPreload, {}, mainWindow, true); + if (modalPromise) { + modalPromise.then(() => { + handleNewServerModal(); + }).catch((e) => { + // e is undefined for user cancellation + if (e) { + log.error(`there was an error in the welcome screen modal: ${e}`); + } + }); + } else { + log.warn('There is already a welcome screen modal'); + } +} + export function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, data: MentionData) { log.debug('Intercom.handleMentionNotification', {title, body, channel, teamId, url, silent, data}); displayMention(title, body, channel, teamId, url, silent, event.sender, data); diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index 4bd60c78..13e89c7c 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -7,7 +7,7 @@ import {dialog, ipcMain} from 'electron'; import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; -import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SHOW_NEW_SERVER_MODAL} from 'common/communication'; +import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, MAIN_WINDOW_SHOWN} from 'common/communication'; import {MattermostServer} from 'common/servers/MattermostServer'; import {getServerView, getTabViewName} from 'common/tabs/TabView'; import urlUtils from 'common/utils/url'; @@ -547,7 +547,7 @@ describe('main/views/viewManager', () => { }; viewManager.getServers = () => []; viewManager.showInitial(); - expect(ipcMain.emit).toHaveBeenCalledWith(SHOW_NEW_SERVER_MODAL); + expect(ipcMain.emit).toHaveBeenCalledWith(MAIN_WINDOW_SHOWN); }); }); diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index a09647a6..83b50c29 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -20,7 +20,7 @@ import { BROWSER_HISTORY_PUSH, UPDATE_LAST_ACTIVE, UPDATE_URL_VIEW_WIDTH, - SHOW_NEW_SERVER_MODAL, + MAIN_WINDOW_SHOWN, } from 'common/communication'; import Config from 'common/config'; import urlUtils from 'common/utils/url'; @@ -215,7 +215,7 @@ export class ViewManager { } } else { this.mainWindow.webContents.send(SET_ACTIVE_VIEW, null, null); - ipcMain.emit(SHOW_NEW_SERVER_MODAL); + ipcMain.emit(MAIN_WINDOW_SHOWN); } } diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index 075136e7..368ee6ef 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -217,7 +217,17 @@ describe('main/windows/windowManager', () => { }); it('should use getSize when the platform is linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + windowManager.handleResizeMainWindow(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + expect(view.setBounds).not.toHaveBeenCalled(); jest.runAllTimers(); expect(view.setBounds).toHaveBeenCalledWith({width: 1000, height: 900}); @@ -281,6 +291,76 @@ describe('main/windows/windowManager', () => { }); }); + describe('handleResizedMainWindow', () => { + const windowManager = new WindowManager(); + const view = { + setBounds: jest.fn(), + tab: { + url: 'http://server-1.com', + }, + view: { + webContents: { + getURL: jest.fn(), + }, + }, + }; + windowManager.mainWindow = { + getContentBounds: () => ({width: 800, height: 600}), + getSize: () => [1000, 900], + }; + + beforeEach(() => { + getAdjustedWindowBoundaries.mockImplementation((width, height) => ({width, height})); + }); + + afterEach(() => { + windowManager.isResizing = true; + jest.resetAllMocks(); + }); + + it('should not handle bounds if no window available', () => { + windowManager.handleResizedMainWindow(); + expect(windowManager.isResizing).toBe(false); + expect(view.setBounds).not.toHaveBeenCalled(); + }); + + it('should use getContentBounds when the platform is different to linux', () => { + windowManager.viewManager = { + getCurrentView: () => view, + }; + + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'windows', + }); + + windowManager.handleResizedMainWindow(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + + expect(windowManager.isResizing).toBe(false); + expect(view.setBounds).toHaveBeenCalledWith({width: 800, height: 600}); + }); + + it('should use getSize when the platform is linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + + windowManager.handleResizedMainWindow(); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + + expect(windowManager.isResizing).toBe(false); + expect(view.setBounds).toHaveBeenCalledWith({width: 1000, height: 900}); + }); + }); + describe('restoreMain', () => { const windowManager = new WindowManager(); windowManager.mainWindow = { diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index 68cf3b7e..b3440ae4 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -203,8 +203,12 @@ export class WindowManager { } handleResizedMainWindow = () => { + log.silly('WindowManager.handleResizedMainWindow'); + if (this.mainWindow) { - this.throttledWillResize(this.mainWindow?.getContentBounds()); + const bounds = this.getBounds(); + this.throttledWillResize(bounds); + ipcMain.emit(RESIZE_MODAL, null, bounds); } this.isResizing = false; } @@ -228,17 +232,7 @@ export class WindowManager { return; } - let bounds; - - // Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs: - // https://github.com/electron/electron/issues/28699 - // https://github.com/electron/electron/issues/28106 - if (process.platform === 'linux') { - const size = this.mainWindow.getSize(); - bounds = {width: size[0], height: size[1]}; - } else { - bounds = this.mainWindow.getContentBounds(); - } + const bounds = this.getBounds(); // Another workaround since the window doesn't update p roperly under Linux for some reason // See above comment @@ -261,6 +255,24 @@ export class WindowManager { currentView.setBounds(bounds); }; + private getBounds = () => { + let bounds; + + if (this.mainWindow) { + // Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs: + // https://github.com/electron/electron/issues/28699 + // https://github.com/electron/electron/issues/28106 + if (process.platform === 'linux') { + const size = this.mainWindow.getSize(); + bounds = {width: size[0], height: size[1]}; + } else { + bounds = this.mainWindow.getContentBounds(); + } + } + + return bounds as Electron.Rectangle; + } + // max retries allows the message to get to the renderer even if it is sent while the app is starting up. sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: any[]) => { if (!this.mainWindow || !this.mainWindowReady) { diff --git a/src/renderer/assets/svg/bullseye.svg b/src/renderer/assets/svg/bullseye.svg new file mode 100644 index 00000000..89ebe33e --- /dev/null +++ b/src/renderer/assets/svg/bullseye.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/svg/channels.svg b/src/renderer/assets/svg/channels.svg new file mode 100644 index 00000000..b8c95232 --- /dev/null +++ b/src/renderer/assets/svg/channels.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/svg/chat2.svg b/src/renderer/assets/svg/chat2.svg new file mode 100644 index 00000000..8441ba90 --- /dev/null +++ b/src/renderer/assets/svg/chat2.svg @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/svg/clipboard.svg b/src/renderer/assets/svg/clipboard.svg new file mode 100644 index 00000000..c7c395d1 --- /dev/null +++ b/src/renderer/assets/svg/clipboard.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/components/Carousel/Carousel.tsx b/src/renderer/components/Carousel/Carousel.tsx new file mode 100644 index 00000000..7c5bd465 --- /dev/null +++ b/src/renderer/components/Carousel/Carousel.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useRef} from 'react'; +import classNames from 'classnames'; + +import CarouselButton, {ButtonDirection} from './CarouselButton'; +import CarouselPaginationIndicator from './CarouselPaginationIndicator'; + +import 'renderer/css/components/Carousel.scss'; + +const AUTO_CHANGE_TIME = 5000; + +type CarouselProps = { + slides: Array<{key: string; content: React.ReactNode}>; + startIndex?: number; + darkMode?: boolean; +}; + +function Carousel({ + slides, + startIndex = 0, + darkMode = false, +}: CarouselProps) { + const [slideIn, setSlideIn] = useState(startIndex); + const [slideOut, setSlideOut] = useState(NaN); + const [direction, setDirection] = useState(ButtonDirection.NEXT); + const [autoChange, setAutoChange] = useState(true); + const timerRef = useRef(null); + + const disableNavigation = slides.length <= 1; + + useEffect(() => { + timerRef.current = autoChange ? ( + setTimeout(() => { + handleOnNextButtonClick(true); + }, AUTO_CHANGE_TIME) + ) : null; + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [slideIn, autoChange]); + + const handleOnPrevButtonClick = () => { + moveSlide(slideIn - 1); + setDirection(ButtonDirection.PREV); + setAutoChange(false); + }; + + const handleOnNextButtonClick = (fromAuto?: boolean) => { + moveSlide(slideIn + 1); + setDirection(ButtonDirection.NEXT); + + if (!fromAuto) { + setAutoChange(false); + } + }; + + const handleOnPaginationIndicatorClick = (indicatorIndex: number) => { + moveSlide(indicatorIndex); + setDirection(indicatorIndex > slideIn ? ButtonDirection.NEXT : ButtonDirection.PREV); + setAutoChange(false); + }; + + const moveSlide = (toIndex: number) => { + if (toIndex === slideIn) { + return; + } + + let current = toIndex; + + if (toIndex < 0) { + current = slides.length - 1; + } else if (toIndex >= slides.length) { + current = 0; + } + + setSlideOut(slideIn); + setSlideIn(current); + }; + + return ( +
+
+ {slides.map(({key, content}, slideIndex) => { + const isPrev = slideIndex === slideOut; + const isCurrent = slideIndex === slideIn; + + return ( +
+ {content} +
+ ); + })} +
+
+ + + +
+
+ ); +} + +export default Carousel; diff --git a/src/renderer/components/Carousel/CarouselButton.tsx b/src/renderer/components/Carousel/CarouselButton.tsx new file mode 100644 index 00000000..399f45b2 --- /dev/null +++ b/src/renderer/components/Carousel/CarouselButton.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import classNames from 'classnames'; + +import 'renderer/css/components/Button.scss'; +import 'renderer/css/components/CarouselButton.scss'; + +export enum ButtonDirection { + NEXT = 'next', + PREV = 'prev', +} + +type CarouselButtonProps = { + direction: ButtonDirection; + disabled?: boolean; + darkMode?: boolean; + onClick?: () => void; +}; + +function CarouselButton({ + direction = ButtonDirection.NEXT, + disabled = false, + darkMode = false, + onClick = () => null, +}: CarouselButtonProps) { + const handleOnClick = () => { + onClick(); + }; + + return ( + + ); +} + +export default CarouselButton; diff --git a/src/renderer/components/Carousel/CarouselPaginationIndicator.tsx b/src/renderer/components/Carousel/CarouselPaginationIndicator.tsx new file mode 100644 index 00000000..5f36f733 --- /dev/null +++ b/src/renderer/components/Carousel/CarouselPaginationIndicator.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import classNames from 'classnames'; + +import 'renderer/css/components/Button.scss'; +import 'renderer/css/components/CarouselPaginationIndicator.scss'; + +type CarouselPaginationIndicatorProps = { + pages: number; + activePage: number; + disabled?: boolean; + darkMode?: boolean; + onClick?: (pageIndex: number) => void; +}; + +function CarouselPaginationIndicator({ + pages, + activePage, + disabled, + darkMode, + onClick = () => null, +}: CarouselPaginationIndicatorProps) { + const handleOnClick = useCallback((pageIndex: number) => () => { + onClick(pageIndex); + }, [onClick]); + + const handleOnKeyDown = useCallback((pageIndex: number) => (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onClick(pageIndex); + } + }, [onClick]); + + const getIndicators = useCallback(() => { + const indicators = []; + + for (let pageIndex = 0; pageIndex < pages; pageIndex++) { + indicators.push( +
+
+
, + ); + } + + return indicators; + }, [pages, activePage, darkMode, handleOnClick]); + + return ( +
+ {getIndicators()} +
+ ); +} + +export default CarouselPaginationIndicator; diff --git a/src/renderer/components/Carousel/index.ts b/src/renderer/components/Carousel/index.ts new file mode 100644 index 00000000..9c17be45 --- /dev/null +++ b/src/renderer/components/Carousel/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './Carousel'; diff --git a/src/renderer/components/Header/Header.tsx b/src/renderer/components/Header/Header.tsx new file mode 100644 index 00000000..bdadbb11 --- /dev/null +++ b/src/renderer/components/Header/Header.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import classNames from 'classnames'; + +import Logo from 'renderer/components/Logo'; + +import 'renderer/css/components/Header.scss'; + +type HeaderProps = { + alternateLink?: React.ReactElement; + darkMode?: boolean; +} + +const Header = ({ + alternateLink, + darkMode, +}: HeaderProps) => ( +
+
+
+ +
+ {alternateLink} +
+
+); + +export default Header; diff --git a/src/renderer/components/Header/index.ts b/src/renderer/components/Header/index.ts new file mode 100644 index 00000000..202bf86e --- /dev/null +++ b/src/renderer/components/Header/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './Header'; diff --git a/src/renderer/components/Logo.tsx b/src/renderer/components/Logo.tsx new file mode 100644 index 00000000..4272d8e6 --- /dev/null +++ b/src/renderer/components/Logo.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +type Props = { + width?: number; + height?: number; +} + +export default ({ + width = 170, + height = 28, +}: Props) => ( + + + + +); diff --git a/src/renderer/components/MainPage.tsx b/src/renderer/components/MainPage.tsx index dc32c379..3ef725e6 100644 --- a/src/renderer/components/MainPage.tsx +++ b/src/renderer/components/MainPage.tsx @@ -521,6 +521,11 @@ class MainPage extends React.PureComponent { ref={this.topBar} className={'topBar-bg'} > + {window.process.platform !== 'linux' && this.props.teams.length === 0 && ( +
+ {intl.formatMessage({id: 'renderer.components.mainPage.titleBar', defaultMessage: 'Mattermost'})} +
+ )} - 0} - isMenuOpen={this.state.isMenuOpen} - darkMode={this.state.darkMode} - /> + {this.props.teams.length !== 0 && ( + 0} + isMenuOpen={this.state.isMenuOpen} + darkMode={this.state.darkMode} + /> + )} {tabsRow} {upgradeIcon} {titleBarButtons} diff --git a/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx new file mode 100644 index 00000000..dc031d3c --- /dev/null +++ b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {useIntl, FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; + +import bullseye from 'renderer/assets/svg/bullseye.svg'; +import channels from 'renderer/assets/svg/channels.svg'; +import chat2 from 'renderer/assets/svg/chat2.svg'; +import clipboard from 'renderer/assets/svg/clipboard.svg'; + +import Carousel from 'renderer/components/Carousel'; +import Header from 'renderer/components/Header'; +import LoadingBackground from 'renderer/components/LoadingScreen/LoadingBackground'; + +import WelcomeScreenSlide from './WelcomeScreenSlide'; + +import 'renderer/css/components/Button.scss'; +import 'renderer/css/components/WelcomeScreen.scss'; +import 'renderer/css/components/LoadingScreen.css'; + +type WelcomeScreenProps = { + darkMode?: boolean; + onGetStarted?: () => void; +}; + +function WelcomeScreen({ + darkMode = false, + onGetStarted = () => null, +}: WelcomeScreenProps) { + const {formatMessage} = useIntl(); + + const slides = useMemo(() => [ + { + key: 'welcome', + title: formatMessage({id: 'renderer.components.welcomeScreen.slides.welcome.title', defaultMessage: 'Welcome'}), + subtitle: formatMessage({ + id: 'renderer.components.welcomeScreen.slides.welcome.subtitle', + defaultMessage: 'Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with the tools you love.', + }), + image: ( + + ), + main: true, + }, + { + key: 'channels', + title: formatMessage({id: 'renderer.components.welcomeScreen.slides.channels.title', defaultMessage: 'Channels'}), + subtitle: ( + (<>
{x}), + }} + /> + ), + image: ( + + ), + }, + { + key: 'playbooks', + title: formatMessage({id: 'renderer.components.welcomeScreen.slides.playbooks.title', defaultMessage: 'Playbooks'}), + subtitle: formatMessage({ + id: 'renderer.components.welcomeScreen.slides.palybooks.subtitle', + defaultMessage: 'Move faster and make fewer mistakes with checklists, automations, and tool integrations that power your team’s workflows.', + }), + image: ( + + ), + }, + { + key: 'boards', + title: formatMessage({id: 'renderer.components.welcomeScreen.slides.boards.title', defaultMessage: 'Boards'}), + subtitle: formatMessage({ + id: 'renderer.components.welcomeScreen.slides.boards.subtitle', + defaultMessage: 'Ship on time, every time, with a project and task management solution built for digital operations.', + }), + image: ( + + ), + }, + ], []); + + const handleOnGetStartedClick = () => { + onGetStarted(); + }; + + return ( +
+ +
+
+
+ ({ + key, + content: ( + + ), + }))} + darkMode={darkMode} + /> + +
+
+
+
+ ); +} + +export default WelcomeScreen; diff --git a/src/renderer/components/WelcomeScreen/WelcomeScreenSlide.tsx b/src/renderer/components/WelcomeScreen/WelcomeScreenSlide.tsx new file mode 100644 index 00000000..97946b7f --- /dev/null +++ b/src/renderer/components/WelcomeScreen/WelcomeScreenSlide.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import classNames from 'classnames'; + +import 'renderer/css/components/WelcomeScreenSlide.scss'; + +type WelcomeScreenSlideProps = { + title: string; + subtitle: string | React.ReactElement; + image: React.ReactNode; + isMain?: boolean; + darkMode?: boolean; +}; + +const WelcomeScreenSlide = ({ + title, + subtitle, + image, + isMain, + darkMode, +}: WelcomeScreenSlideProps) => ( +
+
+ {image} +
+
+ {title} +
+
+ {subtitle} +
+
+); + +export default WelcomeScreenSlide; diff --git a/src/renderer/components/WelcomeScreen/index.ts b/src/renderer/components/WelcomeScreen/index.ts new file mode 100644 index 00000000..a16024ff --- /dev/null +++ b/src/renderer/components/WelcomeScreen/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './WelcomeScreen'; diff --git a/src/renderer/css/_css_variables.scss b/src/renderer/css/_css_variables.scss new file mode 100644 index 00000000..af5a55a1 --- /dev/null +++ b/src/renderer/css/_css_variables.scss @@ -0,0 +1,14 @@ +:root { + --button-bg: #166de0; + --button-color: #fff; + --center-channel-text: #3f4350; + --sidebar-text-active-border: #579eff; + --denim-button-bg: #1c58d9; + --denim-sidebar-active-border: #5d89ea; + --title-color-indigo-500: #1e325c; + + --button-color-rgb: 255, 255, 255; + --center-channel-color-rgb: 61, 60, 64; + --center-channel-text-rgb: 63, 67, 80; + --denim-button-bg-rgb: 28, 88, 217; +} diff --git a/src/renderer/css/components/Button.scss b/src/renderer/css/components/Button.scss new file mode 100644 index 00000000..5a626043 --- /dev/null +++ b/src/renderer/css/components/Button.scss @@ -0,0 +1,177 @@ +@import url("../_css_variables.scss"); + +.primary-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: var(--button-bg); + border-radius: 4px; + color: var(--button-color); + font-weight: 600; + font-family: 'Open Sans'; + + &:hover { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--button-bg); + } + + &:active { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--button-bg); + } + + &:focus { + box-sizing: border-box; + border: 2px solid var(--sidebar-text-active-border); + outline: none; + } + + &:disabled { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.32); + cursor: not-allowed; + } + + i { + display: flex; + font-size: 18px; + } +} + +.primary-button-inverted { + background: var(--button-color); + color: var(--denim-button-bg); + + &:hover { + background: linear-gradient(0deg, rgba(var(--denim-button-bg-rgb), 0.08), rgba(var(--denim-button-bg-rgb), 0.08)), var(--button-color); + color: var(--denim-button-bg); + } + + &:active { + background: linear-gradient(0deg, rgba(var(--denim-button-bg-rgb), 0.16), rgba(var(--denim-button-bg-rgb), 0.16)), var(--button-color); + color: var(--denim-button-bg); + } + + &:focus { + border: 2px solid var(--denim-sidebar-active-border); + color: var(--denim-button-bg); + } + + &:disabled { + background: rgba(var(--center-channel-text-rgb), 0.08); + color: rgba(var(--center-channel-text-rgb), 0.32); + } +} + +.primary-large-button { + height: 48px; + padding: 0 24px; + font-size: 16px; + line-height: 18px; + + &:focus { + padding: 0 22px; + } +} + +.primary-medium-button { + height: 40px; + padding: 0 20px; + font-size: 14px; + line-height: 14px; + + &:focus { + padding: 0 20px; + } +} + +.icon-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: none; + border-radius: 4px; + color: rgba(var(--center-channel-text-rgb), 0.56); + font-weight: 400; + + &:hover { + background: rgba(var(--center-channel-text-rgb), 0.08); + color: rgba(var(--center-channel-text-rgb), 0.72); + } + + &:active { + background: rgba(var(--denim-button-bg-rgb), 0.08); + color: var(--denim-button-bg); + } + + &:focus-visible { + box-sizing: border-box; + border-color: linear-gradient(0deg, rgba(var(--button-color-rgb), 0.32), rgba(var(--button-color-rgb), 0.32)), var(--denim-button-bg); + outline: none; + } + + &:disabled { + background: none; + color: rgba(var(--center-channel-text-rgb), 0.32); + cursor: not-allowed; + } + + i { + display: flex; + font-style: normal; + justify-content: center; + } +} + +.icon-button-inverted { + background: none; + color: rgba(var(--button-color-rgb), 0.64); + + &:hover { + background: rgba(var(--button-color-rgb), 0.08); + color: var(--button-color); + } + + &:active { + background: rgba(var(--button-color-rgb), 0.16); + color: var(--button-color); + } + + &:focus-visible { + box-sizing: border-box; + border-color: linear-gradient(0deg, rgba(var(--button-color-rgb), 0.32), rgba(var(--button-color-rgb), 0.32)), var(--denim-button-bg); + } + + &:disabled { + background: none; + color: rgba(var(--button-color-rgb), 0.32); + } +} + +.icon-button-small { + height: 28px; + padding: 6px; + font-size: 18px; + line-height: 18px; + + &:focus:not(:focus-visible) { + padding: 6px 8px; + border: 0; + } + + &:focus-visible { + padding: 4px; + border: 2px solid; + } + + i { + width: 16px; + height: 16px; + + &::before { + line-height: 16px; + } + } +} diff --git a/src/renderer/css/components/Carousel.scss b/src/renderer/css/components/Carousel.scss new file mode 100644 index 00000000..a23714e8 --- /dev/null +++ b/src/renderer/css/components/Carousel.scss @@ -0,0 +1,94 @@ +.Carousel { + display: flex; + flex: 1; + flex-flow: column; + align-items: center; + justify-content: center; + width: 100%; + + .Carousel__slides { + min-height: 380px; + width: 100%; + position: relative; + display: flex; + justify-content: center; + + .Carousel__slide { + position: absolute; + bottom: 0; + opacity: 0; + } + + .Carousel__slide-current { + opacity: 1; + } + } + + .Carousel__pagination { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + margin-top: 22px; + } +} + +.inFromRight { + animation: inFromRight 0.4s ease-in-out; +} + +.inFromLeft { + animation: inFromLeft 0.4s ease-in-out; +} + +.outToRight { + animation: outToRight 0.4s ease-in-out; +} + +.outToLeft { + animation: outToLeft 0.4s ease-in-out; +} + +@keyframes inFromRight { + 0% { + transform: translateX(30%); + opacity: 0; + } + 100% { + transform: translateX(0%); + opacity: 1; + } +} + +@keyframes inFromLeft { + 0% { + transform: translateX(-30%); + opacity: 0; + } + 100% { + transform: translateX(0%); + opacity: 1; + } +} + +@keyframes outToRight { + 0% { + transform: translateX(0%); + opacity: 1; + } + 100% { + transform: translateX(30%); + opacity: 0; + } +} + +@keyframes outToLeft { + 0% { + transform: translateX(0%); + opacity: 1; + } + 100% { + transform: translateX(-30%); + opacity: 0; + } +} \ No newline at end of file diff --git a/src/renderer/css/components/CarouselButton.scss b/src/renderer/css/components/CarouselButton.scss new file mode 100644 index 00000000..49dcc336 --- /dev/null +++ b/src/renderer/css/components/CarouselButton.scss @@ -0,0 +1,10 @@ +@import '~@mattermost/compass-icons/css/compass-icons.css'; + +.CarouselButton { + height: 32px; + padding: 8px; + + &:focus { + padding: 6px; + } +} diff --git a/src/renderer/css/components/CarouselPaginationIndicator.scss b/src/renderer/css/components/CarouselPaginationIndicator.scss new file mode 100644 index 00000000..57dcaae5 --- /dev/null +++ b/src/renderer/css/components/CarouselPaginationIndicator.scss @@ -0,0 +1,70 @@ +@import url("../_css_variables.scss"); + +.CarouselPaginationIndicator { + display: flex; + align-items: center; + justify-content: center; + margin: 0 23px; + + .indicatorDot { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + border-radius: 50%; + background: none; + box-shadow: 0 0 0 3px transparent; + cursor: pointer; + + &:not(:first-child) { + margin-left: 4px; + } + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(var(--denim-button-bg-rgb), 0.32); + } + + &.active, + &.disabled { + cursor: default; + pointer-events: none; + } + + &.active { + background: rgba(var(--denim-button-bg-rgb), 0.16); + + .dot { + background: var(--denim-button-bg); + } + } + + &:focus-visible { + box-sizing: border-box; + border: 2px solid var(--sidebar-text-active-border); + outline: none; + } + } + + .indicatorDot-inverted { + .dot { + background: rgba(var(--button-color-rgb), 0.32); + } + + &.active { + background: rgba(var(--button-color-rgb), 0.16); + + .dot { + background: var(--button-color); + } + } + + &:focus-visible { + border: 2px solid var(--denim-sidebar-active-border); + color: var(--denim-button-bg); + } + } +} diff --git a/src/renderer/css/components/Header.scss b/src/renderer/css/components/Header.scss new file mode 100644 index 00000000..15bede62 --- /dev/null +++ b/src/renderer/css/components/Header.scss @@ -0,0 +1,36 @@ +@import url("../_css_variables.scss"); + +.Header { + position: relative; + display: flex; + width: 100%; + min-height: 100px; + padding: 0 40px; + + .Header__main { + display: flex; + width: 100%; + height: 100%; + flex-flow: wrap; + align-items: center; + justify-content: space-between; + + .Header__logo { + width: 170px; + + path { + fill: var(--center-channel-text); + } + } + } + + &.Header--darkMode { + .Header__main { + .Header__logo { + path { + fill: var(--button-color); + } + } + } + } +} diff --git a/src/renderer/css/components/WelcomeScreen.scss b/src/renderer/css/components/WelcomeScreen.scss new file mode 100644 index 00000000..c97eed32 --- /dev/null +++ b/src/renderer/css/components/WelcomeScreen.scss @@ -0,0 +1,35 @@ +.WelcomeScreen { + flex-direction: column; + z-index: 20; + + * { + z-index: 21; + } + + .WelcomeScreen__body { + display: flex; + flex: 1; + width: 100%; + align-items: center; + justify-content: center; + -webkit-font-smoothing: antialiased; + + .WelcomeScreen__content { + display: flex; + flex-direction: column; + width: 100%; + align-items: center; + justify-content: center; + + .WelcomeScreen__button { + margin-top: 22px; + } + } + } + + .WelcomeScreen__footer { + display: block; + width: 100%; + height: 100px; + } +} diff --git a/src/renderer/css/components/WelcomeScreenSlide.scss b/src/renderer/css/components/WelcomeScreenSlide.scss new file mode 100644 index 00000000..f34a0d98 --- /dev/null +++ b/src/renderer/css/components/WelcomeScreenSlide.scss @@ -0,0 +1,60 @@ +@import url("../_css_variables.scss"); +@import url("../fonts.css"); + +.WelcomeScreenSlide { + display: flex; + flex: 1; + max-width: 540px; + flex-flow: column; + justify-content: flex-end; + + .WelcomeScreenSlide__image { + align-self: center; + } + + .WelcomeScreenSlide__title { + color: var(--title-color-indigo-500); + font-family: 'Metropolis'; + font-size: 80px; + font-weight: 600; + letter-spacing: -0.05em; + line-height: 88px; + text-align: center; + } + + .WelcomeScreenSlide__subtitle { + color: rgba(var(--center-channel-text-rgb), 0.72); + font-family: Open Sans; + font-size: 16px; + font-weight: 400; + line-height: 24px; + text-align: center; + margin-top: 16px; + } + + &.WelcomeScreenSlide--main { + position: relative; + height: 100%; + + .WelcomeScreenSlide__title { + font-size: 128px; + line-height: 128px; + } + + .WelcomeScreenSlide__image { + position: absolute; + display: block; + bottom: 115px; + } + } + + &.WelcomeScreenSlide--darkMode { + .WelcomeScreenSlide__title { + color: var(--button-color); + } + + .WelcomeScreenSlide__subtitle { + color: rgba(var(--button-color-rgb), 0.72); + } + } +} diff --git a/src/renderer/css/fonts.css b/src/renderer/css/fonts.css index 1feac8dc..3a709ec0 100644 --- a/src/renderer/css/fonts.css +++ b/src/renderer/css/fonts.css @@ -39,3 +39,45 @@ font-weight: 600; src: url('../../assets/fonts/open-sans-v13-latin-ext_latin_cyrillic-ext_greek-ext_greek_cyrillic_vietnamese-600italic.woff2') format('woff2'); } + +@font-face { + font-family: 'Metropolis'; + font-style: normal; + font-weight: 600; + src: url('../../assets/fonts/Metropolis-SemiBold.woff') format('woff'); +} + +@font-face { + font-family: 'Metropolis'; + font-style: italic; + font-weight: 600; + src: url('../../assets/fonts/Metropolis-SemiBoldItalic.woff') format('woff'); +} + +@font-face { + font-family: 'Metropolis'; + font-style: normal; + font-weight: 400; + src: url('../../assets/fonts/Metropolis-Regular.woff') format('woff'); +} + +@font-face { + font-family: 'Metropolis'; + font-style: italic; + font-weight: 400; + src: url('../../assets/fonts/Metropolis-RegularItalic.woff') format('woff'); +} + +@font-face { + font-family: 'Metropolis'; + font-style: normal; + font-weight: 300; + src: url('../../assets/fonts/Metropolis-Light.woff') format('woff'); +} + +@font-face { + font-family: 'Metropolis'; + font-style: italic; + font-weight: 300; + src: url('../../assets/fonts/Metropolis-LightItalic.woff') format('woff'); +} diff --git a/src/renderer/css/index.css b/src/renderer/css/index.css index 12961281..7ba43e4d 100644 --- a/src/renderer/css/index.css +++ b/src/renderer/css/index.css @@ -117,6 +117,28 @@ body { color: rgba(243,243,243,0.7); } +.topBar .app-title { + position: absolute; + top: 0; + left: 0; + display: flex; + width: 100%; + height: 40px; + justify-content: center; + align-items: center; + color: rgba(61,60,64,0.7); + font-family: "Open Sans", sans-serif; + font-weight: 600; + font-size: 14px; + letter-spacing: -0.2px; + z-index: 0; + -webkit-app-region: drag; +} + +.topBar.darkMode .app-title { + color: rgba(221,221,221,0.64); +} + .topBar .title-bar-btns { position: relative; line-height: 40px; diff --git a/src/renderer/modals/welcomeScreen/welcomeScreen.tsx b/src/renderer/modals/welcomeScreen/welcomeScreen.tsx new file mode 100644 index 00000000..ef4354fc --- /dev/null +++ b/src/renderer/modals/welcomeScreen/welcomeScreen.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import ReactDOM from 'react-dom'; + +import {ModalMessage} from 'types/modals'; + +import { + MODAL_RESULT, + GET_MODAL_UNCLOSEABLE, + GET_DARK_MODE, + DARK_MODE_CHANGE, +} from 'common/communication'; +import IntlProvider from 'renderer/intl_provider'; +import WelcomeScreen from '../../components/WelcomeScreen'; + +import 'bootstrap/dist/css/bootstrap.min.css'; + +const onGetStarted = () => { + window.postMessage({type: MODAL_RESULT}, window.location.href); +}; + +const WelcomeScreenModalWrapper = () => { + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + window.postMessage({type: GET_MODAL_UNCLOSEABLE}, window.location.href); + window.postMessage({type: GET_DARK_MODE}, window.location.href); + window.addEventListener('message', handleMessageEvent); + + return () => { + window.removeEventListener('message', handleMessageEvent); + }; + }, []); + + const handleMessageEvent = (event: {data: ModalMessage}) => { + if (event.data.type === DARK_MODE_CHANGE) { + setDarkMode(event.data.data); + } + }; + + return ( + + + + ); +}; + +const start = async () => { + ReactDOM.render( + , + document.getElementById('app'), + ); +}; + +start(); diff --git a/webpack.config.renderer.js b/webpack.config.renderer.js index a18fcb83..a20a02fa 100644 --- a/webpack.config.renderer.js +++ b/webpack.config.renderer.js @@ -29,6 +29,7 @@ module.exports = merge(base, { permissionModal: './src/renderer/modals/permission/permission.tsx', certificateModal: './src/renderer/modals/certificate/certificate.tsx', loadingScreen: './src/renderer/modals/loadingScreen/index.tsx', + welcomeScreen: './src/renderer/modals/welcomeScreen/welcomeScreen.tsx', }, output: { path: path.resolve(__dirname, 'dist/renderer'), @@ -102,6 +103,12 @@ module.exports = merge(base, { chunks: ['loadingScreen'], filename: 'loadingScreen.html', }), + new HtmlWebpackPlugin({ + title: 'Mattermost Desktop Settings', + template: 'src/renderer/index.html', + chunks: ['welcomeScreen'], + filename: 'welcomeScreen.html', + }), new MiniCssExtractPlugin({ filename: 'styles.[contenthash].css', ignoreOrder: true,