diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 93b696a5b..cd7c9d7d0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1868,5 +1868,17 @@ }, "learnMore": { "message": "Learn More" + }, + "unknownCountry": { + "message": "Unknown Country", + "description": "When an onion path node is unable to find the country for an IP address." + }, + "device": { + "message": "Device", + "description": "The device of the client running Session" + }, + "destination": { + "message": "Destination", + "description": "The destination that the message is being sent to" } } diff --git a/js/background.js b/js/background.js index a8506d0a6..12aa4ab98 100644 --- a/js/background.js +++ b/js/background.js @@ -13,7 +13,7 @@ */ // eslint-disable-next-line func-names -(async function() { +(async function () { 'use strict'; // Globally disable drag and drop @@ -433,7 +433,7 @@ // Ensure that this file is either small enough or is resized to meet our // requirements for attachments try { - const withBlob = await window.Signal.Util.AttachmentUtil.autoScale( + const withBlob = await winvadow.Signal.Util.AttachmentUtil.autoScale( { contentType: avatar.type, file: new Blob([data.data], { @@ -527,10 +527,232 @@ } }; + window.onProfileEditOk = async (newName, avatar) => { + let newAvatarPath = ''; + let url = null; + let profileKey = null; + if (avatar) { + const data = await readFile({ file: avatar }); + // Ensure that this file is either small enough or is resized to meet our + // requirements for attachments + try { + const withBlob = await winvadow.Signal.Util.AttachmentUtil.autoScale( + { + contentType: avatar.type, + file: new Blob([data.data], { + type: avatar.contentType, + }), + }, + { + maxSide: 640, + maxSize: 1000 * 1024, + } + ); + const dataResized = await window.Signal.Types.Attachment.arrayBufferFromFile( + withBlob.file + ); + + // For simplicity we use the same attachment pointer that would send to + // others, which means we need to wait for the database response. + // To avoid the wait, we create a temporary url for the local image + // and use it until we the the response from the server + const tempUrl = window.URL.createObjectURL(avatar); + conversation.setLokiProfile({ displayName: newName }); + conversation.set('avatar', tempUrl); + + // Encrypt with a new key every time + profileKey = libsignal.crypto.getRandomBytes(32); + const encryptedData = await textsecure.crypto.encryptProfile( + dataResized, + profileKey + ); + + const avatarPointer = await libsession.Utils.AttachmentUtils.uploadAvatarV1({ + ...dataResized, + data: encryptedData, + size: encryptedData.byteLength, + }); + + ({ url } = avatarPointer); + + storage.put('profileKey', profileKey); + + conversation.set('avatarPointer', url); + + const upgraded = await Signal.Migrations.processNewAttachment({ + isRaw: true, + data: data.data, + url, + }); + newAvatarPath = upgraded.path; + // Replace our temporary image with the attachment pointer from the server: + conversation.set('avatar', null); + conversation.setLokiProfile({ + displayName: newName, + avatar: newAvatarPath, + }); + await conversation.commit(); + window.libsession.Utils.UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await window.libsession.Utils.SyncUtils.forceSyncConfigurationNowIfNeeded(true); + } catch (error) { + window.log.error( + 'showEditProfileDialog Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + } + } else { + // do not update the avatar if it did not change + conversation.setLokiProfile({ + displayName: newName, + }); + // might be good to not trigger a sync if the name did not change + await conversation.commit(); + window.libsession.Utils.UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await window.libsession.Utils.SyncUtils.forceSyncConfigurationNowIfNeeded(true); + } + + // inform all your registered public servers + // could put load on all the servers + // if they just keep changing their names without sending messages + // so we could disable this here + // or least it enable for the quickest response + window.lokiPublicChatAPI.setProfileName(newName); + + if (avatar) { + window + .getConversationController() + .getConversations() + .filter(convo => convo.isPublic()) + .forEach(convo => convo.trigger('ourAvatarChanged', { url, profileKey })); + } + } + window.showOnionStatusDialog = () => { appView.showOnionStatusDialog(); }; + window.commitProfileEdits = async (newName, avatar) => { + const ourNumber = window.storage.get('primaryDevicePubKey'); + const conversation = await window + .getConversationController() + .getOrCreateAndWait(ourNumber, 'private'); + + const readFile = attachment => + new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = e => { + const data = e.target.result; + resolve({ + ...attachment, + data, + size: data.byteLength, + }); + }; + fileReader.onerror = reject; + fileReader.onabort = reject; + fileReader.readAsArrayBuffer(attachment.file); + }); + + // const avatarPath = conversation.getAvatarPath(); + // const profile = conversation.getLokiProfile(); + // const displayName = profile && profile.displayName; + + let newAvatarPath = ''; + let url = null; + let profileKey = null; + if (avatar) { + const data = await readFile({ file: avatar }); + // Ensure that this file is either small enough or is resized to meet our + // requirements for attachments + try { + const withBlob = await window.Signal.Util.AttachmentUtil.autoScale( + { + contentType: avatar.type, + file: new Blob([data.data], { + type: avatar.contentType, + }), + }, + { + maxSide: 640, + maxSize: 1000 * 1024, + } + ); + const dataResized = await window.Signal.Types.Attachment.arrayBufferFromFile( + withBlob.file + ); + + // For simplicity we use the same attachment pointer that would send to + // others, which means we need to wait for the database response. + // To avoid the wait, we create a temporary url for the local image + // and use it until we the the response from the server + const tempUrl = window.URL.createObjectURL(avatar); + conversation.setLokiProfile({ displayName: newName }); + conversation.set('avatar', tempUrl); + + // Encrypt with a new key every time + profileKey = libsignal.crypto.getRandomBytes(32); + const encryptedData = await textsecure.crypto.encryptProfile(dataResized, profileKey); + + const avatarPointer = await libsession.Utils.AttachmentUtils.uploadAvatarV1({ + ...dataResized, + data: encryptedData, + size: encryptedData.byteLength, + }); + + ({ url } = avatarPointer); + + storage.put('profileKey', profileKey); + + conversation.set('avatarPointer', url); + + const upgraded = await Signal.Migrations.processNewAttachment({ + isRaw: true, + data: data.data, + url, + }); + newAvatarPath = upgraded.path; + // Replace our temporary image with the attachment pointer from the server: + conversation.set('avatar', null); + conversation.setLokiProfile({ + displayName: newName, + avatar: newAvatarPath, + }); + await conversation.commit(); + window.libsession.Utils.UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await window.libsession.Utils.SyncUtils.forceSyncConfigurationNowIfNeeded(true); + } catch (error) { + window.log.error( + 'showEditProfileDialog Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + } + } else { + // do not update the avatar if it did not change + conversation.setLokiProfile({ + displayName: newName, + }); + // might be good to not trigger a sync if the name did not change + await conversation.commit(); + window.libsession.Utils.UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await window.libsession.Utils.SyncUtils.forceSyncConfigurationNowIfNeeded(true); + } + + // inform all your registered public servers + // could put load on all the servers + // if they just keep changing their names without sending messages + // so we could disable this here + // or least it enable for the quickest response + window.lokiPublicChatAPI.setProfileName(newName); + + if (avatar) { + window + .getConversationController() + .getConversations() + .filter(convo => convo.isPublic()) + .forEach(convo => convo.trigger('ourAvatarChanged', { url, profileKey })); + } + }; + // Set user's launch count. const prevLaunchCount = window.getSettingValue('launch-count'); const launchCount = !prevLaunchCount ? 1 : prevLaunchCount + 1; diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index 47864e729..323813331 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -48,7 +48,7 @@ .avatar-center-inner { display: flex; - padding-top: 30px; + // padding-top: 30px; } .upload-btn-background { diff --git a/stylesheets/_modal.scss b/stylesheets/_modal.scss index 4eb7137e8..9356a3808 100644 --- a/stylesheets/_modal.scss +++ b/stylesheets/_modal.scss @@ -235,6 +235,7 @@ border-radius: 50%; background-color: $session-color-white; transition: $session-transition-duration; + margin-top: 30px; &:hover { filter: brightness(90%); @@ -395,15 +396,6 @@ border-radius: 50%; display: inline-block; } - - // TODO: Use colors defined in UI constants - .green { - background-color: $color-loki-green; - } - - .red { - background-color: #ff453a; - } } } diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 1521ffeb3..857104625 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -756,6 +756,7 @@ label { background-color: rgba($session-color-black, 0.72); opacity: 0; transition: $session-transition-duration; + margin: 30px 0px; &:after { content: 'Edit'; @@ -1200,38 +1201,43 @@ input { // .session-onion-path-wrapper { - .onion__node-list { - display: flex; - flex-direction: column; - margin: $session-margin-sm; - align-items: flex-start; - - .onion__node { - display: flex; - flex-grow: 1; - align-items: center; - margin: $session-margin-xs; +.onion__node-list { + display: flex; + flex-direction: column; + margin: $session-margin-sm; + align-items: flex-start; - * { - margin: $session-margin-sm; - } + .onion__node { + display: flex; + flex-grow: 1; + align-items: center; + margin: $session-margin-xs; - svg { - animation: glow 1s ease-in-out infinite alternate; - } + * { + margin: $session-margin-sm; } - } - @keyframes glow { - from { - -webkit-filter: drop-shadow( 0px 0px 3px rgba(0,0,0,0)); - filter: drop-shadow( 0px 0px 3px rgba(0,0,0,0)); - } - to { - -webkit-filter: drop-shadow( 0px 0px 5px rgba(46, 247, 28, 0.7)); - filter: drop-shadow( 0px 0px 5px rgba(68, 236, 2, 0.7)); + .session-icon-button { + margin: $session-margin-sm !important; } + + // svg { + // animation: glow 1s ease-in-out infinite alternate; + // } } +} + +// @keyframes glow { +// from { +// -webkit-filter: drop-shadow( 0px 0px 3px rgba(0,0,0,0)); +// filter: drop-shadow( 0px 0px 3px rgba(0,0,0,0)); +// } +// to { +// -webkit-filter: drop-shadow( 0px 0px 5px rgba(46, 247, 28, 0.7)); +// filter: drop-shadow( 0px 0px 5px rgba(68, 236, 2, 0.7)); +// color: red; +// } +// } // } diff --git a/stylesheets/themes.scss b/stylesheets/themes.scss index 2ff4d278e..bd346b42b 100644 --- a/stylesheets/themes.scss +++ b/stylesheets/themes.scss @@ -3,6 +3,7 @@ $white: #ffffff; $black: #000000; $destructive: #ff453a; +$warning: #e7b100; $accentLightTheme: #00e97b; $accentDarkTheme: #00f782; @@ -19,6 +20,7 @@ $themes: ( accent: $accentLightTheme, accentButton: $black, cellBackground: #fcfcfc, + warning: $warning, destructive: $destructive, modalBackground: #fcfcfc, fakeChatBubbleBackground: #f5f5f5, @@ -68,6 +70,7 @@ $themes: ( dark: ( accent: $accentDarkTheme, accentButton: $accentDarkTheme, + warning: $warning, destructive: $destructive, cellBackground: #1b1b1b, modalBackground: #101011, diff --git a/ts/components/EditProfileDialog.tsx b/ts/components/EditProfileDialog.tsx index c2f623d5d..eeeb2b100 100644 --- a/ts/components/EditProfileDialog.tsx +++ b/ts/components/EditProfileDialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { QRCode } from 'react-qr-svg'; @@ -10,17 +10,24 @@ import { SessionIconButton, SessionIconSize, SessionIconType } from './session/i import { SessionModal } from './session/SessionModal'; import { PillDivider } from './session/PillDivider'; import { ToastUtils, UserUtils } from '../session/utils'; -import { DefaultTheme } from 'styled-components'; +import { DefaultTheme, useTheme } from 'styled-components'; import { MAX_USERNAME_LENGTH } from './session/registration/RegistrationTabs'; import { SessionSpinner } from './session/SessionSpinner'; +import { ConversationTypeEnum } from '../models/conversation'; + +import { ConversationController } from '../session/conversations'; +import { useSelector } from 'react-redux'; +import { getOurNumber } from '../state/selectors/user'; +import { SessionWrapperModal } from './session/SessionWrapperModal'; +import { times } from 'underscore'; interface Props { - i18n: any; - profileName: string; - avatarPath: string; - pubkey: string; - onClose: any; - onOk: any; + i18n?: any; + profileName?: string; + avatarPath?: string; + pubkey?: string; + onClose?: any; + onOk?: any; theme: DefaultTheme; } @@ -46,9 +53,9 @@ export class EditProfileDialog extends React.Component { this.fireInputEvent = this.fireInputEvent.bind(this); this.state = { - profileName: this.props.profileName, - setProfileName: this.props.profileName, - avatar: this.props.avatarPath, + profileName: this.props.profileName || '', + setProfileName: this.props.profileName || '', + avatar: this.props.avatarPath || '', mode: 'default', loading: false, }; @@ -58,8 +65,44 @@ export class EditProfileDialog extends React.Component { window.addEventListener('keyup', this.onKeyUp); } + async componentDidMount() { + const ourNumber = window.storage.get('primaryDevicePubKey'); + const conversation = await window + .getConversationController() + .getOrCreateAndWait(ourNumber, ConversationTypeEnum.PRIVATE); + + const readFile = (attachment: any) => + new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = (e: any) => { + const data = e.target.result; + resolve({ + ...attachment, + data, + size: data.byteLength, + }); + }; + fileReader.onerror = reject; + fileReader.onabort = reject; + fileReader.readAsArrayBuffer(attachment.file); + }); + + const avatarPath = conversation.getAvatarPath(); + const profile = conversation.getLokiProfile(); + const displayName = profile && profile.displayName; + + this.setState({ + ...this.state, + profileName: profile.profileName, + avatar: avatarPath || '', + setProfileName: profile.profileName + }) + + } + public render() { - const i18n = this.props.i18n; + // const i18n = this.props.i18n; + const i18n = window.i18n; const viewDefault = this.state.mode === 'default'; const viewEdit = this.state.mode === 'edit'; @@ -70,61 +113,67 @@ export class EditProfileDialog extends React.Component { const backButton = viewEdit || viewQR ? [ - { - iconType: SessionIconType.Chevron, - iconRotation: 90, - onClick: () => { - this.setState({ mode: 'default' }); - }, + { + iconType: SessionIconType.Chevron, + iconRotation: 90, + onClick: () => { + this.setState({ mode: 'default' }); }, - ] + }, + ] : undefined; return ( - -
- - {viewQR && this.renderQRView(sessionID)} - {viewDefault && this.renderDefaultView()} - {viewEdit && this.renderEditView()} - -
- -

