From dd1f9b055f11ac0bcb740c4fa09c07cfc74ec9c9 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 17 Oct 2019 11:22:07 -0700 Subject: [PATCH] New AvatarPopup component --- _locales/en/messages.json | 4 + images/icons/v2/archive-outline-16.svg | 1 + images/icons/v2/archive-solid-16.svg | 1 + images/icons/v2/settings-outline-16.svg | 1 + images/icons/v2/settings-solid-16.svg | 1 + js/models/conversations.js | 2 +- main.js | 3 +- stylesheets/_conversation.scss | 2 +- stylesheets/_global.scss | 4 +- stylesheets/_mixins.scss | 3 +- stylesheets/_modules.scss | 139 +++++++++++++++++++++++- stylesheets/_settings.scss | 4 +- stylesheets/_variables.scss | 1 - ts/components/Avatar.tsx | 37 ++++++- ts/components/AvatarPopup.md | 48 ++++++++ ts/components/AvatarPopup.tsx | 71 ++++++++++++ ts/components/MainHeader.tsx | 131 ++++++++++++++++++++-- ts/shims/Whisper.ts | 5 + ts/util/lint/exceptions.json | 4 +- 19 files changed, 432 insertions(+), 30 deletions(-) create mode 100644 images/icons/v2/archive-outline-16.svg create mode 100644 images/icons/v2/archive-solid-16.svg create mode 100644 images/icons/v2/settings-outline-16.svg create mode 100644 images/icons/v2/settings-solid-16.svg create mode 100644 ts/components/AvatarPopup.md create mode 100644 ts/components/AvatarPopup.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 07ddb1a56e..7a6e477684 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -166,6 +166,10 @@ "description": "Only available on development modes, menu option to open up the standalone device setup sequence" }, + "avatarMenuViewArchive": { + "message": "View Archive", + "description": "One of the menu options available in the Avatar Popup menu" + }, "loading": { "message": "Loading...", "description": diff --git a/images/icons/v2/archive-outline-16.svg b/images/icons/v2/archive-outline-16.svg new file mode 100644 index 0000000000..0b2339bfc6 --- /dev/null +++ b/images/icons/v2/archive-outline-16.svg @@ -0,0 +1 @@ +archive-outline-16 \ No newline at end of file diff --git a/images/icons/v2/archive-solid-16.svg b/images/icons/v2/archive-solid-16.svg new file mode 100644 index 0000000000..b65bc54d75 --- /dev/null +++ b/images/icons/v2/archive-solid-16.svg @@ -0,0 +1 @@ +archive-solid-16 \ No newline at end of file diff --git a/images/icons/v2/settings-outline-16.svg b/images/icons/v2/settings-outline-16.svg new file mode 100644 index 0000000000..2c350ebe3f --- /dev/null +++ b/images/icons/v2/settings-outline-16.svg @@ -0,0 +1 @@ +settings-16 \ No newline at end of file diff --git a/images/icons/v2/settings-solid-16.svg b/images/icons/v2/settings-solid-16.svg new file mode 100644 index 0000000000..a19ebdcb5f --- /dev/null +++ b/images/icons/v2/settings-solid-16.svg @@ -0,0 +1 @@ +settings-solid-16 \ No newline at end of file diff --git a/js/models/conversations.js b/js/models/conversations.js index ac320b4d6f..283c1daf07 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1939,7 +1939,7 @@ }, getProfileName() { - if (this.isPrivate() && !this.get('name')) { + if (this.isPrivate()) { return this.get('profileName'); } return null; diff --git a/main.js b/main.js index 9d4a1fca74..3606585ac9 100644 --- a/main.js +++ b/main.js @@ -524,6 +524,8 @@ async function showSettingsWindow() { return; } + addDarkOverlay(); + const size = mainWindow.getSize(); const options = { width: Math.min(500, size[0]), @@ -557,7 +559,6 @@ async function showSettingsWindow() { }); settingsWindow.once('ready-to-show', () => { - addDarkOverlay(); settingsWindow.show(); }); } diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 8685df43d5..3e23840fd0 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -122,7 +122,7 @@ label { display: block; margin: 10px 0; - font-size: $font-size-small; + @include font-body-2; } .icon { diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index f29619e53d..db7b95219d 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -12,14 +12,14 @@ body { width: 100%; margin: 0; - color: $color-gray-95; + color: $color-gray-90; @include font-body-1; } body.light-theme { background-color: $color-white; - color: $color-gray-95; + color: $color-gray-90; } body.dark-theme { background-color: $color-gray-95; diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 1be7a7ccce..12cb4a7289 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -131,8 +131,7 @@ // Other @mixin popper-shadow() { - box-shadow: 0 0 0 1px $color-black-alpha-05, - 0 8px 20px 0 $color-black-alpha-20; + box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.3), 0px 0px 8px rgba(0, 0, 0, 0.05); } @mixin button-reset { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4fa3e997a3..d97055c78e 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3159,6 +3159,9 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +.module-avatar--with-click { + cursor: pointer; +} .module-avatar--no-image { @include light-theme { background-color: $color-conversation-grey; @@ -6343,7 +6346,141 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', margin-top: -1px; } -// Third-party module: react-contextmenu +// Module: Avatar Popup + +.module-avatar-popup { + min-width: 240px; + + border-radius: 4px; + padding-bottom: 4px; + + @include popper-shadow; + + @include light-theme { + color: $color-gray-90; + background-color: $color-white; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-75; + } +} + +.module-avatar-popup__profile { + display: flex; + flex-direction: row; + align-items: center; +} + +.module-avatar-popup__profile { + padding: 12px; +} + +.module-avatar-popup__profile__text { + margin-left: 10px; +} + +.module-avatar-popup__profile__name { + @include font-body-2-bold; +} +.module-avatar-popup__profile__number { + @include font-caption; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} + +.module-avatar-popup__divider { + border: none; + padding: 0; + margin: 0; + + height: 1px; + width: 100%; + margin-bottom: 6px; + + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-60; + } +} + +.module-avatar-popup__item { + @include font-body-2; + @include button-reset; + + display: flex; + flex-direction: row; + align-items: center; + + width: 100%; + height: 32px; + padding: 6px; + + @include light-theme { + &:hover { + background-color: $color-gray-05; + } + &:focus { + background-color: $color-gray-05; + } + } + @include dark-theme { + &:hover { + background-color: $color-gray-60; + } + &:focus { + background-color: $color-gray-05; + } + } +} + +.module-avatar-popup__item__icon { + margin-left: 6px; + + height: 16px; + width: 16px; +} +.module-avatar-popup__item__icon-settings { + @include light-theme { + @include color-svg( + '../images/icons/v2/settings-outline-16.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/settings-solid-16.svg', + $color-gray-15 + ); + } +} +.module-avatar-popup__item__icon-archive { + @include light-theme { + @include color-svg( + '../images/icons/v2/archive-outline-16.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/archive-solid-16.svg', + $color-gray-15 + ); + } +} + +.module-avatar-popup__item__text { + margin-left: 8px; +} + +/* Third-party module: react-contextmenu*/ .react-contextmenu { border-radius: 4px; diff --git a/stylesheets/_settings.scss b/stylesheets/_settings.scss index e55dc77885..2bee35821a 100644 --- a/stylesheets/_settings.scss +++ b/stylesheets/_settings.scss @@ -35,12 +35,12 @@ margin: 0 0 20px 20px; } .synced_at { - font-size: $font-size-small; + @include font-body-2; color: $color-gray-60; } .sync_failed { display: none; - font-size: $font-size-small; + @include font-body-2; color: $color-accent-red; } } diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index e254b16a15..f2515bbc5d 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -216,4 +216,3 @@ $color-signal-blue-tint-alpha-50: rgba($color-ios-blue-tint, 0.5); $left-pane-width: 320px; $header-height: 48px; -$font-size-small: (13/14) + em; diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index daa8d24ecf..977eee4923 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -4,16 +4,22 @@ import classNames from 'classnames'; import { getInitials } from '../util/getInitials'; import { LocalizerType } from '../types/Util'; -interface Props { +export interface Props { avatarPath?: string; color?: string; conversationType: 'group' | 'direct'; - i18n: LocalizerType; noteToSelf?: boolean; name?: string; phoneNumber?: string; profileName?: string; size: 28 | 52 | 80; + + onClick?: () => unknown; + + // Matches Popper's RefHandler type + innerRef?: (ref: HTMLElement | null) => void; + + i18n: LocalizerType; } interface State { @@ -63,9 +69,15 @@ export class Avatar extends React.Component { } public renderNoImage() { - const { conversationType, name, noteToSelf, size } = this.props; + const { + conversationType, + name, + noteToSelf, + profileName, + size, + } = this.props; - const initials = getInitials(name); + const initials = getInitials(name || profileName); const isGroup = conversationType === 'group'; if (noteToSelf) { @@ -105,7 +117,14 @@ export class Avatar extends React.Component { } public render() { - const { avatarPath, color, size, noteToSelf } = this.props; + const { + avatarPath, + color, + innerRef, + noteToSelf, + onClick, + size, + } = this.props; const { imageBroken } = this.state; const hasImage = !noteToSelf && avatarPath && !imageBroken; @@ -114,14 +133,20 @@ export class Avatar extends React.Component { throw new Error(`Size ${size} is not supported!`); } + const role = onClick ? 'button' : undefined; + return (
{hasImage ? this.renderImage() : this.renderNoImage()}
diff --git a/ts/components/AvatarPopup.md b/ts/components/AvatarPopup.md new file mode 100644 index 0000000000..72d9c96231 --- /dev/null +++ b/ts/components/AvatarPopup.md @@ -0,0 +1,48 @@ +### With avatar + +```jsx + + console.log('onViewPreferences', args)} + onViewArchive={(...args) => console.log('onViewArchive', args)} + i18n={util.i18n} + /> + +``` + +### With no avatar + +```jsx + + console.log('onViewPreferences', args)} + onViewArchive={(...args) => console.log('onViewArchive', args)} + i18n={util.i18n} + /> + +``` + +### With empty profileName + +```jsx + + console.log('onViewPreferences', args)} + onViewArchive={(...args) => console.log('onViewArchive', args)} + i18n={util.i18n} + /> + +``` diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx new file mode 100644 index 0000000000..022aa0fa6f --- /dev/null +++ b/ts/components/AvatarPopup.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { LocalizerType } from '../types/Util'; +import { Avatar, Props as AvatarProps } from './Avatar'; + +import { isEmpty } from 'lodash'; + +export type Props = { + readonly i18n: LocalizerType; + + onViewPreferences: () => unknown; + onViewArchive: () => unknown; + + // Matches Popper's RefHandler type + innerRef?: (ref: HTMLElement | null) => void; + style: React.CSSProperties; +} & AvatarProps; + +export const AvatarPopup = (props: Props) => { + const { + i18n, + profileName, + phoneNumber, + onViewPreferences, + onViewArchive, + style, + } = props; + + const hasProfileName = !isEmpty(profileName); + + return ( +
+
+ +
+
+ {hasProfileName ? profileName : phoneNumber} +
+ {hasProfileName ? ( +
+ {phoneNumber} +
+ ) : null} +
+
+
+ + +
+ ); +}; diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index d300d64937..0a72882784 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -1,8 +1,12 @@ import React from 'react'; import classNames from 'classnames'; import { debounce } from 'lodash'; +import { Manager, Popper, Reference } from 'react-popper'; +import { createPortal } from 'react-dom'; +import { showSettings } from '../shims/Whisper'; import { Avatar } from './Avatar'; +import { AvatarPopup } from './AvatarPopup'; import { LocalizerType } from '../types/Util'; export interface PropsType { @@ -42,15 +46,36 @@ export interface PropsType { clearConversationSearch: () => void; clearSearch: () => void; + + showArchivedConversations: () => void; } -export class MainHeader extends React.Component { +interface StateType { + showingAvatarPopup: boolean; + popperRoot: HTMLDivElement | null; +} + +export class MainHeader extends React.Component { private readonly inputRef: React.RefObject; constructor(props: PropsType) { super(props); this.inputRef = React.createRef(); + + this.state = { + showingAvatarPopup: false, + popperRoot: null, + }; + } + + public componentDidMount() { + const popperRoot = document.createElement('div'); + document.body.appendChild(popperRoot); + + this.setState({ + popperRoot, + }); } public componentDidUpdate(prevProps: PropsType) { @@ -65,6 +90,50 @@ export class MainHeader extends React.Component { } } + public handleOutsideClick = ({ target }: MouseEvent) => { + const { popperRoot, showingAvatarPopup } = this.state; + + if ( + showingAvatarPopup && + popperRoot && + !popperRoot.contains(target as Node) + ) { + this.hideAvatarPopup(); + } + }; + + public handleOutsideKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.hideAvatarPopup(); + } + }; + + public showAvatarPopup = () => { + this.setState({ + showingAvatarPopup: true, + }); + document.addEventListener('click', this.handleOutsideClick); + document.addEventListener('keydown', this.handleOutsideKeyUp); + }; + + public hideAvatarPopup = () => { + document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleOutsideKeyUp); + this.setState({ + showingAvatarPopup: false, + }); + }; + + public componentWillUnmount() { + const { popperRoot } = this.state; + + if (popperRoot) { + document.body.removeChild(popperRoot); + document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleOutsideKeyUp); + } + } + // tslint:disable-next-line member-ordering public search = debounce((searchTerm: string) => { const { @@ -177,6 +246,7 @@ export class MainHeader extends React.Component { } }; + // tslint:disable-next-line:max-func-body-length public render() { const { avatarPath, @@ -188,7 +258,9 @@ export class MainHeader extends React.Component { searchConversationId, searchConversationName, searchTerm, + showArchivedConversations, } = this.props; + const { showingAvatarPopup, popperRoot } = this.state; const placeholder = searchConversationName ? i18n('searchIn', [searchConversationName]) @@ -196,16 +268,53 @@ export class MainHeader extends React.Component { return (
- + + + {({ ref }) => ( + + )} + + {showingAvatarPopup && popperRoot + ? createPortal( + + {({ ref, style }) => ( + { + showSettings(); + this.hideAvatarPopup(); + }} + onViewArchive={() => { + showArchivedConversations(); + this.hideAvatarPopup(); + }} + /> + )} + , + popperRoot + ) + : null} +
{searchConversationId ? (