{sessionID}

- -
- - - {viewDefault || viewQR ? ( - { - this.copySessionID(sessionID); - }} - /> - ) : ( - !this.state.loading && ( + // +
+ + +
+ + {viewQR && this.renderQRView(sessionID)} + {viewDefault && this.renderDefaultView()} + {viewEdit && this.renderEditView()} + +
+ +

{sessionID}

+ +
+ + + {viewDefault || viewQR ? ( { + this.copySessionID(sessionID); + }} /> - ) - )} - -
-
- + ) : ( + !this.state.loading && ( + + ) + )} + +
+
+ {/* */} + +
); } @@ -280,9 +329,9 @@ export class EditProfileDialog extends React.Component { const avatar = this.inputEl && - this.inputEl.current && - this.inputEl.current.files && - this.inputEl.current.files.length > 0 + this.inputEl.current && + this.inputEl.current.files && + this.inputEl.current.files.length > 0 ? this.inputEl.current.files[0] : null; @@ -291,7 +340,8 @@ export class EditProfileDialog extends React.Component { loading: true, }, async () => { - await this.props.onOk(newName, avatar); + // await this.props.onOk(newName, avatar); + await window.commitProfileEdits(newName, avatar); this.setState({ loading: false, diff --git a/ts/components/OnionStatusDialog.tsx b/ts/components/OnionStatusDialog.tsx index 3daf9f4f8..4bb4ec19e 100644 --- a/ts/components/OnionStatusDialog.tsx +++ b/ts/components/OnionStatusDialog.tsx @@ -1,10 +1,4 @@ -import React, { useState, useEffect } from 'react'; -import { SessionButton, SessionButtonColor, SessionButtonType } from './session/SessionButton'; -import { SessionModal } from './session/SessionModal'; -import { DefaultTheme } from 'styled-components'; -import { getPathNodesIPAddresses } from '../session/onions/onionSend'; -import { useInterval } from '../hooks/useInterval'; -import classNames from 'classnames'; +import React from 'react'; import _ from 'lodash'; @@ -13,206 +7,136 @@ import { getTheme } from '../state/selectors/theme'; import electron from 'electron'; import { useSelector } from 'react-redux'; import { StateType } from '../state/reducer'; -import { SessionIconButton, SessionIconSize, SessionIconType } from './session/icon'; -import { Constants } from '../session'; +import { SessionIcon, SessionIconSize, SessionIconType } from './session/icon'; const { shell } = electron; import { SessionWrapperModal } from '../components/session/SessionWrapperModal'; import { Snode } from '../session/onions'; -import ip2country from "ip2country"; -import countryLookup from "country-code-lookup"; - -// import ipLocation = require('ip-location'); - - -// interface OnionStatusDialogProps { -// theme: DefaultTheme; -// nodes?: Array; -// onClose: any; -// } - -// export interface IPathNode { -// ip?: string; -// label: string; -// } - -// export const OnionPath = (props: { nodes: IPathNode[]; hasPath: boolean }) => { -// const { nodes, hasPath } = props; - -// return ( -//
-// {nodes.map(node => { -// return OnionPathNode({ hasPath, node }); -// })} -//
-// ); -// }; - -// export const OnionStatusDialog = (props: OnionStatusDialogProps) => { -// const { theme, onClose } = props; - -// const [onionPathAddresses, setOnionPathAddresses] = useState([]); -// const [pathNodes, setPathNodes] = useState([]); -// const [hasPath, setHasPath] = useState(false); - -// const getOnionPathAddresses = () => { -// const onionPathAddresses = getPathNodesIPAddresses(); -// console.log('Current Onion Path - ', onionPathAddresses); -// setOnionPathAddresses(onionPathAddresses); -// }; - -// const buildOnionPath = () => { -// // TODO: Add i18n to onion path -// // Default path values -// let path = [ -// { -// label: 'You', -// }, -// { -// ip: 'Connecting...', -// label: 'Entry Node', -// }, -// { -// ip: 'Connecting...', -// label: 'Service Node', -// }, -// { -// ip: 'Connecting...', -// label: 'Service Node', -// }, -// { -// label: 'Destination', -// }, -// ]; - -// // FIXME call function to check if an onion path exists -// setHasPath(onionPathAddresses.length !== 0); - -// // if there is a onion path, update the addresses -// if (onionPathAddresses.length) { -// onionPathAddresses.forEach((ipAddress, index) => { -// const pathIndex = index + 1; -// path[pathIndex].ip = ipAddress; -// }); -// } -// setPathNodes(path); -// }; - -// useInterval(() => { -// getOnionPathAddresses(); -// }, 1000); - -// useEffect(() => { -// buildOnionPath(); -// }, [onionPathAddresses]); - -// const openFAQPage = () => { -// console.log('Opening FAQ Page'); -// shell.openExternal('https://getsession.org/faq/#onion-routing'); -// }; - -// return ( -// -//
-//
-//

{window.i18n('onionPathIndicatorDescription')}

-//
- -// - -// -// -// ); -// }; +import ip2country from 'ip2country'; +import countryLookup from 'country-code-lookup'; +import { useTheme } from 'styled-components'; + +export type OnionPathModalType = { + onConfirm?: () => void; + onClose?: () => void; + confirmText?: string; + cancelText?: string; + title?: string; +}; + +export type StatusLightType = { + glowStartDelay: number; + glowDuration: number; + color?: string; +}; const OnionPathModalInner = (props: any) => { - const onionPath = useSelector((state: StateType) => state.onionPaths.snodePath); + const onionNodes = useSelector((state: StateType) => state.onionPaths.snodePath); + const onionPath = onionNodes.path; + // including the device and destination in calculation + const glowDuration = onionPath.length + 2; + + const nodes = [ + { + label: window.i18n('device'), + }, + ...onionNodes.path, + , + { + label: window.i18n('destination'), + }, + ]; - return (
- {onionPath.path.map((snode: Snode, index: number) => { + {nodes.map((snode: Snode | any, index: number) => { return ( <> - + ); })} - {/* TODO: Destination node maybe pass in light colour maybe changes based on if 3 nodes are connected similar to the action panel light? */} -
); }; +export type OnionNodeStatusLightType = { + snode: Snode; + label?: string; + glowStartDelay: number; + glowDuration: number; +}; /** * Component containing a coloured status light and an adjacent country label. - * @param props - * @returns + * @param props + * @returns */ -export const LabelledStatusLight = (props: any): JSX.Element => { - let { snode, label } = props; +export const OnionNodeStatusLight = (props: OnionNodeStatusLightType): JSX.Element => { + const { snode, label, glowStartDelay, glowDuration } = props; + const theme = useTheme(); let labelText = label ? label : countryLookup.byIso(ip2country(snode.ip))?.country; - console.log('@@@@ country data:: ', labelText); if (!labelText) { - labelText = `${snode.ip} - Destination unknown`; - console.log(`@@@@ country data failure on code:: ${ip2country(snode.ip)} and ip ${snode.ip}`); + labelText = window.i18n('unknownCountry'); } return (
- - {labelText ? + + {labelText ? ( <>
{labelText}
- : - null - } + ) : null}
- ) -} - -export const OnionNodeLight = (props: any) => { - -} + ); +}; -export const StatusLight = (props: any) => { - const [showModal, toggleShowModal] = useState(false); - const { isSelected, color } = props; +/** + * An icon with a pulsating glow emission. + */ +export const StatusLight = (props: StatusLightType) => { + const { glowStartDelay, glowDuration, color } = props; const theme = useSelector(getTheme); - const openFAQPage = () => { - console.log('Opening FAQ Page'); - shell.openExternal('https://getsession.org/faq/#onion-routing'); - }; - - const onClick = () => { - toggleShowModal(!showModal); - }; - return ( <> - - - {showModal ? ( - - - - ) : null} ); }; + +export const OnionPathModal = (props: OnionPathModalType) => { + const onConfirm = () => { + shell.openExternal('https://getsession.org/faq/#onion-routing'); + }; + return ( + + + + ); +}; diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index ee53e66f1..0cf7c0416 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -36,10 +36,12 @@ import { forceRefreshRandomSnodePool } from '../../session/snode_api/snodePool'; import { SwarmPolling } from '../../session/snode_api/swarmPolling'; import { getOnionPathStatus } from '../../session/onions/onionSend'; import { Constants } from '../../session'; -import { StatusLight } from '../OnionStatusDialog'; import { StateType } from '../../state/reducer'; import _ from 'lodash'; import { useNetwork } from '../../hooks/useNetwork'; +import { OnionPathModal } from '../OnionStatusDialog'; +import { EditProfileDialog } from '../EditProfileDialog'; +import { useTheme } from 'styled-components'; // tslint:disable-next-line: no-import-side-effect no-submodule-imports @@ -53,20 +55,33 @@ export enum SectionType { PathIndicator, } -const Section = (props: { type: SectionType; avatarPath?: string; hasOnionPath?: boolean }) => { +const Section = (props: { + setModal?: any; + type: SectionType; + avatarPath?: string; + hasOnionPath?: boolean; +}) => { const ourNumber = useSelector(getOurNumber); const unreadMessageCount = useSelector(getUnreadMessageCount); const theme = useSelector(getTheme); const dispatch = useDispatch(); - const { type, avatarPath, hasOnionPath } = props; + const { setModal, type, avatarPath, hasOnionPath } = props; const focusedSection = useSelector(getFocusedSection); const isSelected = focusedSection === props.type; + + const handleModalClose = () => { + setModal(null); + } + const handleClick = () => { /* tslint:disable:no-void-expression */ if (type === SectionType.Profile) { - window.showEditProfileDialog(); + // window.showEditProfileDialog(); + + // setModal( setModal(null)}>); + setModal(); } else if (type === SectionType.Moon) { const themeFromSettings = window.Events.getThemeSetting(); const updatedTheme = themeFromSettings === 'dark' ? 'light' : 'dark'; @@ -76,7 +91,7 @@ const Section = (props: { type: SectionType; avatarPath?: string; hasOnionPath?: dispatch(applyTheme(newThemeObject)); } else if (type === SectionType.PathIndicator) { // Show Path Indicator Modal - window.showOnionStatusDialog(); + setModal(); } else { dispatch(clearSearch()); dispatch(showLeftPaneSection(type)); @@ -102,7 +117,24 @@ const Section = (props: { type: SectionType; avatarPath?: string; hasOnionPath?: let iconColor = undefined; if (type === SectionType.PathIndicator) { // Set icon color based on result - iconColor = hasOnionPath ? Constants.UI.COLORS.GREEN : Constants.UI.COLORS.DANGER; + const red = theme.colors.destructive; + const green = theme.colors.accent; + const orange = theme.colors.warning; + + iconColor = hasOnionPath ? theme.colors.accent : theme.colors.destructive; + const onionState = useSelector((state: StateType) => state.onionPaths); + + iconColor = red; + const isOnline = useNetwork(); + if (!(onionState && onionState.snodePath) || !isOnline) { + iconColor = Constants.UI.COLORS.DANGER; + } else { + const onionSnodePath = onionState.snodePath; + if (onionState && onionSnodePath && onionSnodePath.path.length > 0) { + let onionNodeCount = onionSnodePath.path.length; + iconColor = onionNodeCount > 2 ? green : onionNodeCount > 1 ? orange : red; + } + } } const unreadToShow = type === SectionType.Message ? unreadMessageCount : undefined; @@ -128,45 +160,18 @@ const Section = (props: { type: SectionType; avatarPath?: string; hasOnionPath?: iconType = SessionIconType.Moon; } - // calculate light status. - // TODO: Refactor this so this logic is reusable elsewhere. - - - // TEST: - if (type === SectionType.PathIndicator) { - const onionState = useSelector((state: StateType) => state.onionPaths); - - let statusColor = Constants.UI.COLORS.DANGER; - const isOnline = useNetwork(); - if (!(onionState && onionState.snodePath) || !isOnline) { - return ; - } else { - - const onionSnodePath = onionState.snodePath; - if (onionState && onionSnodePath && onionSnodePath.path.length > 0) { - let onionNodeCount = onionSnodePath.path.length; - statusColor = - onionNodeCount > 2 - ? Constants.UI.COLORS.GREEN - : onionNodeCount > 1 - ? Constants.UI.COLORS.WARNING - : Constants.UI.COLORS.DANGER; - } - } - - return ; - } - return ( - + <> + + ); }; @@ -243,6 +248,8 @@ export const ActionsPanel = () => { const [hasOnionPath, setHasOnionPath] = useState(false); const ourPrimaryConversation = useSelector(getOurPrimaryConversation); + const [modal, setModal] = useState(null); + // this maxi useEffect is called only once: when the component is mounted. // For the action panel, it means this is called only one per app start/with a user loggedin useEffect(() => { @@ -259,42 +266,6 @@ export const ActionsPanel = () => { const getOnionPathIndicator = () => { const hasOnionPath = getOnionPathStatus(); - - // const update: OnionUpdate = { - // nodes: [ - // { - // ip: 'hi', - // label: 'hi', - // isConnected: Math.random() > 0.5, - // isAttemptingConnect: Math.random() > 0.7, - // }, - // { - // ip: 'hi2', - // label: 'hi2', - // isConnected: Math.random() > 0.5, - // isAttemptingConnect: Math.random() > 0.7, - // }, - // { - // ip: 'hi3', - // label: 'hi3', - // isConnected: Math.random() > 0.5, - // isAttemptingConnect: Math.random() > 0.7, - // }, - // ], - // }; - - - // dispatch(updateOnionPaths(update)); - - // TEST: Stuff - // let testNode: SnodePath = { - // bad: false, - // path: new Array() - // } - - // dispatch(updateOnionPaths(testNode)); - - // console.log('Is Onion Path found -', hasOnionPath); setHasOnionPath(hasOnionPath); }; @@ -324,15 +295,20 @@ export const ActionsPanel = () => { return (
-
-
-
-
+
+
+
+
+ {modal ? modal : null} -
-
+
+
); }; diff --git a/ts/components/session/SessionWrapperModal.tsx b/ts/components/session/SessionWrapperModal.tsx index 7f00c71d8..623f8c592 100644 --- a/ts/components/session/SessionWrapperModal.tsx +++ b/ts/components/session/SessionWrapperModal.tsx @@ -5,7 +5,7 @@ import { SessionIconButton, SessionIconSize, SessionIconType } from './icon/'; import { SessionButton, SessionButtonColor, SessionButtonType } from './SessionButton'; import { DefaultTheme } from 'styled-components'; -import { useKeyPress } from "use-hooks"; +import { useKeyPress } from 'use-hooks'; interface Props { title: string; @@ -28,44 +28,96 @@ interface Props { theme: DefaultTheme; } -interface State { - isVisible: boolean; -} +export type SessionWrapperModalType = { + title?: string; + onConfirm?: () => void; + onClose?: () => void; + showClose?: boolean + confirmText?: string; + cancelText?: string; + showExitIcon?: boolean; + theme?: any; + headerIconButtons?: any[]; + children: any; +}; -export const SessionWrapperModal = (props: any) => { - const { onclick, showModal, title, onConfirm } = props; +export const SessionWrapperModal = (props: SessionWrapperModalType) => { + const { + title, + onConfirm, + onClose, + showClose = false, + confirmText, + cancelText, + showExitIcon, + theme, + headerIconButtons, + } = props; useEffect(() => { window.addEventListener('keyup', upHandler); return () => { window.removeEventListener('keyup', upHandler); - } - }, []) + }; + }, []); // TODO: warrick: typing - const upHandler = ({key}: any ) => { + const upHandler = ({ key }: any) => { if (key === 'Escape') { - props.onclick(); + if (props.onClose) { + props.onClose(); + } } - } - + }; return (
-
- {/* Onion Nodes / Generic Title {title} */} - {title} +
+ {showExitIcon ? ( + + ) : null} +
+
{title}
+
+ {headerIconButtons + ? headerIconButtons.map((iconItem: any) => { + return ( + + ); + }) + : null}
{props.children}
- Close + {onConfirm ? ( + + {confirmText || window.i18n('ok')} + + ) : null} + {onClose && showClose ? ( + + {cancelText || window.i18n('close')} + + ) : null}
@@ -74,110 +126,3 @@ export const SessionWrapperModal = (props: any) => {
); }; - -// export class SessionModal extends React.PureComponent { -// public static defaultProps = { -// showExitIcon: true, -// showHeader: true, -// headerReverse: false, -// }; - -// private node: HTMLDivElement | null; - -// constructor(props: any) { -// super(props); -// this.state = { -// isVisible: true, -// }; - -// this.close = this.close.bind(this); -// this.onKeyUp = this.onKeyUp.bind(this); -// this.node = null; - -// window.addEventListener('keyup', this.onKeyUp); -// } - -// public componentDidMount() { -// document.addEventListener('mousedown', this.handleClick, false); -// } - -// public componentWillUnmount() { -// document.removeEventListener('mousedown', this.handleClick, false); -// } - -// public handleClick = (e: any) => { -// if (this.node && this.node.contains(e.target)) { -// return; -// } - -// this.close(); -// }; - -// public render() { -// const { title, headerIconButtons, showExitIcon, showHeader, headerReverse } = this.props; -// const { isVisible } = this.state; - -// return isVisible ? ( -//
(this.node = node)} className={'session-modal'}> -// {showHeader ? ( -// <> -//
-//
-// {showExitIcon ? ( -// -// ) : null} -//
-//
{title}
-//
-// {headerIconButtons -// ? headerIconButtons.map((iconItem: any) => { -// return ( -// -// ); -// }) -// : null} -//
-//
-// -// ) : null} - -//
{this.props.children}
-//
-// ) : null; -// } - -// public close() { -// this.setState({ -// isVisible: false, -// }); - -// window.removeEventListener('keyup', this.onKeyUp); -// document.removeEventListener('mousedown', this.handleClick, false); - -// if (this.props.onClose) { -// this.props.onClose(); -// } -// } - -// public onKeyUp(event: any) { -// switch (event.key) { -// case 'Esc': -// case 'Escape': -// this.close(); -// break; -// default: -// } -// } -// } diff --git a/ts/components/session/icon/SessionIcon.tsx b/ts/components/session/icon/SessionIcon.tsx index 85afa1961..8a02123c7 100644 --- a/ts/components/session/icon/SessionIcon.tsx +++ b/ts/components/session/icon/SessionIcon.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { icons, SessionIconSize, SessionIconType } from '../icon'; import styled, { css, DefaultTheme, keyframes } from 'styled-components'; +import { drop } from 'lodash'; export type SessionIconProps = { iconType: SessionIconType; @@ -9,6 +10,9 @@ export type SessionIconProps = { iconColor?: string; iconRotation?: number; rotateDuration?: number; + glowDuration?: number; + borderRadius?: number; + glowStartDelay?: number; theme: DefaultTheme; }; @@ -40,6 +44,10 @@ type StyledSvgProps = { height: string | number; iconRotation: number; rotateDuration?: number; + borderRadius?: number; + glowDuration?: number; + glowStartDelay?: number; + iconColor?: string; }; const rotate = keyframes` @@ -51,21 +59,59 @@ const rotate = keyframes` } `; +/** + * Creates a glow animation made for multiple element sequentially + * @param color + * @param glowDuration + * @param glowStartDelay + * @returns + */ +const glow = (color: string, glowDuration: number, glowStartDelay: number) => { + let dropShadowType = `drop-shadow(0px 0px 6px ${color}) `; + //increase shadow intensity by 3 + let dropShadow = `${dropShadowType.repeat(2)};`; + + // TODO: Decrease dropshadow for last frame + // creating keyframe for sequential animations + let kf = ''; + for (let i = 0; i <= glowDuration; i++) { + // const percent = (100 / glowDuration) * i; + const percent = (100 / glowDuration) * i; + if (i === glowStartDelay) { + kf += `${percent}% { + filter: ${dropShadow} + }`; + } else { + kf += `${percent}% { + filter: none; + }`; + } + } + return keyframes`${kf}`; +}; + const animation = (props: any) => { if (props.rotateDuration) { return css` ${rotate} ${props.rotateDuration}s infinite linear; `; + } else if (props.glowDuration !== undefined && props.glowStartDelay !== undefined) { + return css` + ${glow(props.iconColor, props.glowDuration, props.glowStartDelay)} ${2}s ease-in infinite; + `; } else { return; } }; +// ${glow(props.iconColor, props.glowDuration, props.glowStartDelay)} ${props.glowDuration}s ease-in ${ props.glowStartDelay }s infinite alternate; + //tslint:disable no-unnecessary-callback-wrapper const Svg = styled.svg` width: ${props => props.width}; animation: ${props => animation(props)}; transform: ${props => `rotate(${props.iconRotation}deg)`}; + border-radius: ${props => props.borderRadius}; `; //tslint:enable no-unnecessary-callback-wrapper @@ -77,6 +123,9 @@ const SessionSvg = (props: { iconRotation: number; iconColor?: string; rotateDuration?: number; + glowDuration?: number; + glowStartDelay?: number; + borderRadius?: number; theme: DefaultTheme; }) => { const colorSvg = props.iconColor || props?.theme?.colors.textColor; @@ -84,6 +133,15 @@ const SessionSvg = (props: { return ( + {/* { props.glowDuration ? + + + + + + : + null + } */} {pathArray.map((path, index) => { return ; })} @@ -92,7 +150,15 @@ const SessionSvg = (props: { }; export const SessionIcon = (props: SessionIconProps) => { - const { iconType, iconColor, theme, rotateDuration } = props; + const { + iconType, + iconColor, + theme, + rotateDuration, + glowDuration, + borderRadius, + glowStartDelay, + } = props; let { iconSize, iconRotation } = props; iconSize = iconSize || SessionIconSize.Medium; iconRotation = iconRotation || 0; @@ -111,6 +177,9 @@ export const SessionIcon = (props: SessionIconProps) => { width={iconDimensions * ratio} height={iconDimensions} rotateDuration={rotateDuration} + glowDuration={glowDuration} + glowStartDelay={glowStartDelay} + borderRadius={borderRadius} iconRotation={iconRotation} iconColor={iconColor} theme={theme} diff --git a/ts/ip2country.d.ts b/ts/ip2country.d.ts index 97eb6b44a..6c4567906 100644 --- a/ts/ip2country.d.ts +++ b/ts/ip2country.d.ts @@ -1 +1 @@ -declare module 'ip2country'; \ No newline at end of file +declare module 'ip2country'; diff --git a/ts/session/onions/index.ts b/ts/session/onions/index.ts index 7c82b5401..96d5603fe 100644 --- a/ts/session/onions/index.ts +++ b/ts/session/onions/index.ts @@ -63,7 +63,11 @@ export class OnionPaths { goodPaths = this.onionPaths.filter(x => !x.bad); } - window.inboxStore?.dispatch(updateOnionPaths(goodPaths[0])); + if (goodPaths.length <= 0) { + window.inboxStore?.dispatch(updateOnionPaths({ path: new Array(), bad: true })); + } else { + window.inboxStore?.dispatch(updateOnionPaths(goodPaths[0])); + } const paths = _.shuffle(goodPaths); @@ -105,8 +109,6 @@ export class OnionPaths { log.error('LokiSnodeAPI::getOnionPath - otherPaths no path in', otherPaths[0]); } - // window.inboxStore?.dispatch(updateOnionPaths(otherPaths[0])); - return otherPaths[0].path; } diff --git a/ts/state/ducks/SessionTheme.tsx b/ts/state/ducks/SessionTheme.tsx index f045df4ea..3432efc28 100644 --- a/ts/state/ducks/SessionTheme.tsx +++ b/ts/state/ducks/SessionTheme.tsx @@ -3,9 +3,11 @@ import React from 'react'; // import 'reset-css/reset.css'; import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { pushToastWarning } from '../../session/utils/Toast'; const white = '#ffffff'; const black = '#000000'; +const warning = '#e7b100'; const destructive = '#ff453a'; const accentLightTheme = '#00e97b'; const accentDarkTheme = '#00f782'; @@ -40,6 +42,7 @@ export const lightTheme: DefaultTheme = { colors: { accent: accentLightTheme, accentButton: black, + warning: warning, destructive: destructive, cellBackground: '#fcfcfc', modalBackground: '#fcfcfc', @@ -95,6 +98,7 @@ export const darkTheme = { colors: { accent: accentDarkTheme, accentButton: accentDarkTheme, + warning: warning, destructive: destructive, cellBackground: '#1b1b1b', modalBackground: '#101011', diff --git a/ts/state/ducks/onion.tsx b/ts/state/ducks/onion.tsx index 4db436752..0c3a99a3b 100644 --- a/ts/state/ducks/onion.tsx +++ b/ts/state/ducks/onion.tsx @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { SnodePath, Snode } from "../../session/onions/index"; +import _, { forEach } from 'underscore'; +import { SnodePath, Snode } from '../../session/onions/index'; export type OnionState = { // nodes: Array; @@ -15,9 +16,9 @@ export type OnionState = { const initialState = { snodePath: { path: new Array(), - bad: false - } -} + bad: false, + }, +}; /** * This slice is the one holding the default joinable rooms fetched once in a while from the default opengroup v2 server. @@ -26,11 +27,10 @@ const onionSlice = createSlice({ name: 'onionPaths', initialState, reducers: { - // updateOnionPaths(state, action: PayloadAction) { updateOnionPaths(state, action: PayloadAction) { - return { - snodePath: action.payload - } + let newPayload = { snodePath: action.payload }; + let isEqual = JSON.stringify(state, null, 2) == JSON.stringify(newPayload, null, 2); + return isEqual ? state : newPayload; }, }, }); diff --git a/ts/styled.d.ts b/ts/styled.d.ts index afd7d30bb..905375e34 100644 --- a/ts/styled.d.ts +++ b/ts/styled.d.ts @@ -26,6 +26,7 @@ declare module 'styled-components' { colors: { accent: string; accentButton: string; + warning: string; destructive: string; cellBackground: string; modalBackground: string; diff --git a/ts/window.d.ts b/ts/window.d.ts index 5eb9706cf..b7c282656 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -15,8 +15,9 @@ import { Store } from 'redux'; import { MessageController } from './session/messages/MessageController'; import { DefaultTheme } from 'styled-components'; -import { ConversationCollection } from './models/conversation'; +import { ConversationCollection, ConversationModel } from './models/conversation'; import { ConversationType } from './state/ducks/conversations'; +import { ConversationController } from './session/conversations'; /* We declare window stuff here instead of global.d.ts because we are importing other declarations. @@ -99,5 +100,10 @@ declare global { darkTheme: DefaultTheme; LokiPushNotificationServer: any; LokiPushNotificationServerApi: any; + + getConversationController: () => ConversationController; + getOrCreateAndWait: () => Promise; + + commitProfileEdits: (string, string) => void; } }