diff --git a/.gitignore b/.gitignore index b71c22740..78740c3ba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* +git-deps +git-install-lock + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/Dockerfile b/Dockerfile index 54bd8da88..4aa4ac712 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /var/app COPY . /var/app RUN yarn install RUN yarn build +RUN node git-install.js -c FROM node:16.1-alpine diff --git a/config-overrides.js b/config-overrides.js index 6f7f062b2..bf5136b12 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -33,6 +33,7 @@ module.exports = function override(config, env) { 'process.env.IS_APP': JSON.stringify(!!process.env.IS_APP), 'process.env.DESKTOP_APP': JSON.stringify(!!process.env.DESKTOP_APP), 'process.env.MOBILE_APP': JSON.stringify(!!process.env.MOBILE_APP), + //'process.env.NO_NOTIFY': JSON.stringify(true), }), ) diff --git a/config/mobile.json b/config/mobile.json index 332dc1e3e..6168e18d2 100644 --- a/config/mobile.json +++ b/config/mobile.json @@ -17,7 +17,8 @@ "custom_client": "blogs" }, "notify_service": { - "host": "https://notify.golos.app" + "host": "https://notify.golos.app", + "host_ws": "wss://notify.golos.app/ws" }, "blogs_service": { "host": "https://golos.id" diff --git a/git-deps/.gitkeep b/git-deps/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/git-install.js b/git-install.js new file mode 100644 index 000000000..cec1ab52d --- /dev/null +++ b/git-install.js @@ -0,0 +1,125 @@ +const fs = require('fs') +const util = require('util') +const exec = util.promisify(require('child_process').exec) + +const package = require('./package.json') + +const { dependencies, gitDependencies } = package + +const dir = 'git-deps' + +let clc = {} +for (const [key, code] of Object.entries({ + cyanBright: '\x1b[36m', + yellowBright: '\x1b[33m', + greenBright: '\x1b[32m', +})) { + clc[key] = (...args) => { + return code + [...args].join(' ') + '\x1b[0m' + } +} + +const logDep = (color, ...args) => { + let msg = [' -', ...args] + if (color) { + msg = [clc[color](...msg)] + } + console.log(...msg) +} + +async function main() { + const { argv } = process + if (argv[2] === '-c' || argv[2] === '--cleanup') { + const files = await fs.promises.readdir(dir) + for (const f of files) { + if (f !== '.gitignore') { + fs.rmSync(dir + '/' + f, { recursive: true, force: true }) + } + } + return + } + + if (!gitDependencies) { + console.log('No gitDependencies in package.json, so nothing to preinstall.') + return + } + + const lockFile = 'git-install-lock' + if (fs.existsSync(lockFile)) { + console.error(lockFile, 'exists, so cannot run. It is for recursion protection.') + return + } + fs.writeFileSync(lockFile, '1') + + console.log(clc.cyanBright('preinstalling deps...')) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir) + } + process.chdir(dir) + + const gitDeps = Object.entries(gitDependencies) + const deps = {} + for (const [ key, dep ] of gitDeps) { + const url = new URL(dep) + + const { pathname } = url + const [ repo, branchFolder ] = pathname.split('/tree/') + let branch = '', folder = '' + if (branchFolder) { + const parts = branchFolder.split('/') + branch = ' -b ' + parts[0] + if (parts[1]) { + parts.shift() + folder = parts.join('/') + } + } + + const commit = url.hash.replace('#', '') + const repoName = repo.split('/')[2] + + deps[key] = { dep, repo, branch, folder, repoName, commit } + + if (fs.existsSync(repoName)) { + logDep('yellowBright', repoName, 'already cloned, using as cache.') + continue + } + + deps[key].cloned = true + + const clone = 'git clone ' + url.origin + repo + branch + logDep('greenBright', clone) + await exec(clone) + + if (commit) { + process.chdir(repoName) + const resetTo = 'git reset --hard ' + commit + logDep(null, '-', resetTo) + await exec(resetTo) + process.chdir('..') + } + } + + console.log(' ') + console.log(clc.cyanBright('yarn-adding cloned deps (if not added)...')) + + for (const [ key, dep ] of Object.entries(deps)) { + let path = './' + dir + '/' + dep.repoName + if (dep.folder) { + path += '/' + dep.folder + } + + if (argv[2] === '-f' || dependencies[key] !== path) { + const add = 'yarn add ' + path + logDep('greenBright', add) + await exec(add) + } + } + + console.log(clc.greenBright('ok')) + console.log('') + + fs.unlinkSync('../' + lockFile) +} + +main() diff --git a/package.json b/package.json index c370f6e81..347170cb5 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "connected-react-router": "^6.9.2", "counterpart": "^0.18.6", "emoji-picker-element": "^1.10.1", - "formik": "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465", + "formik": "./git-deps/formik/packages/formik", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.34", + "golos-lib-js": "^0.9.76", "history": "4.10.1", "immutable": "^4.0.0", "koa": "^2.13.4", @@ -32,18 +32,20 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-dom-confetti": "^0.2.0", - "react-dropzone": "^12.0.4", - "react-foundation-components": "git+https://github.com/golos-blockchain/react-foundation-components.git#6606fd5529f1ccbc77cd8d33a8ce139fdf8f9a11", + "react-dropzone": "^14.2.3", + "react-foundation-components": "./git-deps/react-foundation-components", "react-intl": "^5.24.6", "react-notification": "^6.8.5", "react-redux": "^7.2.6", "react-router-dom": "^5.3.0", + "react-select": "^5.8.0", "react-textarea-autosize": "^8.3.3", "redux": "^4.1.2", "redux-logger": "^3.0.6", "redux-modules": "0.0.5", "redux-saga": "^1.1.3", - "sass": "^1.49.7" + "sass": "^1.49.7", + "speakingurl": "^14.0.1" }, "devDependencies": { "@red-mobile/cordova-plugin-shortcuts-android": "^1.0.1", @@ -64,9 +66,14 @@ "react-app-rewired": "^2.1.11", "react-scripts": "^5.0.0" }, + "gitDependencies": { + "formik": "https://github.com/golos-blockchain/formik/tree/master/packages/formik#3b21166c33ade760d562091e1fa0b71d172a7aaf", + "react-foundation-components": "https://github.com/golos-blockchain/react-foundation-components#45bcb22dd3e078a515a9f7dfb33f834b31f83a43" + }, "scripts": { + "preinstall": "node git-install.js", "cordova": "cordova", - "dev": "react-app-rewired start", + "dev": "cross-env react-app-rewired start", "dev:server": "nodemon server", "build": "react-app-rewired build", "prod": "NODE_ENV=production node server/index.js", diff --git a/res/icon-hdpi.png b/res/icon-hdpi.png index ef8a85f0e..bb8139157 100644 Binary files a/res/icon-hdpi.png and b/res/icon-hdpi.png differ diff --git a/res/icon-ldpi.png b/res/icon-ldpi.png index 7f7926ffb..2fdf78bfa 100644 Binary files a/res/icon-ldpi.png and b/res/icon-ldpi.png differ diff --git a/res/icon-mdpi.png b/res/icon-mdpi.png index 7bbfea954..c239e0c64 100644 Binary files a/res/icon-mdpi.png and b/res/icon-mdpi.png differ diff --git a/res/icon-xhdpi.png b/res/icon-xhdpi.png index 56ba338cd..661b0f709 100644 Binary files a/res/icon-xhdpi.png and b/res/icon-xhdpi.png differ diff --git a/res/icon-xxhdpi.png b/res/icon-xxhdpi.png index 46653ac1d..83265bc73 100644 Binary files a/res/icon-xxhdpi.png and b/res/icon-xxhdpi.png differ diff --git a/res/icon-xxxhdpi.png b/res/icon-xxxhdpi.png index 636f95d71..d4075083c 100644 Binary files a/res/icon-xxxhdpi.png and b/res/icon-xxxhdpi.png differ diff --git a/server/index.js b/server/index.js index 3fbf53473..b7b0aef0c 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ const cors = require('@koa/cors') const coBody = require('co-body') const config = require('config') const git = require('git-rev-sync') +const fs = require('fs') const path = require('path') const { convertEntriesToArrays, } = require('./utils/misc') @@ -70,14 +71,22 @@ if (env === 'production') { } if (env === 'production') { + const buildPath = path.join(__dirname, '../build') app.use(async (ctx, next) => { - if (ctx.path.startsWith('/@')) { - ctx.url = '/' + const parts = ctx.path.split('/') + // / + // /@user + // /group + if (parts.length === 2 && parts[1] !== 'api') { + const filePath = path.join(buildPath, parts[1]) + if (!fs.existsSync(filePath)) { + ctx.url = '/' + } } await next() }) const cacheOpts = { maxage: 0, gzip: true } - app.use(static(path.join(__dirname, '../build'), cacheOpts)) + app.use(static(buildPath, cacheOpts)) } app.use(router.routes()) diff --git a/src/_themes.scss b/src/_themes.scss index ea18d539a..525d58fdf 100644 --- a/src/_themes.scss +++ b/src/_themes.scss @@ -57,6 +57,7 @@ $themes: ( navBackgroundColor: $color-white, highlightBackgroundColor: #f3faf0, tableRowEvenBackgroundColor: #f4f4f4, + borderColor: $light-gray, border: 1px solid $light-gray, borderLight: 1px solid $color-border-light-lightest, borderDark: 1px solid $color-text-gray, @@ -98,6 +99,7 @@ $themes: ( zebra: $black-gray, highlighted: $black-gray, tableRowEvenBackgroundColor: #212C33, + borderColor: $color-border-dark, border: 1px solid $color-border-dark, borderLight: 1px solid $color-border-dark-lightest, textColorPrimaryborderDark: 1px solid $color-text-gray-light, diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg new file mode 100644 index 000000000..f922ce9f7 --- /dev/null +++ b/src/assets/icons/chevron-right.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/clock.svg b/src/assets/icons/clock.svg new file mode 100644 index 000000000..90bb0d34b --- /dev/null +++ b/src/assets/icons/clock.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/golos.svg b/src/assets/icons/golos.svg new file mode 100644 index 000000000..cf13e03be --- /dev/null +++ b/src/assets/icons/golos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/info_o.svg b/src/assets/icons/info_o.svg new file mode 100644 index 000000000..601b562a2 --- /dev/null +++ b/src/assets/icons/info_o.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/ionicons/ban-outline.svg b/src/assets/icons/ionicons/ban-outline.svg new file mode 100644 index 000000000..723ad0f15 --- /dev/null +++ b/src/assets/icons/ionicons/ban-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/ionicons/ban.svg b/src/assets/icons/ionicons/ban.svg new file mode 100644 index 000000000..309cc25cb --- /dev/null +++ b/src/assets/icons/ionicons/ban.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/ionicons/checkmark-circle.svg b/src/assets/icons/ionicons/checkmark-circle.svg index 42d13ecb6..482458739 100644 --- a/src/assets/icons/ionicons/checkmark-circle.svg +++ b/src/assets/icons/ionicons/checkmark-circle.svg @@ -1 +1 @@ -Checkmark Circle \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/checkmark-sharp.svg b/src/assets/icons/ionicons/checkmark-sharp.svg index c8bfecc03..e4974ce93 100644 --- a/src/assets/icons/ionicons/checkmark-sharp.svg +++ b/src/assets/icons/ionicons/checkmark-sharp.svg @@ -1 +1 @@ -Checkmark \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/gift.svg b/src/assets/icons/ionicons/gift.svg index 5344c44ff..8c05bf862 100644 --- a/src/assets/icons/ionicons/gift.svg +++ b/src/assets/icons/ionicons/gift.svg @@ -1 +1 @@ -Gift \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/happy-outline.svg b/src/assets/icons/ionicons/happy-outline.svg index c04a7b0f8..bd302e8c0 100644 --- a/src/assets/icons/ionicons/happy-outline.svg +++ b/src/assets/icons/ionicons/happy-outline.svg @@ -1 +1 @@ -Happy \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/image-outline.svg b/src/assets/icons/ionicons/image-outline.svg index ba3436168..2074d922e 100644 --- a/src/assets/icons/ionicons/image-outline.svg +++ b/src/assets/icons/ionicons/image-outline.svg @@ -1 +1 @@ -Image \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/language-outline.svg b/src/assets/icons/ionicons/language-outline.svg index a1a8cea59..b8f2f0c05 100644 --- a/src/assets/icons/ionicons/language-outline.svg +++ b/src/assets/icons/ionicons/language-outline.svg @@ -1 +1 @@ -Language \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/lock-closed-outline.svg b/src/assets/icons/ionicons/lock-closed-outline.svg index b7b2d7034..7a64518bf 100644 --- a/src/assets/icons/ionicons/lock-closed-outline.svg +++ b/src/assets/icons/ionicons/lock-closed-outline.svg @@ -1 +1 @@ -Lock Closed \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/ionicons/lock-open-outline.svg b/src/assets/icons/ionicons/lock-open-outline.svg index 2699b886e..a9401575c 100644 --- a/src/assets/icons/ionicons/lock-open-outline.svg +++ b/src/assets/icons/ionicons/lock-open-outline.svg @@ -1,8 +1,6 @@ - Lock Open - Layer 1 diff --git a/src/assets/icons/ionicons/person-add.svg b/src/assets/icons/ionicons/person-add.svg new file mode 100644 index 000000000..94e1a1403 --- /dev/null +++ b/src/assets/icons/ionicons/person-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/ionicons/person.svg b/src/assets/icons/ionicons/person.svg new file mode 100644 index 000000000..04ac019df --- /dev/null +++ b/src/assets/icons/ionicons/person.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/ionicons/trash-outline.svg b/src/assets/icons/ionicons/trash-outline.svg index c31d78df8..6cb4d4688 100644 --- a/src/assets/icons/ionicons/trash-outline.svg +++ b/src/assets/icons/ionicons/trash-outline.svg @@ -1 +1 @@ -Trash \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 000000000..deecc8126 --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/team.svg b/src/assets/icons/team.svg new file mode 100644 index 000000000..0b1aa9041 --- /dev/null +++ b/src/assets/icons/team.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/voters.svg b/src/assets/icons/voters.svg new file mode 100644 index 000000000..6b0985169 --- /dev/null +++ b/src/assets/icons/voters.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/group.png b/src/assets/images/group.png new file mode 100644 index 000000000..cf6af912e Binary files /dev/null and b/src/assets/images/group.png differ diff --git a/src/components/all.scss b/src/components/all.scss index 5e942a191..f45c91c86 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -4,10 +4,12 @@ @import './elements/LoadingIndicator'; @import './elements/Logo'; @import "./elements/NotifiCounter"; +@import "./elements/Stub"; @import "./elements/Userpic"; @import "./elements/VerticalMenu"; @import "./elements/app/AppReminder"; @import "./elements/app/LoginAppReminder"; +@import "./elements/common/AccountName/index"; @import "./elements/common/DialogManager/index"; @import "./elements/common/Input/index"; @import './elements/donate/PresetSelector'; @@ -17,9 +19,17 @@ @import "./dialogs/DialogFrame/index"; @import "./dialogs/CommonDialog/index"; @import "./dialogs/AddImageDialog/index"; +@import "./dialogs/LoginDialog/index"; // modules +@import './modules/AccountDropdown.scss'; @import './modules/LoginForm.scss'; +@import './modules/CreateGroup.scss'; +@import './modules/groups/MyGroups.scss'; +@import './modules/groups/TopGroups.scss'; +@import './modules/groups/GroupMembers.scss'; +@import './modules/groups/GroupSettings.scss'; +@import './modules/MessagesTopCenter.scss'; @import './modules/Modals.scss'; @import "./pages/Messages"; diff --git a/src/components/dialogs/LoginDialog/index.jsx b/src/components/dialogs/LoginDialog/index.jsx new file mode 100644 index 000000000..78338dc78 --- /dev/null +++ b/src/components/dialogs/LoginDialog/index.jsx @@ -0,0 +1,191 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; +import { config, auth } from 'golos-lib-js' + +import DialogFrame from 'app/components/dialogs/DialogFrame'; +import DialogManager from 'app/components/elements/common/DialogManager'; +import Input from 'app/components/elements/common/Input'; +import keyCodes from 'app/utils/keyCodes'; +import { pageSession } from 'app/redux/UserSaga' + +export function showLoginDialog(username, onClose, authType = 'active', saveLogin = false, hint = '') { + DialogManager.showDialog({ + component: LoginDialog, + adaptive: true, + props: { + username, + authType, + hint, + }, + onClose: (data) => { + if (onClose) onClose(data) + }, + }); +} + +export default class LoginDialog extends React.PureComponent { + static propTypes = { + onClose: PropTypes.func.isRequired, + }; + + state = { + password: '', + error: '', + saveLogin: false + } + + componentDidMount() { + let { saveLogin, hint } = this.props + const session = pageSession.load() + if (session) { + this.setState({ + password: session[1] + }) + saveLogin = true + } + if (saveLogin) { + this.setState({ saveLogin }) + } + const linkInput = document.getElementsByClassName('AddImageDialog__link-input')[0]; + if (linkInput) + linkInput.focus(); + setTimeout(() => { + this.setState({ + enabled: true + }) + }, hint ? 1500 : 0) + } + + onPasswordChange = (e) => { + e.preventDefault() + this.setState({ + password: e.target.value + }) + } + + onSaveLoginChange = (e) => { + this.setState({ + saveLogin: !this.state.saveLogin + }) + } + + onLogin = async (e) => { + e.preventDefault() + const { username, authType } = this.props + const { password, saveLogin } = this.state + + this.setState({ + error: '' + }) + let authRes + try { + authRes = await auth.login(username, password) + } catch (err) { + this.setState({ + error: tt('login_dialog_jsx.node_failure_NODE_ERROR', { + NODE: config.get('websocket'), + ERROR: err.toString().substring(0, 100) + }) + }) + return + } + if (!authRes[authType]) { + this.setState({ + error: tt('login_dialog_jsx.wrong_pass_ROLE', { + ROLE: authType + }) + }) + return + } + + if (saveLogin) { + pageSession.save(password, username) + } else { + pageSession.clear() + } + + this.setState({ + error: '' + }) + this.props.onClose({ + password + }) + } + + onCancel = (e) => { + e.preventDefault() + this.props.onClose({}) + } + + render() { + const { password, error, saveLogin, enabled } = this.state + + let hint + if (this.props.hint) { + hint =  {this.props.hint} + } + + return ( + +
+
+ {tt('loginform_jsx.is_is_for_operation')} + {hint} + . +
+ +
+ {error &&
+ {error} +
} +
+
+ +
+
+
+
+ + +
+
+
+ ); + } + + _onInputKeyDown = e => { + if (e.which === keyCodes.ENTER) { + e.preventDefault(); + this.props.onClose({ + url: e.target.value, + }); + } + }; + + _onCloseClick = () => { + this.props.onClose(); + }; +} diff --git a/src/components/dialogs/LoginDialog/index.scss b/src/components/dialogs/LoginDialog/index.scss new file mode 100644 index 000000000..2408f5140 --- /dev/null +++ b/src/components/dialogs/LoginDialog/index.scss @@ -0,0 +1,12 @@ +.LoginDialog { + min-width: 700px; + + @media screen and (max-width: 700px) { + min-width: 200px; + width: 100%; + } + + .button { + margin-bottom: 0px; + } +} diff --git a/src/components/elements/Icon.jsx b/src/components/elements/Icon.jsx index 120f3e412..4825eff24 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -9,6 +9,7 @@ const icons = new Map([ // ['chevron-up-circle', require('app/assets/icons/chevron-up-circle.svg')], // ['chevron-down-circle', require('app/assets/icons/chevron-down-circle.svg')], ['chevron-left', require('app/assets/icons/chevron-left.svg')], + ['chevron-right', require('app/assets/icons/chevron-right.svg')], // ['chatboxes', require('app/assets/icons/chatboxes.svg')], ['cross', require('app/assets/icons/cross.svg')], // ['chatbox', require('app/assets/icons/chatbox.svg')], @@ -20,16 +21,16 @@ const icons = new Map([ ['pencil', require('app/assets/icons/pencil.svg')], // ['vk', require('app/assets/icons/vk.svg')], // ['link', require('app/assets/icons/link.svg')], - // ['clock', require('app/assets/icons/clock.svg')], + ['clock', require('app/assets/icons/clock.svg')], // ['copy', require('app/assets/icons/copy.svg')], // ['extlink', require('app/assets/icons/extlink.svg')], - // ['golos', require('app/assets/icons/golos.svg')], + ['golos', require('app/assets/icons/golos.svg')], ['dropdown-arrow', require('app/assets/icons/dropdown-arrow.svg')], // ['printer', require('app/assets/icons/printer.svg')], - // ['search', require('app/assets/icons/search.svg')], + ['search', require('app/assets/icons/search.svg')], // ['menu', require('app/assets/icons/menu.svg')], // ['voter', require('app/assets/icons/voter.svg')], - // ['voters', require('app/assets/icons/voters.svg')], + ['voters', require('app/assets/icons/voters.svg')], // ['empty', require('app/assets/icons/empty.svg')], // ['flag1', require('app/assets/icons/flag1.svg')], // ['flag2', require('app/assets/icons/flag2.svg')], @@ -42,7 +43,7 @@ const icons = new Map([ // ['eye_strike', require('app/assets/icons/eye_strike.svg')], // ['eye_gray', require('app/assets/icons/eye_gray.svg')], // ['location', require('app/assets/icons/location.svg')], - // ['info_o', require('app/assets/icons/info_o.svg')], + ['info_o', require('app/assets/icons/info_o.svg')], // ['feedback', require('app/assets/icons/feedback.svg')], // ['cog', require('app/assets/icons/cog.svg')], // ['enter', require('app/assets/icons/enter.svg')], @@ -59,7 +60,7 @@ const icons = new Map([ // ['female', require('app/assets/icons/female.svg')], // ['money', require('app/assets/icons/money.svg')], // ['tips', require('app/assets/icons/tips.svg')], - // ['team', require('app/assets/icons/team.svg')], + ['team', require('app/assets/icons/team.svg')], // ['rocket', require('app/assets/icons/rocket.svg')], // ['blockchain', require('app/assets/icons/blockchain.svg')], // ['shuffle', require('app/assets/icons/shuffle.svg')], @@ -127,6 +128,8 @@ const icons = new Map([ // ['editor-toolbar/picture', require('app/assets/icons/editor-toolbar/picture.svg')], // ['editor-toolbar/video', require('app/assets/icons/editor-toolbar/video.svg')], // ['editor-toolbar/search', require('app/assets/icons/editor-toolbar/search.svg')], + ['ionicons/ban', require('app/assets/icons/ionicons/ban.svg')], + ['ionicons/ban-outline', require('app/assets/icons/ionicons/ban-outline.svg')], ['ionicons/checkmark-circle', require('app/assets/icons/ionicons/checkmark-circle.svg')], ['ionicons/checkmark-sharp', require('app/assets/icons/ionicons/checkmark-sharp.svg')], ['ionicons/gift', require('app/assets/icons/ionicons/gift.svg')], @@ -136,6 +139,8 @@ const icons = new Map([ ['ionicons/lock-closed-outline', require('app/assets/icons/ionicons/lock-closed-outline.svg')], ['ionicons/lock-open-outline', require('app/assets/icons/ionicons/lock-open-outline.svg')], ['ionicons/trash-outline', require('app/assets/icons/ionicons/trash-outline.svg')], + ['ionicons/person', require('app/assets/icons/ionicons/person.svg')], + ['ionicons/person-add', require('app/assets/icons/ionicons/person-add.svg')], // ['notification/comment', require('app/assets/icons/notification/comment.svg')], // ['notification/donate', require('app/assets/icons/notification/donate.svg')], // ['notification/transfer', require('app/assets/icons/notification/transfer.svg')], diff --git a/src/components/elements/Stub.jsx b/src/components/elements/Stub.jsx new file mode 100644 index 000000000..8615c1e2f --- /dev/null +++ b/src/components/elements/Stub.jsx @@ -0,0 +1,161 @@ +import React from 'react' +import { connect } from 'react-redux' +import tt from 'counterpart' + +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import transaction from 'app/redux/TransactionReducer' +import { getRoleInGroup } from 'app/utils/groups' +import { maxDateStr, isBlockedByMe, isBlockingMe } from 'app/utils/misc' + +class StubInner extends React.Component { + onBtnClick = (e) => { + e.preventDefault() + const { username, group, pending } = this.props + this.props.groupMember({ + requester: username, group: group.name, + member: username, + member_type: pending ? 'retired' : 'pending', + onSuccess: () => { + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + + render() { + const { type, banned, notMember, pending, loading, blocked, blocking } = this.props + + const isCompose = type === 'compose' + + let text, btn + if (banned) { + text = tt('stub_jsx.banned') + } else if (pending) { + text = tt('stub_jsx.pending') + text += ' ' + btn = {tt('msgs_group_dropdown.cancel')} + } else if (notMember) { + text = isCompose ? tt('stub_jsx.read_only') : tt('stub_jsx.private_group') + text += ' ' + btn = {tt('stub_jsx.join')} + } else if (blocked) { + text = tt('stub_jsx.blocked') + } else if (blocking) { + text = tt('stub_jsx.blocking') + } + + if (isCompose) { + return
+ {text}{btn} +
+ } else { + if (loading) { + return
+
+ +
+
+ } + return
+
+ {text}{btn} +
+
+ } + } +} + +const Stub = connect( + (state, ownProps) => { + const currentUser = state.user.get('current') + + const username = state.user.getIn(['current', 'username']) + + let the_group = state.global.get('the_group') + if (the_group && the_group.toJS) the_group = the_group.toJS() + + return { + the_group, + username, + } + }, + dispatch => ({ + groupMember: ({ requester, group, member, member_type, + onSuccess, onError }) => { + const opData = { + requester, + name: group, + member, + member_type, + json_metadata: '{}', + extensions: [], + } + + const plugin = 'private_message' + const json = JSON.stringify(['private_group_member', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: plugin, + required_posting_auths: [requester], + json, + }, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + }), +)(StubInner) + +export default Stub + +export const renderStubs = (the_group, to, username, accounts) => { + let composeStub, msgsStub + + const isGroup = to && !to.startsWith('@') + if (to && !isGroup) { + const acc = accounts[to.replace('@', '')] + if (isBlockingMe(acc)) { + composeStub = { ui: } + return { composeStub, msgsStub} + } + if (isBlockedByMe(acc)) { + composeStub = { ui: } + return { composeStub, msgsStub} + } + } + + if (!the_group || the_group.error) { + if (isGroup) { + composeStub = { disabled: true } + if (the_group !== null) { // if not 404 + msgsStub = { ui: } + } + } + return { composeStub, msgsStub} + } + if (the_group.name !== to) { + return { composeStub, msgsStub} + } + + const { privacy } = the_group + const { amBanned, amMember, amModer, amPending } = getRoleInGroup(the_group, username) + const notMember = !amModer && !amMember + if (amBanned || (privacy !== 'public_group' && notMember)) { + composeStub = { ui: } + if (privacy === 'private_group') { + composeStub = { disabled: true } + msgsStub = { ui: } + } + } + + return { composeStub, msgsStub} +} diff --git a/src/components/elements/Stub.scss b/src/components/elements/Stub.scss new file mode 100644 index 000000000..2b676e94d --- /dev/null +++ b/src/components/elements/Stub.scss @@ -0,0 +1,24 @@ +.compose-stub { + height: 60px !important; + padding-top: 0.75rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.5rem; +} + +.message-list-container { + .msgs-stub { + position: absolute; + top: 50%; + transform: translateY(-50%); + padding: 1rem; + width: 98%; + } +} + +.stub-btn { + white-space: nowrap; + &.alert { + color: red; + } +} diff --git a/src/components/elements/Userpic.jsx b/src/components/elements/Userpic.jsx index 32b410f90..3f6e089c1 100644 --- a/src/components/elements/Userpic.jsx +++ b/src/components/elements/Userpic.jsx @@ -6,10 +6,12 @@ import tt from 'counterpart' import CircularProgress from './CircularProgress' import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl'; +import LetteredAvatar from 'app/components/elements/messages/LetteredAvatar' class Userpic extends Component { static propTypes = { account: PropTypes.string, + disabled: PropTypes.bool, votingPower: PropTypes.number, showProgress: PropTypes.bool, progressClass: PropTypes.string, @@ -21,6 +23,7 @@ class Userpic extends Component { static defaultProps = { width: 48, height: 48, + disabled: false, hideIfDefault: false, showProgress: false } @@ -49,6 +52,8 @@ class Userpic extends Component { } } + let isDefault = false + if (url && /^(https?:)\/\//.test(url)) { const size = width && width > 75 ? '200x200' : '75x75'; url = proxifyImageUrlWithStrip(url, size); @@ -57,9 +62,10 @@ class Userpic extends Component { return null; } url = require('app/assets/images/user.png'); + isDefault = true } - return url + return { url, isDefault } } votingPowerToPercents = power => power / 100 @@ -91,12 +97,20 @@ class Userpic extends Component { } render() { - const { title, width, height, votingPower, reputation, hideReputationForSmall, showProgress, onClick } = this.props + const { account, disabled, title, width, height, votingPower, reputation, hideReputationForSmall, showProgress, onClick } = this.props + + const { url, isDefault } = this.extractUrl() const style = { width: `${width}px`, height: `${height}px`, - backgroundImage: `url(${this.extractUrl()})` + backgroundImage: `url(${url})` + } + + let lettered + if (isDefault) { + lettered = } if (votingPower) { @@ -114,7 +128,9 @@ class Userpic extends Component {
{reputation}
} else { - return
+ return
+ {lettered} +
} } } diff --git a/src/components/elements/VerticalMenu.scss b/src/components/elements/VerticalMenu.scss index 9bdf1c161..d3aba6adc 100644 --- a/src/components/elements/VerticalMenu.scss +++ b/src/components/elements/VerticalMenu.scss @@ -55,6 +55,10 @@ padding: 0 1rem 0 3.8rem; line-height: 50px; + @media screen and (max-width: 39.9375em) { + padding: 0 20px; + } + .Icon { @include themify($themes) { fill: themed('textColorPrimary'); diff --git a/src/components/elements/common/AccountName/index.jsx b/src/components/elements/common/AccountName/index.jsx new file mode 100644 index 000000000..fa9e578ec --- /dev/null +++ b/src/components/elements/common/AccountName/index.jsx @@ -0,0 +1,115 @@ +import React from 'react' +import tt from 'counterpart' +import AsyncSelect from 'react-select/async' +import { api } from 'golos-lib-js' + +import Userpic from 'app/components/elements/Userpic' + +class AccountName extends React.Component { + constructor(props) { + super(props) + this.state = { + defaultOptions: [], + isLoading: true, + } + this.ref = React.createRef() + } + + onAccountsLoad = (accs) => { + const { onAccountsLoad } = this.props + onAccountsLoad(accs) + } + + lookupAccounts = async (value) => { + try { + //await new Promise(resolve => setTimeout(resolve, 2000)) + const { includeFrozen, filterAccounts } = this.props + const accNames = await api.lookupAccountsAsync(value.toLowerCase(), 6, { + include_frozen: includeFrozen, + filter_accounts: [...filterAccounts], + }) + const accs = await api.lookupAccountNamesAsync(accNames) + this.onAccountsLoad(accs) + return accs + } catch (err) { + console.error(err) + return [] + } + } + + onChange = (acc) => { + const { onChange } = this.props + if (onChange) { + const e = { target: { value: acc.name, account: acc } } + onChange(e, acc) + } + } + + onMenuOpen = async (e) => { + const { current } = this.ref + if (!current) { console.warn('No AsyncSelect ref'); return; } + const target = current.inputRef + if (!target) { console.warn('No inputRef'); return; } + const { value } = target + if (!value) { + this.setState({ + isLoading: true, + defaultOptions: [] + }, async () => { + const defaultOptions = await this.lookupAccounts('') + this.setState({ + isLoading: false, + defaultOptions + }) + }) + } + } + + testIt = (e) => { + e.preventDefault() + console.log(this.state.dop) + if (this.state.dop === true) + this.setState({ + dop: [] + }); else this.setState({ + dop: true + }) + } + + render() { + const { onChange, className, ...rest } = this.props + const { defaultOptions, isLoading } = this.state + // isOptionSelected = false disables blue bg if opened not first time + return tt('account_name_jsx.loading')} + noOptionsMessage={() => tt('account_name_jsx.no_options')} + + loadOptions={this.lookupAccounts} + isLoading={isLoading} + defaultOptions={defaultOptions} + cacheOptions={false} + onMenuOpen={this.onMenuOpen} + ref={this.ref} + isOptionSelected={() => false} + + className={'AccountName ' + (className || ' ')} + getOptionLabel={(option) => { + return + + {`${option.name}`} + + }} + controlShouldRenderValue={false} + onChange={this.onChange} + {...rest} + /> + } +} + +AccountName.defaultProps = { + includeFrozen: false, + filterAccounts: [], +} + +export default AccountName diff --git a/src/components/elements/common/AccountName/index.scss b/src/components/elements/common/AccountName/index.scss new file mode 100644 index 000000000..bd494fc0a --- /dev/null +++ b/src/components/elements/common/AccountName/index.scss @@ -0,0 +1,14 @@ +.AccountName { + input { + box-shadow: none !important; + line-height: 1 !important; + height: 1.8rem !important; + } + .name-item { + margin-top: 0.45rem; + display: inline-block; + .title { + margin-left: 0.25rem; + } + } +} diff --git a/src/components/elements/common/DialogManager/index.jsx b/src/components/elements/common/DialogManager/index.jsx index b5cdf94e4..e2dbbc7fd 100644 --- a/src/components/elements/common/DialogManager/index.jsx +++ b/src/components/elements/common/DialogManager/index.jsx @@ -132,7 +132,7 @@ export default class DialogManager extends React.PureComponent { style={{ top }} >
0 ? { diff --git a/src/components/elements/common/DialogManager/index.scss b/src/components/elements/common/DialogManager/index.scss index ecf6b82ee..7714a003e 100644 --- a/src/components/elements/common/DialogManager/index.scss +++ b/src/components/elements/common/DialogManager/index.scss @@ -5,7 +5,7 @@ left: 0; right: 0; bottom: 0; - z-index: 500; + z-index: 1000; &__window { position: absolute; @@ -30,6 +30,11 @@ &__dialog { max-width: 600px; pointer-events: initial; + &.adaptive { + @media screen and (max-width: 700px) { + width: 100%; + } + } } &__shade { diff --git a/src/components/elements/groups/GroupCost.jsx b/src/components/elements/groups/GroupCost.jsx new file mode 100644 index 000000000..371e6fbe9 --- /dev/null +++ b/src/components/elements/groups/GroupCost.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import tt from 'counterpart' + +import Icon from 'app/components/elements/Icon' + +export default class GroupCost extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + render() { + let { cost, marginTop } = this.props + if (!cost) { + return null + } + marginTop = marginTop || '1.0rem' + const isFree = cost.eq(0) + const costTitle = isFree ? tt('create_group_jsx.create_of_group_is') : tt('create_group_jsx.gbg_too_low') + const costStr = isFree ? tt('create_group_jsx.free') : cost.floatString + return
+
+ + + + {costTitle} + {costStr}. + + +
+
+ } +} diff --git a/src/components/elements/groups/GroupMember.jsx b/src/components/elements/groups/GroupMember.jsx new file mode 100644 index 000000000..30a7855d6 --- /dev/null +++ b/src/components/elements/groups/GroupMember.jsx @@ -0,0 +1,131 @@ +import React from 'react' +import tt from 'counterpart' +import cn from 'classnames' + +import Icon from 'app/components/elements/Icon' +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import Userpic from 'app/components/elements/Userpic' +import { getRoleInGroup } from 'app/utils/groups' +import isScreenSmall from 'app/utils/isScreenSmall' + +class GroupMember extends React.Component { + // shouldComponentUpdate(nextProps) { + // const { member } = this.props + // if (member.member_type !== nextProps.member_type) { + // return true + // } + // return false + // } + + groupMember = (e, member, setMemberType) => { + e.preventDefault() + const { username, currentGroup } = this.props + const { account } = member + const currentMemberType = member.member_type + + if (currentMemberType === setMemberType) return; + if (username === account) return + const group = currentGroup.name + + // Update in UI immediately + this.props.updateGroupMember(group, account, setMemberType) + + const { creatingNew } = currentGroup + if (!creatingNew) { + this.props.groupMember({ + requester: username, group, + member: account, + member_type: setMemberType, + onSuccess: () => { + }, + onError: (err, errStr) => { + // Update it back! + this.props.updateGroupMember(group, account, currentMemberType) + + if (errStr.includes('duplicate transaction')) { + // "Too Many Requests" - just nothing action, not alert + return + } + alert(errStr) + } + }) + } + } + + render() { + const { member, username, currentGroup } = this.props + const { account, member_type, joined } = member + const { creatingNew, } = currentGroup + + let { amOwner, amModer } = getRoleInGroup(currentGroup, username) + if (creatingNew) { + amOwner = true + amModer = true + } + + const isMe = username === account + const isOwner = currentGroup.owner === account + const isMember = !isOwner && member_type === 'member' + const isModer = !isOwner && member_type === 'moder' + const isBanned = !isOwner && member_type === 'banned' + + let memberTitle, moderTitle, banTitle + let memberBtn, moderBtn, banBtn, deleteBtn + let ownerTitle = tt('group_members_jsx.owner') + if (account !== username) { + memberTitle = (isMember && tt('group_members_jsx.member')) || + (isBanned && tt('group_members_jsx.unban')) || + (!isMember && tt('group_members_jsx.make_member')) + moderTitle = (isModer && tt('group_members_jsx.moder')) || + tt('group_members_jsx.make_moder') + banTitle = (isBanned && tt('group_members_jsx.unban')) || + tt('group_members_jsx.ban') + } else { + memberTitle = tt('group_members_jsx.member') + moderTitle = tt('group_members_jsx.moder') + banTitle = tt('group_members_jsx.banned') + } + + if (!creatingNew) { + if ((!isMe || isBanned) && amModer) { + banBtn = this.groupMember(e, member, isBanned ? 'member' : 'banned')} /> + } + } else { + deleteBtn = this.groupMember(e, member, 'retired')} /> + } + + const isSmall = isScreenSmall() + + return + + + + + {account} + + + + + {!isSmall && !creatingNew && } + + + {isOwner && } + {(amOwner || isMember) && this.groupMember(e, member, 'member')} />} + {(amOwner|| isModer) && this.groupMember(e, member, 'moder')} />} + {banBtn} + {deleteBtn} + + + } +} + +export default GroupMember diff --git a/src/components/elements/messages/AuthorDropdown/AuthorDropdown.scss b/src/components/elements/messages/AuthorDropdown/AuthorDropdown.scss new file mode 100644 index 000000000..f54374909 --- /dev/null +++ b/src/components/elements/messages/AuthorDropdown/AuthorDropdown.scss @@ -0,0 +1,28 @@ +.AuthorDropdown { + padding: 0.5rem; + + .link { + font-weight: bold; + } + + .last-seen { + font-size: 95%; + } + + .btns { + min-width: 250px; + width: 100%; + } + .btn { + float: right; + margin-right: 0.5rem !important; + margin-top: 0.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + + .title { + vertical-align: middle; + margin-left: 5px; + } + } +} diff --git a/src/components/elements/messages/AuthorDropdown/index.jsx b/src/components/elements/messages/AuthorDropdown/index.jsx new file mode 100644 index 000000000..10d046744 --- /dev/null +++ b/src/components/elements/messages/AuthorDropdown/index.jsx @@ -0,0 +1,142 @@ +import React from 'react' +import {connect} from 'react-redux' +import { withRouter } from 'react-router' +import { Link } from 'react-router-dom' +import tt from 'counterpart' +import cn from 'classnames' + +import Icon from 'app/components/elements/Icon' +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import transaction from 'app/redux/TransactionReducer' +import { getRoleInGroup } from 'app/utils/groups' +import { getLastSeen } from 'app/utils/NormalizeProfile' + +import './AuthorDropdown.scss' + +class AuthorDropdown extends React.Component { + constructor(props) { + super(props) + this.state = { + } + } + + btnClick = (e, isBanned) => { + e.preventDefault() + const { author, username, the_group } = this.props + if (!the_group) { + return + } + this.setState({submitting: true}) + + const member_type = isBanned ? 'member' : 'banned' + this.props.groupMember({ + requester: username, group: the_group.name, + member: author, + member_type, + onSuccess: () => { + this.setState({submitting: false}) + document.body.click() + }, + onError: (err, errStr) => { + this.setState({submitting: false}) + alert(errStr) + } + }) + } + + render() { + const { author, authorAcc, the_group, account } = this.props + + let lastSeen + if (authorAcc) { + lastSeen = getLastSeen(authorAcc) + } + + let isModer + if (the_group && account) { + const { amModer } = getRoleInGroup(the_group, account.name) + isModer = amModer + } + + let banBtn + if (isModer) { + const isBanned = authorAcc && authorAcc.member_type === 'banned' + const isOwner = the_group && the_group.owner === author + banBtn = + } + + return
+
+ {'@' + author} +
+ {lastSeen ?
+ {tt('messages.last_seen')} + +
: lastSeen} +
+ {banBtn} +
+
+ } +} + +export default withRouter(connect( + (state, ownProps) => { + const currentUser = state.user.get('current') + const accounts = state.global.get('accounts') + + let authorAcc = accounts.get(ownProps.author) + authorAcc = authorAcc ? authorAcc.toJS() : null + + let the_group = state.global.get('the_group') + if (the_group && the_group.toJS) the_group = the_group.toJS() + + const username = state.user.getIn(['current', 'username']) + + return { + username, + authorAcc, + the_group, + account: currentUser && accounts && accounts.toJS()[currentUser.get('username')], + } + }, + dispatch => ({ + groupMember: ({ requester, group, member, member_type, + onSuccess, onError }) => { + const opData = { + requester, + name: group, + member, + member_type, + json_metadata: '{}', + extensions: [], + } + + const plugin = 'private_message' + const json = JSON.stringify(['private_group_member', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: plugin, + required_posting_auths: [requester], + json, + }, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + }), +)(AuthorDropdown)) diff --git a/src/components/elements/messages/ChatError/ChatError.scss b/src/components/elements/messages/ChatError/ChatError.scss new file mode 100644 index 000000000..6b0ee4537 --- /dev/null +++ b/src/components/elements/messages/ChatError/ChatError.scss @@ -0,0 +1,6 @@ +.ChatError { + position: absolute; + top: 50%; + transform: translateX(-50%) translateY(-50%); + left: 50%; +} diff --git a/src/components/elements/messages/ChatError/index.jsx b/src/components/elements/messages/ChatError/index.jsx new file mode 100644 index 000000000..3db51ff3d --- /dev/null +++ b/src/components/elements/messages/ChatError/index.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import tt from 'counterpart' + +import Icon from 'app/components/elements/Icon' +import './ChatError.scss' + +class ChatError extends React.Component { + render() { + const { isGroup, error } = this.props + + if (error === 404) { + return
+
+
{isGroup ? tt('msgs_chat_error.404_group') : tt('msgs_chat_error.404_acc')}
+
{tt('msgs_chat_error.404_but')}
+
+ } + + return
+
{tt('msgs_chat_error.500_group')}
+
{tt('msgs_chat_error.500_load_msgs')}
+
{error}
+
+ } +} + +export default ChatError diff --git a/src/components/elements/messages/Compose/index.jsx b/src/components/elements/messages/Compose/index.jsx index 9cbec8062..0be80a774 100644 --- a/src/components/elements/messages/Compose/index.jsx +++ b/src/components/elements/messages/Compose/index.jsx @@ -1,5 +1,6 @@ import React from 'react'; import tt from 'counterpart'; +import cn from 'classnames' import { Picker } from 'emoji-picker-element'; import TextareaAutosize from 'react-textarea-autosize'; @@ -7,6 +8,18 @@ import Icon from 'app/components/elements/Icon'; import { displayQuoteMsg } from 'app/utils/MessageUtils'; import './Compose.css'; +const fixComposeSize = () => { + const sb = document.getElementsByClassName('msgs-sidebar')[0] + let cw = '100%' + if (sb) { + cw = 'calc(100% - ' + sb.offsetWidth + 'px)' + } + const compose = document.getElementsByClassName('msgs-compose')[0] + if (compose) { + compose.style.width = cw + } +} + export default class Compose extends React.Component { onKeyDown = (e) => { if (!window.IS_MOBILE_DEVICE && e.keyCode === 13) { @@ -56,14 +69,24 @@ export default class Compose extends React.Component { componentDidMount() { this.init(); + fixComposeSize() + window.addEventListener('resize', fixComposeSize) } componentDidUpdate() { this.init(); } + componentWillUnmount() { + window.removeEventListener('resize', fixComposeSize) + } + onEmojiClick = (event) => { event.stopPropagation(); + + const { stub } = this.props + if (stub) return + this._tooltip.classList.toggle('shown'); if (!this._tooltip.classList.contains('shown')) { const input = document.getElementsByClassName('msgs-compose-input')[0]; @@ -100,6 +123,7 @@ export default class Compose extends React.Component { onEmojiSelect = (event) => { event.stopPropagation(); + this._tooltip.classList.toggle('shown'); const input = document.getElementsByClassName('msgs-compose-input')[0]; @@ -200,16 +224,20 @@ export default class Compose extends React.Component { } render() { - const { account, rightItems, replyingMessage } = this.props + const { account, rightItems, replyingMessage, stub } = this.props const { onPanelDeleteClick, onPanelReplyClick, onPanelEditClick, onPanelCloseClick, onCancelReply } = this; const selectedMessages = Object.entries(this.props.selectedMessages); let selectedMessagesCount = 0; - let selectedEditablesCount = 0; + let selectedEditables = 0 + let selectedDeletables = 0 for (let [nonce, info] of selectedMessages) { selectedMessagesCount++; if (info.editable) { - selectedEditablesCount++; + selectedEditables++; + } + if (info.deletable) { + selectedDeletables++ } } @@ -224,9 +252,11 @@ export default class Compose extends React.Component {
); } - const sendButton = selectedMessagesCount ? null : - (); @@ -234,13 +264,14 @@ export default class Compose extends React.Component { return (
{ - !selectedMessagesCount ? rightItems : null + (!selectedMessagesCount || stub) ? rightItems : null } - {!selectedMessagesCount ? (
- {quote} - + {stub ? null : quote} + {(stub && stub.ui) ? stub.ui : this.onChange(e.target.value)} - /> + />}
) : null} {sendButton} - {selectedMessagesCount ? (
+ {(selectedMessagesCount && !stub) ? (
{(selectedMessagesCount === 1) ? ( - - {(selectedMessagesCount === 1 && selectedEditablesCount === 1) ? ( : null} + {(selectedMessagesCount === 1 && selectedEditables === 1) ? () : null} diff --git a/src/components/elements/messages/ConversationListItem/ConversationListItem.css b/src/components/elements/messages/ConversationListItem/ConversationListItem.css index 20e7c582e..11346a85f 100644 --- a/src/components/elements/messages/ConversationListItem/ConversationListItem.css +++ b/src/components/elements/messages/ConversationListItem/ConversationListItem.css @@ -54,6 +54,11 @@ text-align: center; } +.conversation-unread.mention:not(.mine) { + background-color: #007aff; + margin-left: 8px; +} + .conversation-unread.mine { float: right; color: #007aff; diff --git a/src/components/elements/messages/ConversationListItem/index.jsx b/src/components/elements/messages/ConversationListItem/index.jsx index 6985e6858..391452424 100644 --- a/src/components/elements/messages/ConversationListItem/index.jsx +++ b/src/components/elements/messages/ConversationListItem/index.jsx @@ -10,7 +10,8 @@ export default class ConversationListItem extends React.Component { constructor(props) { super(props); this.state = { - avatarSrc: require('app/assets/images/user.png'), + //avatarSrc: require('app/assets/images/user.png'), + avatarSrc: null, }; } @@ -34,8 +35,10 @@ export default class ConversationListItem extends React.Component { makeLink = () => { const { conversationLinkPattern } = this.props; if (conversationLinkPattern) { - const { contact } = this.props.data; - return conversationLinkPattern.replace('*', contact); + const { data } = this.props + const { contact } = data + const pattern = conversationLinkPattern(data) + return pattern.replace('*', contact) } return null; }; @@ -48,9 +51,20 @@ export default class ConversationListItem extends React.Component { } }; + _renderAvatar = () => { + const defaultRender = (src) => { + return {''} + } + const { renderConversationAvatar } = this.props + if (!renderConversationAvatar) { + return defaultRender(this.state.avatarSrc) + } + return renderConversationAvatar(this.props.data, defaultRender) + } + render() { const { selected } = this.props; - const { avatar, isSystemMessage, contact, last_message, size, unread_donate } = this.props.data; + const { avatar, isSystemMessage, contact, last_message, size, unread_donate, kind } = this.props.data; const link = this.makeLink(); @@ -70,12 +84,31 @@ export default class ConversationListItem extends React.Component { unread = (
); } - const unreadMessages = size && size.unread_inbox_messages; + let title = '' + + const unreadMessages = size && size.unread_inbox_messages + const unreadMentions = size && size.unread_mentions if (!unread && unreadMessages) { unread = (
{unreadMessages}
) + if (kind === 'group') { + title += tt('plurals.reply_count', { count: unreadMessages }) + } + } + + if (unreadMentions) { + unread = +
+ {unreadMentions} +
+ {unread} +
+ if (kind === 'group') { + if (title) title += ', ' + title += tt('plurals.mention_count', { count: unreadMentions }) + } } let checkmark @@ -86,8 +119,8 @@ export default class ConversationListItem extends React.Component { } return ( - - {''} + + {this._renderAvatar()}

{contact}{checkmark}

{last_body && truncate(last_body, {length: 30})} diff --git a/src/components/elements/messages/Donating/index.jsx b/src/components/elements/messages/Donating/index.jsx index 80ea9a0fe..30c2a4db3 100644 --- a/src/components/elements/messages/Donating/index.jsx +++ b/src/components/elements/messages/Donating/index.jsx @@ -34,8 +34,8 @@ class Donating extends React.Component { onClick = e => { const { isMine } = this.props if (!isMine) { - const { from, to, nonce } = this.props.data - this.props.showDonate(from, to, nonce) + const { group, from, to, nonce } = this.props.data + this.props.showDonate(group || '', from, to, nonce) } } @@ -72,9 +72,9 @@ export default connect( return { ...ownProps } }, dispatch => ({ - showDonate(from, to, nonce) { + showDonate(group, from, to, nonce) { dispatch(user.actions.setDonateDefaults({ - from, to, nonce, + group, from, to, nonce, sym: 'GOLOS', precision: 3, })) diff --git a/src/components/elements/messages/LetteredAvatar/LetteredAvatar.css b/src/components/elements/messages/LetteredAvatar/LetteredAvatar.css new file mode 100644 index 000000000..764331143 --- /dev/null +++ b/src/components/elements/messages/LetteredAvatar/LetteredAvatar.css @@ -0,0 +1,17 @@ +.lettered-avatar-wrapper { + text-align: center; +} + +.lettered-avatar-wrapper.light { + color: #000; +} + +.lettered-avatar-wrapper.dark { + color: #fff; +} + +.lettered-avatar { + white-space: nowrap; + overflow: hidden; + font-size: 24px; +} diff --git a/src/components/elements/messages/LetteredAvatar/colors.js b/src/components/elements/messages/LetteredAvatar/colors.js new file mode 100644 index 000000000..411b0727f --- /dev/null +++ b/src/components/elements/messages/LetteredAvatar/colors.js @@ -0,0 +1,28 @@ +export const defaultColors = [ + '#e25f51',// red + '#f26091',// pink + '#bb65ca',// purple + '#9572cf',// blue-purple + '#7884cd',// blue + '#5b95f9',// light blue + '#48c2f9',// aqua + '#45d0e2',// aqua + //'#48b6ac',// aqua + '#52bc89',// green + '#9bce5f',// green + '#d4e34a',// green + '#feda10',// yellow + '#ff6554', // red #f7c000',// yellow + '#ffa800',// orange + '#ff8a60',// red-orange + //'#c2c2c2',// light dray + '#8fa4af',// R + //'#a2887e',// brown + '#a3a3a3',// gray + '#afb5e2',// U + '#b39bdd',// V + //'#c2c2c2',// brown + '#7cdeeb',// X + '#c7755a', // brown //'#bcaaa4',// light-brown + '#add67d'// green +] diff --git a/src/components/elements/messages/LetteredAvatar/index.jsx b/src/components/elements/messages/LetteredAvatar/index.jsx new file mode 100644 index 000000000..9ad914a1d --- /dev/null +++ b/src/components/elements/messages/LetteredAvatar/index.jsx @@ -0,0 +1,99 @@ +// Copyright (c) https://github.com/ipavlyukov/react-lettered-avatar +// The MIT License +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { defaultColors } from "./colors"; + +import "./LetteredAvatar.css"; + +class LetteredAvatar extends Component { + render() { + const { + name, + color, + backgroundColors, + backgroundColor, + radius, + size, + } = this.props; + let initials = "", + defaultBackground = ""; + + const sumChars = (str) => { + let sum = 0; + for (let i = 0; i < str.length; i++) { + sum += str.charCodeAt(i); + } + return sum; + }; + + // GET AND SET INITIALS + const names = name.split(" "); + if (names.length === 1) { + initials = names[0].substring(0, 1).toUpperCase(); + } else if (names.length > 1) { + names.forEach((n, i) => { + initials += names[i].substring(0, 1).toUpperCase(); + }); + } + + // SET BACKGROUND COLOR + if (/[A-Z]/.test(initials)) { + if (backgroundColor) { + defaultBackground = backgroundColor; + } else { + let colors = backgroundColors + if (backgroundColors && backgroundColors.length) { + colors = backgroundColors + } else { + colors = defaultColors + } + let i = sumChars(name) % colors.length + defaultBackground = colors[i] + } + } else if (/[\d]/.test(initials)) { + defaultBackground = defaultColors[parseInt(initials)]; + } else { + defaultBackground = "#051923"; + } + + const fontSize = size / 2 + + const styles = { + color, + backgroundColor: `${defaultBackground}`, + width: size, + height: size, + lineHeight: `${size}px`, + borderRadius: `${radius || radius === 0 ? radius : size}px`, + fontSize: `100%`, + fontWeight: 550, + }; + return ( +
+
{initials}
+
+ ); + } +} + +LetteredAvatar.propTypes = { + name: PropTypes.string.isRequired, + color: PropTypes.string, + backgroundColor: PropTypes.string, + radius: PropTypes.number, + size: PropTypes.number, +}; + +LetteredAvatar.defaultProps = { + name: "Lettered Avatar", + color: "", + size: 48, +}; + +export default LetteredAvatar; diff --git a/src/components/elements/messages/Message/Message.css b/src/components/elements/messages/Message/Message.css index 1b59c724e..c235da713 100644 --- a/src/components/elements/messages/Message/Message.css +++ b/src/components/elements/messages/Message/Message.css @@ -20,11 +20,33 @@ display: flex; } +.msgs-message .bubble-container .author { + font-size: 98%; + font-weight: bold; + color: #0078C4; + padding-bottom: 3px; +} +.msgs-message .bubble-container .author.banned { + text-decoration: line-through; + color: #999; +} +.msgs-message .bubble-container .avatar { + width: 42px; + margin-top: 14px; + cursor: pointer; +} +.msgs-message .bubble-container .avatar .Userpic { + position: static; +} + .msgs-message .bubble-container a { color: #007aff; text-decoration: underline; font-weight: bold; } +.msgs-message .bubble-container a.mention { + text-decoration: none; +} .msgs-message.mine .bubble-container { justify-content: flex-end; @@ -73,6 +95,11 @@ color: white; } +.msgs-message.highlight .bubble-container .bubble { + background: #fc544e !important; + color: white; +} + .msgs-message.mine .bubble-container .bubble { background: #007aff; color: white; diff --git a/src/components/elements/messages/Message/index.jsx b/src/components/elements/messages/Message/index.jsx index 3f73964d9..37c1d8a7e 100644 --- a/src/components/elements/messages/Message/index.jsx +++ b/src/components/elements/messages/Message/index.jsx @@ -1,13 +1,27 @@ import React from 'react'; +import {connect} from 'react-redux' +import { Fade } from 'react-foundation-components/lib/global/fade' +import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdown' +import { Link } from 'react-router-dom' import tt from 'counterpart'; +import cn from 'classnames' import { Asset } from 'golos-lib-js/lib/utils' +import AuthorDropdown from 'app/components/elements/messages/AuthorDropdown' import Donating from 'app/components/elements/messages/Donating' +import Userpic from 'app/components/elements/Userpic' +import { session } from 'app/redux/UserSaga' +import { accountNameRegEx } from 'app/utils/mentions' import { displayQuoteMsg } from 'app/utils/MessageUtils'; import { proxifyImageUrl } from 'app/utils/ProxifyUrl'; import './Message.css'; -export default class Message extends React.Component { +class Message extends React.Component { + constructor(props) { + super(props) + this.dropdown = React.createRef() + } + onMessageSelect = (idx, event) => { if (this.props.onMessageSelect) { const { data, selected } = this.props; @@ -20,6 +34,8 @@ export default class Message extends React.Component { }; render() { + let username + const { idx, data, @@ -36,7 +52,7 @@ export default class Message extends React.Component { const unread = data.unread ? (
) : null; - const { message } = data; + const { message, group, from} = data; let content; if (message.type === 'image') { @@ -66,6 +82,11 @@ export default class Message extends React.Component { } else if (word.length <= 2 && /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/.test(word)) { spans.push({word}); spans.push(' '); + } else if (word.length > 3 && accountNameRegEx.test(word)) { + const sess = session.load() + if (sess && !username) username = sess[0] + spans.push({word}) + spans.push(' ') } else { spans.push(word + ' '); } @@ -100,13 +121,49 @@ export default class Message extends React.Component { adds.unshift(unread) } + let author + let avatar + if (!isMine && group) { + const { authorAcc } = this.props + const isBanned = authorAcc && authorAcc.member_type === 'banned' + + if (startsSequence) { + author =
{ + e.preventDefault() + e.stopPropagation() + this.dropdown.current.click() + }}> + {from} +
+ + avatar = } + transition={Fade} + > + + + + } + + avatar =
+ {avatar} +
+ } + return (
+ ].join(' ')} id={'msgs-' + data.nonce}> { showTimestamp &&
@@ -115,8 +172,10 @@ export default class Message extends React.Component { }
+ {avatar} {isMine ? adds : null}
this.onMessageSelect(idx, event)} title={friendlyDate + (modified ? tt('g.modified') : '')}> + {author} { quoteHeader } { content }
@@ -126,3 +185,18 @@ export default class Message extends React.Component { ); } } + +export default connect( + (state, ownProps) => { + const accounts = state.global.get('accounts') + + let authorAcc = ownProps.data && accounts.get(ownProps.data.from) + authorAcc = authorAcc ? authorAcc.toJS() : null + + return { + authorAcc, + } + }, + dispatch => ({ + }), +)(Message) diff --git a/src/components/elements/messages/StartPanel/StartPanel.scss b/src/components/elements/messages/StartPanel/StartPanel.scss new file mode 100644 index 000000000..49d2b8919 --- /dev/null +++ b/src/components/elements/messages/StartPanel/StartPanel.scss @@ -0,0 +1,14 @@ +.msgs-start-panel { + .button { + display: block; + width: 100%; + &.last-button { + margin-bottom: 0.15rem; + } + + .btn-title { + margin-left: 5px; + vertical-align: middle; + } + } +} diff --git a/src/components/elements/messages/StartPanel/index.jsx b/src/components/elements/messages/StartPanel/index.jsx new file mode 100644 index 000000000..0a593544b --- /dev/null +++ b/src/components/elements/messages/StartPanel/index.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import tt from 'counterpart' +import {connect} from 'react-redux' + +import Icon from 'app/components/elements/Icon' +import user from 'app/redux/UserReducer' +import './StartPanel.scss' + +class StartPanel extends React.Component { + constructor(props) { + super(props) + this.state = { + } + } + + startChat = (e) => { + e.preventDefault() + const inp = document.getElementsByClassName('conversation-search-input') + if (!inp.length) { + console.error('startChat - no conversation-search-input') + return + } + if (inp.length > 1) { + console.error('startChat - multiple conversation-search-input:', inp) + return + } + inp[0].focus() + } + + goCreateGroup = (e) => { + e.preventDefault() + this.props.showCreateGroup() + } + + render() { + return ( +
+ +
+ + +
+
+ ) + } +} + +export default connect( + (state, ownProps) => { + return { ...ownProps } + }, + dispatch => ({ + showCreateGroup() { + dispatch(user.actions.showCreateGroup({ redirectAfter: true })) + }, + }) +)(StartPanel) diff --git a/src/components/elements/messages/Stepper/Stepper.scss b/src/components/elements/messages/Stepper/Stepper.scss new file mode 100644 index 000000000..ea322122e --- /dev/null +++ b/src/components/elements/messages/Stepper/Stepper.scss @@ -0,0 +1,32 @@ +.Stepper { + width: 100%; + + .step { + display: inline-block; + text-align: center; + color: gray; + font-size: 90%; + .bar { + background-color: gray; + height: 8px; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + } + + &.left { + .bar { + background-color: #0078C4; + } + cursor: pointer; + &:hover { + color: #0078C4; + } + } + &.current { + color: #0078C4; + .bar { + background-color: #0078C4; + } + } + } +} diff --git a/src/components/elements/messages/Stepper/index.jsx b/src/components/elements/messages/Stepper/index.jsx new file mode 100644 index 000000000..eced88131 --- /dev/null +++ b/src/components/elements/messages/Stepper/index.jsx @@ -0,0 +1,73 @@ +import React from 'react' + +import './Stepper.scss' + +class Stepper extends React.Component { + constructor(props) { + super(props) + const { steps, startStep } = this.props + const entr = Object.entries(steps) + this.state = { + currentStep: startStep || entr[0][0] + } + } + + _goToStep = (step) => { // TODO: private, if make public - check step exists + this.setState({ + currentStep: step + }, () => { + const { onStep } = this.props + if (onStep) { + onStep({ step }) + } + }) + } + + nextStep = () => { + const { steps } = this.props + const entr = Object.entries(steps) + const { currentStep } = this.state + let found + for (const [key, content] of entr) { + if (found) { + this._goToStep(key) + return key + } + found = key === currentStep + } + return currentStep + } + + render() { + const { steps } = this.props + let { currentStep } = this.state + + const entr = Object.entries(steps) + currentStep = currentStep || entr[0][0] + const width = (100 / entr.length).toFixed(1) + const stepObjs = [] + let foundCurrent + for (const [key, content] of entr) { + const isCurrent = key === currentStep + foundCurrent = foundCurrent || isCurrent + const cn = foundCurrent ? (isCurrent ? 'current' : '') : 'left' + let onClick + if (!foundCurrent) { + onClick = (e) => { + e.preventDefault() + this._goToStep(key) + } + } + stepObjs.push(
+
+ {content} +
) + } + + return
+ {stepObjs} +
+ } +} + +export default Stepper diff --git a/src/components/elements/messages/ToolbarButton/ToolbarButton.css b/src/components/elements/messages/ToolbarButton/ToolbarButton.css index ce54fbd5a..af71bb357 100644 --- a/src/components/elements/messages/ToolbarButton/ToolbarButton.css +++ b/src/components/elements/messages/ToolbarButton/ToolbarButton.css @@ -4,12 +4,12 @@ transition: all 0.1s; } -.msgs-toolbar-button:hover { +.msgs-toolbar-button:hover:not(.disabled) { cursor: pointer; color: #0063ce; } -.msgs-toolbar-button:active { +.msgs-toolbar-button:active:not(.disabled) { color: #007aff; opacity: 0.25; } diff --git a/src/components/modules/AccountDropdown.jsx b/src/components/modules/AccountDropdown.jsx new file mode 100644 index 000000000..fd886540c --- /dev/null +++ b/src/components/modules/AccountDropdown.jsx @@ -0,0 +1,134 @@ +import React from 'react' +import { connect } from 'react-redux' +import tt from 'counterpart' + +import DialogManager from 'app/components/elements/common/DialogManager' +import VerticalMenu from 'app/components/elements/VerticalMenu' +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import { maxDateStr, isBlockedByMe } from 'app/utils/misc' + +class AccountDropdown extends React.Component { + constructor(props) { + super(props) + } + + block = (e, block = true) => { + e.preventDefault() + const { username, toAcc } = this.props + this.props.updateBlock({ blocker: username, blocking: toAcc, block, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + + clearAll = async (e, delete_contact = false) => { + e.preventDefault() + + const { username, messages, toAcc } = this.props + const conf = delete_contact ? + (messages.length ? tt('account_dropdown_jsx.are_you_sure_delete') : + tt('account_dropdown_jsx.are_you_sure_delete_empty')) : + tt('account_dropdown_jsx.are_you_sure') + const res = await DialogManager.dangerConfirm(
+ {conf}@{toAcc}?
, + 'GOLOS Messenger') + if (!res) return + + this.props.clearAll({ from: username, to: toAcc, delete_contact, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + + render() { + const { messages, contacts, accounts, toAcc } = this.props + let menuItems = [ + {link: '/@' + toAcc, extLink: 'blogs', label: {'@' + toAcc}, value: toAcc }, + ] + + const acc = accounts[toAcc] + const blocking = isBlockedByMe(acc) + menuItems.push({link: '#', onClick: e => this.block(e, !blocking), icon: 'ionicons/ban', value: blocking ? tt('g.unblock') : tt('g.block') },) + + if (messages.length) { + menuItems.push({link: '#', onClick: e => this.clearAll(e), icon: 'clock', value: tt('account_dropdown_jsx.clear_history') }) + } + + const hasContact = contacts.filter(c => c.contact === toAcc && c.kind === 'account').length + if (hasContact) { + menuItems.push({link: '#', onClick: e => this.clearAll(e, true), icon: 'ionicons/trash-outline', value: 'clear_all', label: tt('account_dropdown_jsx.delete_chat') },) + } + + return + } +} + +export default connect( + (state, ownProps) => { + const username = state.user.getIn(['current', 'username']) + const messages = state.global.get('messages') + const contacts = state.global.get('contacts') + const accounts = state.global.get('accounts') + return { + username, + messages: messages ? messages.toJS() : [], + contacts: contacts ? contacts.toJS() : [], + accounts: accounts ? accounts.toJS() : {}, + } + }, + dispatch => ({ + updateBlock: ({ blocker, blocking, block, onError }) => { + dispatch(transaction.actions.broadcastOperation({ + type: 'account_setup', + operation: { + account: blocker, + settings: [ + [0, { + account: blocking, + block + }] + ], + extensions: [] + }, + successCallback: () => { + dispatch(g.actions.updateBlocking({ blocker, blocking, block })) + }, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })) + }, + clearAll: ({ from, to, delete_contact, onError }) => { + const newest_date = maxDateStr() + const json = JSON.stringify(['private_delete_message', { + requester: from, + from, + to, + start_date: '1970-01-01T00:00:00', + stop_date: newest_date, + nonce: 0, + extensions: [[1, { + delete_contact + }]] + }]) + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: 'private_message', + required_posting_auths: [from], + json, + }, + successCallback: null, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })) + } + }), +)(AccountDropdown) diff --git a/src/components/modules/AccountDropdown.scss b/src/components/modules/AccountDropdown.scss new file mode 100644 index 000000000..1cfbdcb27 --- /dev/null +++ b/src/components/modules/AccountDropdown.scss @@ -0,0 +1,26 @@ +.AccountDropdown { + backdrop-filter: blur(10px) !important; + border-radius: 8px !important; + + .VerticalMenu { + width: auto; + li[data-value="clear_all"] { + a { + color: #fc544e !important; + font-weight: 450; + } + } + } +} + +.theme-light { + .AccountDropdown { + background-color: rgba(255, 255, 255, 0.75) !important; + } +} + +.theme-dark { + .AccountDropdown { + background-color: rgba(40, 51, 54, 0.75) !important; + } +} diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx new file mode 100644 index 000000000..de050982c --- /dev/null +++ b/src/components/modules/CreateGroup.jsx @@ -0,0 +1,397 @@ +import React from 'react' +import {connect} from 'react-redux' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Map } from 'immutable' +import { api } from 'golos-lib-js' +import { Asset, Price, AssetEditor } from 'golos-lib-js/lib/utils' +import tt from 'counterpart' + +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import user from 'app/redux/UserReducer' +import ExtLink from 'app/components/elements/ExtLink' +import Icon from 'app/components/elements/Icon' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import FormikAgent from 'app/components/elements/donate/FormikUtils' +import Stepper from 'app/components/elements/messages/Stepper' +import GroupName, { validateNameStep } from 'app/components/modules/groups/GroupName' +import GroupLogo, { validateLogoStep } from 'app/components/modules/groups/GroupLogo' +import GroupMembers, { validateMembersStep } from 'app/components/modules/groups/GroupMembers' +import GroupFinal from 'app/components/modules/groups/GroupFinal' +import DialogManager from 'app/components/elements/common/DialogManager' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import isScreenSmall from 'app/utils/isScreenSmall' + +const STEPS = () => { + const isSmall = isScreenSmall() + return { + name: tt('create_group_jsx.step_name'), + logo: isSmall ? tt('create_group_jsx.step_logo_mobile') : tt('create_group_jsx.step_logo'), + members: isSmall ? tt('create_group_jsx.step_members_mobile') : tt('create_group_jsx.step_members'), + final: isSmall ? tt('create_group_jsx.step_create_mobile') : tt('create_group_jsx.step_create') + } +} + +class ActionOnUnmount extends React.Component { + componentWillUnmount() { + const { values, stripGroupMembers } = this.props + if (!values || !stripGroupMembers) { + console.warn('ActionOnUnmount rendered without req props') + return + } + const { name } = values + if (name) { + this.props.stripGroupMembers(name) + } + } + render () { + return null + } +} + +class CreateGroup extends React.Component { + constructor(props) { + super(props) + this.state = { + step: 'name', + validators: 0, + initialValues: { + creatingNew: true, + + title: '', + name: '', + is_encrypted: true, + privacy: 'public_group', + + logo: '', + } + } + this.stepperRef = React.createRef() + } + + componentDidMount = async () => { + try { + const dgp = await api.getChainPropertiesAsync() + const { private_group_cost } = dgp + const cost = await Asset(private_group_cost) + + let acc = await api.getAccountsAsync([this.props.currentAccount.get('name')]) + acc = acc[0] + const { sbd_balance } = acc + const gbgBalance = Asset(sbd_balance) + if (gbgBalance.gte(cost)) { + this.setState({ + cost, + loaded: true + }) + return + } + + const delta = cost.minus(gbgBalance) + this.setState({ + loaded: true, + createError: { + cost, + gbgBalance, + delta, + accName: acc.name + } + }) + } catch (err) { + console.error(err) + this.setState({ + loaded: true, + createError: { + message: err.message + } + }) + } + } + + setValidating = async (validating) => { + return new Promise(resolve => { + this.setState({ + validators: this.state.validators + (validating ? 1 : -1), + }, () => { + resolve() + }) + }) + } + + validate = async (values) => { + const errors = {} + const { step } = this.state + await this.setValidating(true) + if (step === 'name') { + await validateNameStep(values, errors) + } else if (step === 'logo') { + await validateLogoStep(values, errors) + } else if (step === 'members') { + await validateMembersStep(values, errors) + } + await this.setValidating(false) + return errors + } + + _onSubmit = (data, actions) => { + const { currentUser, redirectAfter } = this.props + const creator = currentUser.get('username') + data.creator = creator + + this.setState({ + submitError: '' + }) + + let members = [] + const { name } = data + let { groups } = this.props + if (groups) { + groups = groups.toJS() + const group = groups[name] + if (group) { + let mems = group.members + if (mems) { + members = mems.data + } + } + } + + showLoginDialog(creator, (res) => { + const password = res && res.password + if (!password) { + actions.setSubmitting(false) + return + } + try { + const finalSuccess = () => { + actions.setSubmitting(false) + const { closeMe } = this.props + if (closeMe) closeMe() + if (redirectAfter) { + window.location.href = '/' + data.name + return + } + } + this.props.privateGroup({ + password, + ...data, + onSuccess: () => { + try { + if (!members.length) { + finalSuccess() + return + } + + this.props.groupMembers({ + requester: data.creator, + name: data.name, + members, + onSuccess: () => { + finalSuccess() + }, + onError: (err, errStr) => { + this.setState({ submitError: { + type: 'members', err: errStr } }) + actions.setSubmitting(false) + } + }) + } catch (err) { + this.setState({ submitError: { + type: 'members', err: err.toString() } }) + actions.setSubmitting(false) + } + }, + onError: (err, errStr) => { + this.setState({ submitError: errStr }) + actions.setSubmitting(false) + } + }) + } catch (err) { + this.setState({ submitError: err.toString() }) + actions.setSubmitting(false) + } + }, 'active') + } + + goNext = (e, setFieldValue) => { + const { step } = this.state + if (step === 'final') { + return + } + e.preventDefault() + this.stepperRef.current.nextStep() + } + + onStep = ({ step }) => { + this.setState({ + step + }) + } + + render() { + const { step, loaded, createError, validators, submitError, cost } = this.state + + let form + if (!loaded) { + form =
+ +
+ } else if (createError) { + const { message, cost, gbgBalance, delta, accName } = createError + if (message) { + form =
+ {message} +
+ } else { + form =
+ {tt('create_group_jsx.gbg_too_low') + cost.floatString + '. '}
+ {tt('create_group_jsx.gbg_too_low2')} + {delta.floatString}.
+ + + +
+ } + } else + form = ( + {({ + handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, + }) => { + let disabled = !isValid || !!validators || !values.name + if (submitError && submitError.type === 'members') { + disabled = true + } + return ( +
+ + {!isSubmitting ? (step === 'name' ? : + step === 'logo' ? : + step === 'members' ? : + step === 'final' ? : + ) : null} + + {!isSubmitting && } + {/*submitError &&
{submitError}
*/} + {isSubmitting ?
+ : + + } + + + )}}
) + + return
+
+

{tt('msgs_start_panel.create_group')}

+
+ {form} +
+ } +} + +export default connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]) + + const groups = state.global.get('groups') + + return { ...ownProps, + currentUser, + currentAccount, + groups, + redirectAfter: state.user.get('create_group_redirect_after'), + } + }, + dispatch => ({ + stripGroupMembers: (group) => { + dispatch(g.actions.receiveGroupMembers({ + group, members: [], append: false })) + }, + privateGroup: ({ password, creator, name, title, logo, is_encrypted, privacy, + onSuccess, onError }) => { + const trx = [] + let json_metadata, opData, json + + json_metadata = { + app: 'golos-messenger', + version: 1, + title, + logo + } + json_metadata = JSON.stringify(json_metadata) + opData = { + creator, + name, + json_metadata, + is_encrypted, + privacy, + extensions: [], + } + json = JSON.stringify(['private_group', opData]) + trx.push(['custom_json', { + id: 'private_message', + required_auths: [creator], + json, + }]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + trx, + username: creator, + keys: [password], + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + groupMembers: ({ requester, name, members, + onSuccess, onError }) => { + const trx = [] + let opData, json + + for (const mem of members) { + const { account, member_type, json_metadata } = mem + opData = { + requester, + name, + member: account, + member_type, + json_metadata: '{}', + extensions: [], + } + json = JSON.stringify(['private_group_member', opData]) + trx.push(['custom_json', { + id: 'private_message', + required_posting_auths: [requester], + json, + }]) + } + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + trx, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + }) +)(CreateGroup) diff --git a/src/components/modules/CreateGroup.scss b/src/components/modules/CreateGroup.scss new file mode 100644 index 000000000..93391d0b6 --- /dev/null +++ b/src/components/modules/CreateGroup.scss @@ -0,0 +1,48 @@ +.CreateGroup { + h3 { + padding-left: 0.75rem; + } + .next-button { + width: 48px; + height: 48px; + padding-left: 13px !important; + color: white; + position: absolute; + bottom: 0.4rem; + right: 0.5rem; + box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.4); + } + .Stepper { + width: 85%; + margin-left: 1rem; + } + .icon-hint { + margin-left: 0.25rem; + } + .column { + padding-right: 0.5rem !important; + } + .error { + margin-bottom: 0px !important; + margin-top: 0.5rem; + } + .submit-error { + margin-left: 1rem; + max-width: 83%; + } + + .image-loader { + margin-top: 14px; + } + + .image-preview { + max-width: 75px; + max-height: 75px; + margin-top: 0.75rem; + border: none; + } + .submit-loader { + margin-left: 0.5rem; + margin-top: 0.5rem; + } +} diff --git a/src/components/modules/Donate.jsx b/src/components/modules/Donate.jsx index 496ed2fe5..a7d51de16 100644 --- a/src/components/modules/Donate.jsx +++ b/src/components/modules/Donate.jsx @@ -49,11 +49,14 @@ class Donate extends React.Component { const { sym } = opts if (sym === 'GOLOS') { if (currentAccount) { - res = Asset(currentAccount.get('tip_balance')) + const tip = currentAccount.get('tip_balance') + if (tip) { + res = Asset(tip) + } } } else { const uias = this.props.uias && this.props.uias.toJS() - if (uias) { + if (uias && uias[sym]) { res = Asset(uias[sym].tip_balance) } } @@ -88,13 +91,13 @@ class Donate extends React.Component { _onSubmit = (values, actions) => { const { currentUser, opts, dispatchSubmit } = this.props - const { from, to, nonce } = opts + const { group, from, to, nonce } = opts this.setState({ activeConfetti: true }) setTimeout(() => { dispatchSubmit({ - message: { from, to, nonce }, + message: { group, from, to, nonce }, amount: values.amount.asset, currentUser, errorCallback: (err) => { @@ -110,6 +113,8 @@ class Donate extends React.Component { const { sym } = opts const { activeConfetti } = this.state + const balVal = this.balanceValue() + const form = ( this.onPresetChange(amountStr, values, setFieldValue)} /> - + /> : null}
@@ -211,7 +216,7 @@ export default connect( dispatchSubmit: ({ message, amount, currentUser, errorCallback }) => { - const { from, to, nonce } = message + const { group, from, to, nonce } = message const username = currentUser.get('username') @@ -220,9 +225,9 @@ export default connect( } operation.memo = { - app: 'golos-messenger', version: 1, comment: '', + app: 'golos-messenger', version: 2, comment: '', target: { - from, to, nonce: nonce.toString() + group, from, to, nonce: nonce.toString() } } diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx new file mode 100644 index 000000000..a4744ff6d --- /dev/null +++ b/src/components/modules/MessagesTopCenter.jsx @@ -0,0 +1,440 @@ +import React from 'react' +import {connect} from 'react-redux' +import { withRouter } from 'react-router' +import { Link } from 'react-router-dom' +import { Fade } from 'react-foundation-components/lib/global/fade' +import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdown' +import tt from 'counterpart' +import cn from 'classnames' + +import DialogManager from 'app/components/elements/common/DialogManager' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import DropdownMenu from 'app/components/elements/DropdownMenu' +import Icon from 'app/components/elements/Icon' +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import AccountDropdown from 'app/components/modules/AccountDropdown' +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import user from 'app/redux/UserReducer' +import { getMemberType, getGroupLogo, getGroupMeta, getGroupTitle, } from 'app/utils/groups' +import { getLastSeen } from 'app/utils/NormalizeProfile' + +class MessagesTopCenter extends React.Component { + constructor(props) { + super(props) + this.state = { + } + this.dropdown = React.createRef() + } + + openDropdown = (e) => { + let isInside = false + let node = e.target + while (node.parentNode) { + if (node.className.includes('msgs-group-dropdown') || node.className.includes('AccountDropdown')) { + isInside = true + return + } + node = node.parentNode + } + if (isInside) return + e.preventDefault() + this.dropdown.current.click() + } + + closeDropdown = (e) => { + e.preventDefault() + this.dropdown.current.click() + } + + showGroupMembers = (e) => { + e.preventDefault() + const { the_group } = this.props + if (!the_group) return + const { name } = the_group + this.props.showGroupMembers({ group: name }) + } + + editGroup = (e) => { + e.preventDefault() + const { the_group } = this.props + if (!the_group) return + this.props.showGroupSettings({ group: the_group }) + } + + deleteGroup = (e, title) => { + e.preventDefault() + const { history, the_group } = this.props + if (!the_group) return + showLoginDialog(the_group.owner, (res) => { + const password = res && res.password + if (!password) { + return + } + this.props.deleteGroup({ + owner: the_group.owner, + name: the_group.name, + password, + onSuccess: () => { + if (!history || !history.push) { + console.error('No react-router history', history) + return + } + history.push('/') + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + }, 'active', false, tt('my_groups_jsx.login_hint_GROUP', { + GROUP: title + })) + } + + _renderGroupDropdown = () => { + const { the_group, username } = this.props + if (!the_group) { + return null + } + + const { name, json_metadata, privacy, is_encrypted, + owner, member_list, members, moders } = the_group + const logo = getGroupLogo(json_metadata).url + + const meta = getGroupMeta(json_metadata) + const title = getGroupTitle(meta, name) + + const totalMembers = members + moders + + let groupType + if (privacy === 'public_group') { + groupType = tt('msgs_group_dropdown.public') + } else if (privacy === 'public_read_only') { + groupType = tt('msgs_group_dropdown.read_only') + } else { + groupType = tt('msgs_group_dropdown.private') + } + groupType =
{groupType}
+ + let myStatus = null + let btnType + let showKebab, isOwner, banned + if (owner === username) { + myStatus = tt('msgs_group_dropdown.owner') + showKebab = true + } else { + const member_type = getMemberType(member_list, username) + + const isMember = member_type === 'member' + const isModer = member_type === 'moder' + + if (!member_type) { + btnType = 'join' + } else if (isModer) { + myStatus = tt('msgs_group_dropdown.moder') + btnType = 'retire' + } else if (isMember) { + btnType = 'retire' + } else if (member_type === 'banned') { + myStatus = tt('msgs_group_dropdown.banned') + banned = true + btnType = 'disabled' + } else if (member_type === 'pending') { + myStatus = tt('msgs_group_dropdown.pending') + btnType = 'cancel' + } + } + if (myStatus) { + myStatus =
{myStatus}
+ } + + let btn + if (btnType) { + const onBtnClick = async (e) => { + e.preventDefault() + + if (btnType === 'retire') { + this.closeDropdown(e) + + let retireWarning + if (privacy !== 'public_group') { + retireWarning =
{tt('msgs_group_dropdown.joining_back_will_require_approval')}
+ } + + const res = await DialogManager.dangerConfirm(
+ {tt('msgs_group_dropdown.are_you_sure_retire') + ' ' + title + '?'}{retireWarning}
, + 'GOLOS Messenger') + if (!res) return + } else { + setTimeout(() => { + this.closeDropdown(e) + }, 500) + } + + const groupPublic = privacy === 'public_group' + const member_type = btnType === 'join' ? (groupPublic ? 'member' : 'pending') : 'retired' + this.props.groupMember({ + requester: username, group: name, + member: username, + member_type, + onSuccess: () => { + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + + if (btnType === 'join') { + btn = + } else { + let btnTitle = tt('msgs_group_dropdown.retire') + if (btnType === 'cancel') { + btnTitle = tt('msgs_group_dropdown.cancel') + } + btn = + } + } + + let kebabItems = [ + { link: '#', value: tt('g.edit'), onClick: this.editGroup }, + { link: '#', value: tt('g.delete'), onClick: e => this.deleteGroup(e, title) }, + ] + + const lock = + + return
+ +
+ {title} {lock} +
+ {groupType} + {myStatus} +
+ {showKebab ? + + : null} + + {btn} +
+
+ } + + showErrorLogs = (e) => { + e.preventDefault() + e.stopPropagation() + try { + const { errorLogs } = this.props + let msg = '' + for (const err of errorLogs) { + msg += (err.err ? err.err.toString() : '') + '\n' + JSON.stringify(err.details) + '\n\n' + } + alert(msg) + } catch (err) { + alert('Cannot display error logs, due to: ' + (err && err.toString())) + } + } + + refreshSync = e => { + this.setState({ refreshing: true }) + e.preventDefault() + e.stopPropagation() + this.props.fetchState(this.props.to) + setTimeout(() => { + this.setState({ refreshing: false }) + }, 500) + } + + render() { + let avatar = [] + let items = [] + + const { to, toAcc, isSmall, notifyErrors, the_group } = this.props + + const isGroup = to && !to.startsWith('@') + + let checkmark + if (to === '@notify') { + checkmark = + + + } + + if (isGroup) { + if (the_group) { + const { json_metadata } = the_group + const logo = getGroupLogo(json_metadata).url + avatar.push(
+ +
) + } + items.push(
+ + {to} + + {checkmark} +
) + } else { + items.push(
+ } + transition={Fade} + > + {to} + + {checkmark} +
) + } + + if (notifyErrors >= 30) { + const { refreshing } = this.state + items.push(
+ {isSmall ? + + {tt('messages.sync_error_short')} + + {refreshing ? '...' : tt('g.refresh')}. + + : + {tt('messages.sync_error')} + } +
) + } else { + const secondStyle = {fontSize: '13px', fontWeight: 'normal'} + if (!isGroup) { + const { accounts } = this.props + const acc = accounts[toAcc] + let lastSeen = acc && getLastSeen(acc) + if (lastSeen) { + items.push(
+ { + + {tt('messages.last_seen')} + + + } +
) + } + } else { + const { the_group } = this.props + if (the_group) { + const totalMembers = the_group.members + the_group.moders + items.push(
+ {tt('plurals.member_count', { + count: totalMembers + })} +
) + } + } + } + + return
+
{avatar}
+
{items}
+
+ } +} + +export default withRouter(connect( + (state, ownProps) => { + const currentUser = state.user.get('current') + const accounts = state.global.get('accounts') + + const username = state.user.getIn(['current', 'username']) + + let the_group = state.global.get('the_group') + if (the_group && the_group.toJS) the_group = the_group.toJS() + + return { + the_group, + account: currentUser && accounts && accounts.toJS()[currentUser.get('username')], + currentUser, + accounts: accounts ? accounts.toJS() : {}, + username, + } + }, + dispatch => ({ + groupMember: ({ requester, group, member, member_type, + onSuccess, onError }) => { + const opData = { + requester, + name: group, + member, + member_type, + json_metadata: '{}', + extensions: [], + } + + const plugin = 'private_message' + const json = JSON.stringify(['private_group_member', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: plugin, + required_posting_auths: [requester], + json, + }, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + showGroupMembers({ group }) { + dispatch(user.actions.showGroupMembers({ group: ['the_group', group] })) + }, + showGroupSettings({ group }) { + dispatch(user.actions.showGroupSettings({ group })) + }, + deleteGroup: ({ owner, name, password, + onSuccess, onError }) => { + const opData = { + owner, + name, + extensions: [], + } + + const json = JSON.stringify(['private_group_delete', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: 'private_message', + required_auths: [owner], + json, + }, + username: owner, + keys: [password], + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + } + }), +)(MessagesTopCenter)) diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss new file mode 100644 index 000000000..7cc3d9712 --- /dev/null +++ b/src/components/modules/MessagesTopCenter.scss @@ -0,0 +1,86 @@ +.MessagesTopCenter { + &.clickable { + cursor: pointer; + } + + .avatar-items { + display: inline-block; + vertical-align: top; + margin-top: 5px; + margin-right: 7px; + + .group-logo { + cursor: pointer; + img { + width: 32px; + height: 32px; + border-radius: 50%; + } + } + } + + .main-items { + display: inline-block; + .to-group { + cursor: pointer; + color: #0078C4; + } + .group-stats { + cursor: pointer; + } + } +} + +.GroupDropdown { + backdrop-filter: blur(10px) !important; + border-radius: 8px !important; +} +.theme-light { + .GroupDropdown { + background-color: rgba(255, 255, 255, 0.7) !important; + } +} +.theme-dark { + .GroupDropdown { + background-color: rgba(40, 51, 54, 0.7) !important; + } +} + +.msgs-group-dropdown { + .logo { + margin-top: 1rem; + width: 100%; + max-height: 100px; + object-fit: contain; + } + .title { + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 0.5rem; + margin-bottom: 0.15rem; + } + .group-type { + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-bottom: 0.05rem; + } + .buttons { + margin-top: 0.5rem; + padding-left: 0.75rem; + padding-bottom: 0.75rem; + padding-right: 0.75rem; + min-width: 200px; + + .button { + margin-right: 0px !important; + &.margin { + margin-right: 7px !important; + } + margin-bottom: 0.75rem; + } + .DropdownMenu { + margin-left: 0.25rem; + margin-top: 0.25rem; + } + } +} diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index 8fcb103c1..f4bbcba82 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -2,9 +2,15 @@ import React from 'react'; import PropTypes from 'prop-types' import {NotificationStack} from 'react-notification' import { connect } from 'react-redux'; +import { withRouter } from 'react-router' import CloseButton from 'react-foundation-components/lib/global/close-button'; import Reveal from 'react-foundation-components/lib/global/reveal'; +import CreateGroup from 'app/components/modules/CreateGroup' +import GroupSettings from 'app/components/modules/groups/GroupSettings' +import GroupMembers from 'app/components/modules/groups/GroupMembers' +import MyGroups from 'app/components/modules/groups/MyGroups' +import TopGroups from 'app/components/modules/groups/TopGroups' import Donate from 'app/components/modules/Donate' import LoginForm from 'app/components/modules/LoginForm'; import AppDownload from 'app/components/modules/app/AppDownload' @@ -17,6 +23,11 @@ class Modals extends React.Component { static propTypes = { show_login_modal: PropTypes.bool, show_donate_modal: PropTypes.bool, + show_create_group_modal: PropTypes.bool, + show_my_groups_modal: PropTypes.bool, + show_top_groups_modal: PropTypes.bool, + show_group_settings_modal: PropTypes.bool, + show_group_members_modal: PropTypes.bool, show_app_download_modal: PropTypes.bool, hideDonate: PropTypes.func.isRequired, hideAppDownload: PropTypes.func.isRequired, @@ -27,16 +38,30 @@ class Modals extends React.Component { onLoginBackdropClick = (e) => { const { loginUnclosable } = this.props; if (loginUnclosable) - throw new Error('Closing login modal is forbidden here'); + this.onUnclosableClick(e) }; + onUnclosableClick = (e) => { + throw new Error('Closing modal is forbidden here') + } + render() { const { show_login_modal, show_donate_modal, + show_create_group_modal, + show_my_groups_modal, + show_top_groups_modal, + show_group_settings_modal, + show_group_members_modal, show_app_download_modal, hideLogin, hideDonate, + hideCreateGroup, + hideMyGroups, + hideTopGroups, + hideGroupSettings, + hideGroupMembers, hideAppDownload, notifications, removeNotification, @@ -51,16 +76,62 @@ class Modals extends React.Component { return n; }) : []; + const modalStyle = { + borderRadius: '8px', + boxShadow: '0 0 19px 3px rgba(0,0,0, 0.2)', + overflow: 'hidden', + } + + const doHideLogin = (e) => { + const goBack = () => { + const { history, } = this.props + if (history.action !== 'POP') { + history.goBack() + } else { + history.push('/') + } + } + hideLogin(e, goBack) + } + return (
- {show_login_modal && - + {show_login_modal && + } - {show_donate_modal && + {show_donate_modal && } - {show_app_download_modal && + {show_create_group_modal && + + + } + {show_my_groups_modal && + + + } + {show_top_groups_modal && + + + } + {show_group_settings_modal && + + + } + {show_group_members_modal && + + + } + {show_app_download_modal && } @@ -74,27 +145,55 @@ class Modals extends React.Component { } } -export default connect( +export default withRouter(connect( state => { const loginDefault = state.user.get('loginDefault'); const loginUnclosable = loginDefault && loginDefault.get('unclosable'); return { show_login_modal: state.user.get('show_login_modal'), show_donate_modal: state.user.get('show_donate_modal'), + show_create_group_modal: state.user.get('show_create_group_modal'), + show_my_groups_modal: state.user.get('show_my_groups_modal'), + show_top_groups_modal: state.user.get('show_top_groups_modal'), + show_group_settings_modal: state.user.get('show_group_settings_modal'), + show_group_members_modal: state.user.get('show_group_members_modal'), show_app_download_modal: state.user.get('show_app_download_modal'), loginUnclosable, notifications: state.app.get('notifications'), } }, dispatch => ({ - hideLogin: e => { + hideLogin: (e, goBack) => { if (e) e.preventDefault(); + if (goBack) { + goBack() + } dispatch(user.actions.hideLogin()) }, hideDonate: e => { if (e) e.preventDefault() dispatch(user.actions.hideDonate()) }, + hideCreateGroup: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideCreateGroup()) + }, + hideMyGroups: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideMyGroups()) + }, + hideTopGroups: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideTopGroups()) + }, + hideGroupSettings: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideGroupSettings()) + }, + hideGroupMembers: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideGroupMembers()) + }, hideAppDownload: e => { if (e) e.preventDefault() dispatch(user.actions.hideAppDownload()) @@ -104,4 +203,4 @@ export default connect( removeNotification: (key) => dispatch({type: 'REMOVE_NOTIFICATION', payload: {key}}), }) -)(Modals) +)(Modals)) diff --git a/src/components/modules/groups/GroupFinal.jsx b/src/components/modules/groups/GroupFinal.jsx new file mode 100644 index 000000000..8954093c9 --- /dev/null +++ b/src/components/modules/groups/GroupFinal.jsx @@ -0,0 +1,116 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' + +import GroupCost from 'app/components/elements/groups/GroupCost' +import ExtLink from 'app/components/elements/ExtLink' + +class GroupFinal extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + decorateSubmitError = (error) => { + if (error && error.type === 'members') { + return + {tt('create_group_jsx.cannot_set_members')}
+ {tt('create_group_jsx.cannot_set_members2')}
+
+ {error.err} +
+ } + if (error && error.startsWith && error.startsWith(tt('donate_jsx.insufficient_funds'))) { + const { username } = this.props + return + {error} + + + + + } + return error + } + + render() { + let { group, submitError, cost } = this.props + let moders = [], members = [] + if (group) { + let allMembers = group.get('members') + if (allMembers) { + allMembers = allMembers.get('data').toJS() + const makeLink = (pgm) => { + return + {'@' + pgm.account} + + } + const addCommas = (arr) => { + return arr.reduce((list, elem, i) => { + const { key } = elem + list.push(elem) + if (i !== arr.length - 1) { + list.push() + } + return list + }, []) + } + for (const pgm of allMembers) { + if (pgm.member_type === 'moder') { + moders.push(makeLink(pgm)) + } else { + members.push(makeLink(pgm)) + } + } + moders = addCommas(moders) + members = addCommas(members) + } + } + return +
+
+ + {tt('create_group_jsx.final_desc')} + + {moders.length ?
+ {tt('create_group_jsx.moders_list')} {moders} +
: null} + {members.length ?
+ {tt('create_group_jsx.members_list')} {members} +
: null} + {submitError ?
+ {this.decorateSubmitError(submitError)} +
: null} +
+
+ + +
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const username = currentUser && currentUser.get('username') + + const { newGroup } = ownProps + let currentGroup + if (newGroup) { + currentGroup = newGroup + } + const group = currentGroup && state.global.getIn(['groups', currentGroup.name]) + + return { + ...ownProps, + username, + group, + } + }, + dispatch => ({ + }) +)(GroupFinal) diff --git a/src/components/modules/groups/GroupLogo.jsx b/src/components/modules/groups/GroupLogo.jsx new file mode 100644 index 000000000..871065416 --- /dev/null +++ b/src/components/modules/groups/GroupLogo.jsx @@ -0,0 +1,220 @@ +import React from 'react' +import DropZone from 'react-dropzone' +import { connect } from 'react-redux' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' + +import Input from 'app/components/elements/common/Input'; +import PictureSvg from 'app/assets/icons/editor-toolbar/picture.svg'; +import DialogManager from 'app/components/elements/common/DialogManager' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import { delay } from 'app/utils/misc' +import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl'; + +export async function validateLogoStep(values, errors) { + if (values.logo) { + try { + let previewRes + const img = document.createElement('img') + const uniq = Math.random() + window._logoValidator = uniq + + img.onload = () => { + console.log('img onload') + previewRes = { ok: true } + } + img.onerror = () => { + console.log('img onerror') + previewRes = { err: 'wrong' } + } + img.src = values.logo + + const started = Date.now() + const checkTimeout = 7000 + while (true) { + const elapsed = Date.now() - started + + if (window._logoValidator !== uniq) { // Another loop started + console.log('ret', uniq) + return + } + + if (!previewRes && elapsed > checkTimeout) { + errors.logo = tt('create_group_jsx.image_timeout') + return + } + + if (previewRes) { + if (previewRes.err) { + errors.logo = tt('create_group_jsx.image_wrong') + return + } else { + return + } + } + + await delay(100) + } + } catch (err) { + console.error(err) + errors.logo = 'Unknown validation error' + } + } +} + +class GroupLogo extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + uploadLogo = (file, name) => { + const { notify, uploadImage, applyFieldValue } = this.props + this.setState({ uploading: true }) + uploadImage(file, progress => { + if (progress.url) { + applyFieldValue('logo', progress.url) + } + if (progress.error) { + const { error } = progress; + notify(error, 10000) + } + this.setState({ uploading: false }) + }) + } + + onDrop = (acceptedFiles, rejectedFiles) => { + const file = acceptedFiles[0] + + if (!file) { + if (rejectedFiles.length) { + DialogManager.alert( + tt('post_editor.please_insert_only_image_files') + ) + } + return + } + + this.uploadLogo(file, file.name) + }; + + onInputKeyDown = e => { + if (e.which === keyCodes.ENTER) { + e.preventDefault(); + //this.props.onClose({ + //e.target.value, + //}); + } + }; + + onChange = (e) => { + const { applyFieldValue} = this.props + applyFieldValue('logo', e.target.value) + } + + _renderPreview = () => { + const { values, errors, isValidating } = this.props + let { logo } = values + if (isValidating) { + return
+ +
+ } else if (logo && !errors.logo) { + const size = '75x75' // main size of Userpic + logo = proxifyImageUrlWithStrip(logo, size); + return + } + return null + } + + render() { + const { isValidating } = this.props + const { uploading, } = this.state + + const selectorStyleCover = uploading ? + { + whiteSpace: `nowrap`, + display: `flex`, + alignItems: `center`, + padding: `0 6px`, + pointerEvents: `none`, + cursor: `default`, + opacity: `0.6` + } : + { + display: `flex`, + alignItems: `center`, + padding: `0 6px` + } + + return +
+
+ {tt('create_group_jsx.logo_desc')} +
+
+
+
+ this.onDrop(af, rf)} + > + {({getRootProps, getInputProps}) => (
+ + + + {tt('create_group_jsx.logo_upload')} + +
)} +
+
+
+
+ {tt('create_group_jsx.logo_link')}: +
+ this.onChange(e)} + > + + {!isValidating && } +
+ {this._renderPreview()} +
+
+ + } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + return { + ...ownProps + } + }, + dispatch => ({ + uploadImage: (file, progress) => { + dispatch({ + type: 'user/UPLOAD_IMAGE', + payload: {file, progress}, + }) + }, + notify: (message, dismiss = 3000) => { + dispatch({type: 'ADD_NOTIFICATION', payload: { + key: 'group_logo_' + Date.now(), + message, + dismissAfter: dismiss} + }); + } + }) +)(GroupLogo) diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx new file mode 100644 index 000000000..3bbde6f79 --- /dev/null +++ b/src/components/modules/groups/GroupMembers.jsx @@ -0,0 +1,348 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' +import { validateAccountName } from 'golos-lib-js/lib/utils' +import cn from 'classnames' + +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import AccountName from 'app/components/elements/common/AccountName' +import Input from 'app/components/elements/common/Input'; +import GroupMember from 'app/components/elements/groups/GroupMember' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import MarkNotificationRead from 'app/components/elements/MarkNotificationRead' +import { getRoleInGroup, getGroupMeta, getGroupTitle } from 'app/utils/groups' + +export async function validateMembersStep(values, errors) { + // nothing yet... +} + +class GroupMembers extends React.Component { + constructor(props) { + super(props) + this.state = { + showModers: false, + showPendings: !!props.showPendings, + } + } + + componentDidMount() { + this.init() + } + + componentDidUpdate() { + this.init() + } + + isLoading = () => { + const { group } = this.props + if (!group) return true + const members = group.get('members') + if (!members) return true + return members.get('loading') + } + + init = (force = false) => { + const { initialized } = this.state + if (!initialized || force) { + const { currentGroup } = this.props + if (currentGroup) { + const group = currentGroup + + const memberTypes = ['moder'] + const sortConditions = [] + const { showPendings, showBanneds, showModers } = this.state + if (!showModers) memberTypes.push('member') + if (showPendings) { + memberTypes.push('pending') + sortConditions.push({ + member_type: 'pending', + direction: 'up' + }) + } + if (showBanneds) { + memberTypes.push('banned') + sortConditions.push({ + member_type: 'banned', + direction: 'up' + }) + } + + this.props.fetchGroupMembers(group, memberTypes, sortConditions) + this.setState({ + initialized: true + }) + } + } + } + + toggleModers = (e) => { + this.setState({ + showModers: !this.state.showModers + }, () => { + this.init(true) + }) + } + + togglePendings = (e) => { + const { checked } = e.target + this.setState({ + showPendings: checked + }, () => { + this.init(true) + }) + } + + toggleBanneds = (e) => { + const { checked } = e.target + this.setState({ + showBanneds: checked + }, () => { + this.init(true) + }) + } + + onAddAccount = (e) => { + try { + const { value } = e.target + const member = value + const member_type = 'member' + + const { username, currentGroup } = this.props + const { creatingNew } = currentGroup + const group = currentGroup.name + + if (creatingNew) { + this.props.updateGroupMember(group, member, member_type) + } else { + this.props.groupMember({ + requester: username, group, + member, + member_type, + onSuccess: () => { + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + } catch (err) { // TODO: and it is not enough :) if error in groupMember + console.error(err) + } + } + + _renderMemberTypeSwitch = () => { + const { currentGroup, } = this.props + const { moders, members, } = currentGroup + const { showModers } = this.state + const disabled = !moders + return +
+ {tt('group_members_jsx.all') + ' (' + (moders + members) + ')'} +
+ +
+ {tt('group_members_jsx.moders') + ' (' + moders + ')'} +
+
+ } + + render() { + const { currentGroup, group, username } = this.props + const loading = this.isLoading() + let members = group && group.get('members') + if (members) members = members.get('data') + if (members) members = members.toJS() + + const { creatingNew } = currentGroup + let { amOwner, amModer } = getRoleInGroup(currentGroup, username) + if (creatingNew) { + amOwner = true + amModer = true + } + + let mems + if (loading) { + mems =
+
+
+ +
+
+
+ } else { + mems = [] + for (const member of members) { + const { groupMember, updateGroupMember } = this.props + mems.push() + } + + mems = + + {mems} + +
+ + const filterAccs = new Set() + if (username) filterAccs.add(username) + for (const m of members) { + filterAccs.add(m.account) + } + + mems =
+ {amModer ?
+
+ { + this.props.receiveAccounts(accs) + }} + /> +
+
: null} +
+
+ {mems} +
+
+
+ } + + let header + if (creatingNew) { + header =
+
+ {tt('create_group_jsx.members_desc')} +
+
+ } else { + const { name, json_metadata, pendings, banneds, } = currentGroup + + const meta = getGroupMeta(json_metadata) + let title = getGroupTitle(meta, name) + + title = tt('group_members_jsx.title') + title + tt('group_members_jsx.title2') + + const { showPendings, showBanneds } = this.state + + header =
+
+
+

{title}

+
+
+ {amModer ?
+
+ + + +
+
:
+
+ {this._renderMemberTypeSwitch()} +
+
} + {(username && showPendings) ? : null} +
+ } + + return
+ {header} + {mems} +
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + const currentUser = state.user.get('current') + const username = currentUser && currentUser.get('username') + + const { newGroup } = ownProps + let currentGroup, showPendings + if (newGroup) { + currentGroup = newGroup + } else { + const options = state.user.get('group_members_modal') + if (options) { + currentGroup = options.get('group') + showPendings = options.get('show_pendings') + } + if (currentGroup) { + const [ path, name ] = currentGroup + if (path === 'the_group') { + currentGroup = state.global.get('the_group') + } else { + currentGroup = state.global.get('my_groups').find(g => g.get('name') === name) + } + if (currentGroup) currentGroup = currentGroup.toJS() + } + } + const group = currentGroup && state.global.getIn(['groups', currentGroup.name]) + return { + ...ownProps, + username, + currentGroup, + group, + showPendings, + } + }, + dispatch => ({ + fetchGroupMembers: (group, memberTypes, sortConditions) => { + dispatch(g.actions.fetchGroupMembers({ + group: group.name, creatingNew: !!group.creatingNew, memberTypes, sortConditions, })) + }, + receiveAccounts: (accs) => { + for (const acc of accs) { + dispatch(g.actions.receiveAccount({ account: acc })) + } + }, + updateGroupMember: (group, member, member_type) => { + dispatch(g.actions.updateGroupMember({ + group, member, member_type, })) + }, + groupMember: ({ requester, group, member, member_type, + onSuccess, onError }) => { + const opData = { + requester, + name: group, + member, + member_type, + json_metadata: '{}', + extensions: [], + } + + const plugin = 'private_message' + const json = JSON.stringify(['private_group_member', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: plugin, + required_posting_auths: [requester], + json, + }, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + } + }) +)(GroupMembers) diff --git a/src/components/modules/groups/GroupMembers.scss b/src/components/modules/groups/GroupMembers.scss new file mode 100644 index 000000000..4f81660e9 --- /dev/null +++ b/src/components/modules/groups/GroupMembers.scss @@ -0,0 +1,87 @@ +.GroupMembers { + .member-name { + margin-left: 0.5rem; + line-height: 40px; + vertical-align: top; + } + @include themify($themes) { + .label { + padding: 0.5rem; + transition: all .1s ease-in; + user-select: none; + + &:not(.disabled) { + cursor: pointer; + } + &:not(.checked) { + background: #f4f4f8; + color: #333333; + } + &:hover:not(.disabled) { + background: #0078C4; + color: #fefefe; + } + + &.moders { + &:hover:not(.disabled) { + background: lime; + color: #333333; + } + &.checked { + background: lime; + color: #333333; + } + } + } + + .member-btns { + float: right; + padding-right: 1rem; + + .member-btn { + cursor: pointer; + padding-top: 0.35rem; + transition: all .1s ease-in; + &.selected:not(.ban) { + cursor: auto; + } + } + .owner { + color: green; + } + .member { + color: $medium-gray; + &.selected { + color: #0078C4; + } + &:hover { + color: #0078C4; + } + } + .moder { + margin-left: 0.25rem; + color: $medium-gray; + &.selected { + color: lime; + } + &:hover { + color: lime; + } + } + .ban { + margin-left: 0.35rem; + color: $medium-gray; + &.selected { + color: red; + } + &:hover { + color: red; + } + } + .delete { + margin-left: 0.5rem; + color: red; + } + } + } +} diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx new file mode 100644 index 000000000..46c467025 --- /dev/null +++ b/src/components/modules/groups/GroupName.jsx @@ -0,0 +1,157 @@ +import React from 'react' +import { Field, ErrorMessage, } from 'formik' +import getSlug from 'speakingurl' +import tt from 'counterpart' +import { api } from 'golos-lib-js' + +import GroupCost from 'app/components/elements/groups/GroupCost' +import Icon from 'app/components/elements/Icon' + +export async function validateNameStep(values, errors) { + if (!values.title) { + errors.title = tt('g.required') + } + if (values.name) { + if (values.name.length < 3) { + errors.name = tt('create_group_jsx.group_min_length') + } else { + let group + for (let i = 0; i < 3; ++i) { + try { + console.time('group_exists') + group = await api.getGroupsAsync({ + start_group: values.name, + limit: 1 + }) + console.timeEnd('group_exists') + break + } catch (err) { + console.error(err) + errors.name = 'Blockchain unavailable :(' + } + } + if (group && group[0] && group[0].name === values.name) { + errors.name = tt('create_group_jsx.group_already_exists') + } + } + } +} + +export default class GroupName extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + onTitleChange = (e) => { + const { value } = e.target + if (value.trimLeft() !== value) { + return + } + const { applyFieldValue } = this.props + applyFieldValue('title', value) + let link = getSlug(value).substring(0, 32) + applyFieldValue('name', link) + } + + onNameChange = (e) => { + const { value } = e.target + for (let i = 0; i < value.length; ++i) { + const c = value[i] + const is_alpha = c >= 'a' && c <= 'z' + const is_digit = c >= '0' && c <= '9' + const is_dash = c == '-' + const is_ul = c == '_' + if (i == 0) { + if (!is_alpha && !is_digit) return; + } else { + if (!is_alpha && !is_digit && !is_dash && !is_ul) return; + } + } + const { applyFieldValue } = this.props + applyFieldValue('name', value) + } + + onPrivacyChange = (e) => { + const { applyFieldValue } = this.props + const { value } = e.target + applyFieldValue('privacy', value) + if (value === 'private_group') + applyFieldValue('is_encrypted', true) + } + + render() { + const { values, cost } = this.props + return +
+
+ {tt('create_group_jsx.title')} +
+
+ this.onTitleChange(e)} + autoFocus + validateOnBlur={false} + /> + +
+
+ + {(values.title || values.name) ?
+
+ {tt('create_group_jsx.name')} +
+
+ this.onNameChange(e)} + /> + +
+
: null} + +
+
+ {tt('create_group_jsx.access')} + +
+
+ this.onPrivacyChange(e)} + > + + + + + +
+
+ +
+
+ + +
+
+ + +
+ } +} diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx new file mode 100644 index 000000000..1509851c7 --- /dev/null +++ b/src/components/modules/groups/GroupSettings.jsx @@ -0,0 +1,333 @@ +import React from 'react' +import DropZone from 'react-dropzone' +import {connect} from 'react-redux' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Map } from 'immutable' +import { api, formatter } from 'golos-lib-js' +import tt from 'counterpart' + +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import user from 'app/redux/UserReducer' +import DropdownMenu from 'app/components/elements/DropdownMenu' +import ExtLink from 'app/components/elements/ExtLink' +import Icon from 'app/components/elements/Icon' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import DialogManager from 'app/components/elements/common/DialogManager' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import { validateLogoStep } from 'app/components/modules/groups/GroupLogo' +import { getGroupMeta, getGroupTitle } from 'app/utils/groups' +import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' + +class GroupSettings extends React.Component { + constructor(props) { + super(props) + this.state = { + loaded: false + } + } + + componentDidMount() { + const { currentGroup } = this.props + const group = currentGroup.toJS() + const { name, privacy, json_metadata, is_encrypted } = group + const meta = getGroupMeta(json_metadata) + const initialValues = { + name, + title: meta.title, + logo: meta.logo, + privacy, + is_encrypted, + } + this.setState({ + initialValues + }) + } + + onTitleChange = (e, { applyFieldValue }) => { + const { value } = e.target + if (value.trimLeft() !== value) { + return + } + applyFieldValue('title', value) + } + + onLogoChange = (e, { applyFieldValue }) => { + const { value } = e.target + applyFieldValue('logo', value) + } + + uploadLogo = (file, name, { applyFieldValue }) => { + const { uploadImage } = this.props + this.setState({ uploading: true }) + uploadImage(file, progress => { + if (progress.url) { + applyFieldValue('logo', progress.url) + } + if (progress.error) { + const { error } = progress; + notify(error, 10000) + } + this.setState({ uploading: false }) + }) + } + + onDrop = (acceptedFiles, rejectedFiles, { applyFieldValue }) => { + const file = acceptedFiles[0] + + if (!file) { + if (rejectedFiles.length) { + DialogManager.alert( + tt('post_editor.please_insert_only_image_files') + ) + } + return + } + + this.uploadLogo(file, file.name, { applyFieldValue }) + }; + + onPrivacyChange = (e, { applyFieldValue }) => { + applyFieldValue('privacy', e.target.value) + } + + validate = async (values) => { + const errors = {} + if (!values.title) { + errors.title = tt('g.required') + } else if (values.title.length < 3) { + errors.title = tt('create_group_jsx.group_min_length') + } + await validateLogoStep(values, errors) + return errors + } + + _onSubmit = async (values, actions) => { + const { currentUser } = this.props + const creator = currentUser.get('username') + + this.setState({ + submitError: '' + }) + + showLoginDialog(creator, (res) => { + const password = res && res.password + if (!password) { + actions.setSubmitting(false) + return + } + this.props.privateGroup({ + creator, + password, + ...values, + onSuccess: () => { + actions.setSubmitting(false) + const { closeMe } = this.props + if (closeMe) closeMe() + }, + onError: (err, errStr) => { + this.setState({ submitError: errStr }) + actions.setSubmitting(false) + } + }) + }, 'active', false) + } + + closeMe = (e) => { + e.preventDefault() + this.props.closeMe() + } + + _renderPreview = ({ values, errors }) => { + let { logo } = values + if (logo && !errors.logo) { + const size = '75x75' // main size of Userpic + logo = proxifyImageUrlWithStrip(logo, size); + return + {tt('group_settings_jsx.preview')} + + } + return null + } + + render() { + const { currentGroup } = this.props + const group = currentGroup.toJS() + const { name, json_metadata } = group + + const meta = getGroupMeta(json_metadata) + const title = getGroupTitle(meta, name) + + const { initialValues, submitError } = this.state + + let form + if (!initialValues) { + form = + } else { + form = + {({ + handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, + }) => { + const disabled = !isValid || this.state.uploading + return ( +
+
+
+ {tt('create_group_jsx.title')} + this.onTitleChange(e, { applyFieldValue })} + autoFocus + validateOnBlur={false} + /> + +
+
+ {tt('create_group_jsx.name2')}
+
+ {tt('create_group_jsx.name3')}{name} +
+
+
+ +
+
+ {tt('create_group_jsx.logo')}{this._renderPreview({ values, errors })} + this.onDrop(af, rf, { applyFieldValue })} + > + {({getRootProps, getInputProps, open}) => (
+ + this.onLogoChange(e, { applyFieldValue })} + validateOnBlur={false} + /> + {tt('group_settings_jsx.upload')} +
)} +
+ +
+
+ +
+
+ {tt('create_group_jsx.access')} + + this.onPrivacyChange(e, { applyFieldValue })} + > + + + {values.is_encrypted && } + + +
+
+ +
+
+ {tt('group_settings_jsx.encrypted')} + {values.is_encrypted ? tt('group_settings_jsx.encrypted2') : tt('group_settings_jsx.encrypted3')} +
+
+ + {submitError &&
{submitError}
} +
+ + +
+
+ )}}
+ } + + return
+
+

{tt('group_settings_jsx.title_GROUP', { + GROUP: title + })}

+
+ {form} +
+ } +} + +export default connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]) + + return { ...ownProps, + currentUser, + currentAccount, + currentGroup: state.user.get('current_group'), + } + }, + dispatch => ({ + uploadImage: (file, progress) => { + dispatch({ + type: 'user/UPLOAD_IMAGE', + payload: {file, progress}, + }) + }, + privateGroup: ({ password, creator, name, title, logo, is_encrypted, privacy, + onSuccess, onError }) => { + let json_metadata = { + app: 'golos-messenger', + version: 1, + title, + logo + } + json_metadata = JSON.stringify(json_metadata) + + const opData = { + creator, + name, + json_metadata, + is_encrypted, + privacy, + extensions: [], + } + + const json = JSON.stringify(['private_group', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: 'private_message', + required_auths: [creator], + json, + }, + username: creator, + keys: [password], + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + } + }) +)(GroupSettings) diff --git a/src/components/modules/groups/GroupSettings.scss b/src/components/modules/groups/GroupSettings.scss new file mode 100644 index 000000000..e26d01862 --- /dev/null +++ b/src/components/modules/groups/GroupSettings.scss @@ -0,0 +1,19 @@ +.GroupSettings { + h3 { + padding-left: 0.75rem; + } + .input-group-label.button { + margin-bottom: 0px; + } + .input-group { + margin-bottom: 0px; + } + .error { + margin-bottom: 0px; + margin-top: 5px; + } + .submit-error { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx new file mode 100644 index 000000000..9de07e978 --- /dev/null +++ b/src/components/modules/groups/MyGroups.jsx @@ -0,0 +1,360 @@ +import React from 'react' +import {connect} from 'react-redux' +import { Link } from 'react-router-dom' +import { Map } from 'immutable' +import { api, formatter } from 'golos-lib-js' +import tt from 'counterpart' +import cn from 'classnames' + +import DialogManager from 'app/components/elements/common/DialogManager' +import g from 'app/redux/GlobalReducer' +import transaction from 'app/redux/TransactionReducer' +import user from 'app/redux/UserReducer' +import DropdownMenu from 'app/components/elements/DropdownMenu' +import Icon from 'app/components/elements/Icon' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import MarkNotificationRead from 'app/components/elements/MarkNotificationRead' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import { getGroupLogo, getGroupMeta, getRoleInGroup } from 'app/utils/groups' +import isScreenSmall from 'app/utils/isScreenSmall' + +class MyGroups extends React.Component { + constructor(props) { + super(props) + this.state = { + loaded: false + } + } + + refetch = () => { + const { currentUser } = this.props + this.props.fetchMyGroups(currentUser) + } + + componentDidMount = async () => { + this.refetch() + } + + showTopGroups = (e) => { + e.preventDefault() + const { username } = this.props + this.props.showTopGroups(username) + } + + createGroup = (e) => { + e.preventDefault() + this.props.showCreateGroup() + } + + _renderGroupLogo = (group, meta) => { + const { json_metadata } = group + + const logo = getGroupLogo(json_metadata).url + return + + + } + + deleteGroup = (e, group, title) => { + e.preventDefault() + showLoginDialog(group.owner, (res) => { + const password = res && res.password + if (!password) { + return + } + this.props.deleteGroup({ + owner: group.owner, + name: group.name, + password, + onSuccess: () => { + this.refetch() + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + }, 'active', false, tt('my_groups_jsx.login_hint_GROUP', { + GROUP: title + })) + } + + retireCancel = async (e, group, title, isPending) => { + e.preventDefault() + e.stopPropagation() + const { username } = this.props + + let retireWarning + if (!isPending && group.privacy !== 'public_group') { + retireWarning =
{tt('msgs_group_dropdown.joining_back_will_require_approval')}
+ } + const res = await DialogManager.dangerConfirm(
+ {(isPending ? tt('my_groups_jsx.are_you_sure_cancel') : tt('msgs_group_dropdown.are_you_sure_retire')) + + ' ' + title + '?'} + {retireWarning}
, + 'GOLOS Messenger') + if (!res) return + + this.props.groupMember({ + requester: username, group: group.name, + member: username, + member_type: 'retired', + onSuccess: () => { + this.refetch() + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + + showGroupSettings = (e, group) => { + e.preventDefault() + this.props.showGroupSettings({ group }) + } + + showGroupMembers = (e, group, show_pendings) => { + e.preventDefault() + const { name } = group + this.props.showGroupMembers({ group: name, show_pendings }) + } + + onGoGroup = (e) => { + const { closeMe } = this.props + if (closeMe) closeMe() + } + + _renderGroup = (group) => { + const { name, json_metadata, pendings } = group + + const meta = getGroupMeta(json_metadata) + + const isSmall = isScreenSmall() + + const maxLength = isSmall ? 15 : 20 + let title = meta.title || name + let titleShr = title + if (titleShr.length > maxLength) { + titleShr = titleShr.substring(0, maxLength - 3) + '...' + } + + const kebabItems = [] + + const { username } = this.props + const { amOwner, amModer, amPending, amMember } = getRoleInGroup(group, username) + + if (amOwner) { + kebabItems.push({ link: '#', onClick: e => { + this.showGroupSettings(e, group) + }, value: tt('my_groups_jsx.edit') }) + kebabItems.push({ link: '#', onClick: e => { + this.deleteGroup(e, group, titleShr) + }, value: tt('g.delete') }) + } + if (amMember || (amModer && !amOwner)) { + kebabItems.push({ link: '#', onClick: e => { + this.retireCancel(e, group, titleShr, amPending) + }, value: tt('msgs_group_dropdown.retire') }) + } + + return + + {this._renderGroupLogo(group, meta)} + + {titleShr} + + { + e.preventDefault() + e.stopPropagation() + }}> + {amPending ? : null} + {(amModer && pendings) ? : null} + + {/*amOwner ? : null*/} + {kebabItems.length ? + + : null} + + + + } + + render() { + let groups, hasGroups + + let { my_groups } = this.props + + if (!my_groups) { + groups = + } else { + my_groups = my_groups.toJS() + + if (!my_groups.length) { + groups =
+ {tt('my_groups_jsx.empty')} + {tt('my_groups_jsx.empty2')} + + {tt('my_groups_jsx.find')} + + {' ' + tt('my_groups_jsx.find2') + ' '} + {tt('g.or') + ' '} + + {tt('my_groups_jsx.create')} + + {' ' + tt('my_groups_jsx.create2')}. +
+ } else { + hasGroups = true + groups = [] + for (const g of my_groups) { + groups.push(this._renderGroup(g)) + } + groups = + + {groups} + +
+ } + } + + let button + if (hasGroups) { + button =
+ + +
+ } + + const { username } = this.props + + return
+
+

{tt('my_groups_jsx.title')}

+
+ {button} + {groups} + {hasGroups ?
: null} + {username ? : null} +
+ } +} + +export default connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const username = currentUser && currentUser.get('username') + const my_groups = state.global.get('my_groups') + + return { ...ownProps, + currentUser, + username, + my_groups, + } + }, + dispatch => ({ + fetchMyGroups: (currentUser) => { + if (!currentUser) return + const account = currentUser.get('username') + dispatch(g.actions.fetchMyGroups({ account })) + }, + showCreateGroup() { + dispatch(user.actions.showCreateGroup({ redirectAfter: false })) + }, + showTopGroups(account) { + dispatch(user.actions.showTopGroups({ account })) + }, + showGroupSettings({ group }) { + dispatch(user.actions.showGroupSettings({ group })) + }, + showGroupMembers({ group, show_pendings }) { + dispatch(user.actions.showGroupMembers({ group: ['my_groups', group], show_pendings })) + }, + deleteGroup: ({ owner, name, password, + onSuccess, onError }) => { + const opData = { + owner, + name, + extensions: [], + } + + const json = JSON.stringify(['private_group_delete', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: 'private_message', + required_auths: [owner], + json, + }, + username: owner, + keys: [password], + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + groupMember: ({ requester, group, member, member_type, + onSuccess, onError }) => { + const opData = { + requester, + name: group, + member, + member_type, + json_metadata: '{}', + extensions: [], + } + + const plugin = 'private_message' + const json = JSON.stringify(['private_group_member', opData]) + + dispatch(transaction.actions.broadcastOperation({ + type: 'custom_json', + operation: { + id: plugin, + required_posting_auths: [requester], + json, + }, + username: requester, + successCallback: onSuccess, + errorCallback: (err, errStr) => { + console.error(err) + if (onError) onError(err, errStr) + }, + })); + }, + }) +)(MyGroups) diff --git a/src/components/modules/groups/MyGroups.scss b/src/components/modules/groups/MyGroups.scss new file mode 100644 index 000000000..d0cfc5749 --- /dev/null +++ b/src/components/modules/groups/MyGroups.scss @@ -0,0 +1,47 @@ +.MyGroups { + .group-logo { + width: 67px; + img { + width: 48px; + height: 48px; + border-radius: 50%; + } + } + .group-title { + font-weight: bold; + font-size: 110%; + @include themify($themes) { + color: themed('textColorPrimary'); + } + } + .create-group { + margin-right: 0.5rem !important; + } + .more-group { + float: right; + &:not(:hover) { + border-color: transparent; + } + } + .group-buttons { + float: right; + padding-top: 0.8rem; + cursor: default; + .button { + vertical-align: middle; + margin-bottom: 0px; + } + .DropdownMenu.show > .VerticalMenu { + transform: translateX(-100%); + } + } + .btn-title { + margin-left: 5px; + vertical-align: middle; + } + .button.icon-only { + .btn-title { + display: none; + } + } +} diff --git a/src/components/modules/groups/TopGroups.jsx b/src/components/modules/groups/TopGroups.jsx new file mode 100644 index 000000000..bfab39653 --- /dev/null +++ b/src/components/modules/groups/TopGroups.jsx @@ -0,0 +1,170 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import tt from 'counterpart' + +import g from 'app/redux/GlobalReducer' +import user from 'app/redux/UserReducer' +import Icon from 'app/components/elements/Icon' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' +import { getGroupLogo, getGroupMeta, } from 'app/utils/groups' + +class TopGroups extends React.Component { + constructor(props) { + super(props) + this.state = { + } + } + + refetch = () => { + const { currentUser } = this.props + this.props.fetchTopGroups(currentUser) + } + + componentDidMount = async () => { + this.refetch() + } + + createGroup = (e) => { + e.preventDefault() + this.props.hideTopGroups() + this.props.showCreateGroup() + } + + _renderGroupLogo = (group, meta) => { + const { json_metadata } = group + + const logo = getGroupLogo(json_metadata).url + return + + + } + + onGoGroup = (e) => { + const { hideMyGroups, closeMe } = this.props + hideMyGroups() + if (closeMe) closeMe() + } + + _renderGroup = (group) => { + const { name, json_metadata, members, moders, total_messages, + privacy, is_encrypted } = group + + const meta = getGroupMeta(json_metadata) + + let title = meta.title || name + let titleShr = title + if (titleShr.length > 16) { + titleShr = titleShr.substring(0, 13) + '...' + } + + const { username } = this.props + + const totalMembers = members + moders + + let groupType + if (privacy === 'public_group') { + groupType = tt('top_groups_jsx.public') + } else { + groupType = tt('top_groups_jsx.private') + } + groupType =
{groupType}
+ + const lock = + + return + + {this._renderGroupLogo(group, meta)} + + {titleShr} + + + {tt('plurals.member_count', { + count: totalMembers + })}
+ {tt('plurals.message_count', { + count: total_messages + })} + + + {groupType}
{lock} + + + + } + + render() { + let groups, hasGroups + + let { top_groups } = this.props + + if (!top_groups) { + groups = + } else { + top_groups = top_groups.toJS() + + if (!top_groups.length) { + groups =
+ {tt('top_groups_jsx.empty')} + {tt('top_groups_jsx.empty2')} + + {tt('top_groups_jsx.create')} + +
+ } else { + hasGroups = true + groups = [] + for (const g of top_groups) { + groups.push(this._renderGroup(g)) + } + groups = + + {groups} + +
+ } + } + + return
+
+

{tt('top_groups_jsx.title')}

+
+ {groups} + {hasGroups ?
: null} +
+ } +} + +export default connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const username = currentUser && currentUser.get('username') + const top_groups = state.global.get('top_groups') + + return { ...ownProps, + currentUser, + username, + top_groups, + } + }, + dispatch => ({ + fetchTopGroups: (currentUser) => { + if (!currentUser) return + const account = currentUser.get('username') + dispatch(g.actions.fetchTopGroups({ account })) + }, + hideMyGroups: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideMyGroups()) + }, + hideTopGroups: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideTopGroups()) + }, + showCreateGroup() { + dispatch(user.actions.showCreateGroup({ redirectAfter: false })) + }, + }) +)(TopGroups) diff --git a/src/components/modules/groups/TopGroups.scss b/src/components/modules/groups/TopGroups.scss new file mode 100644 index 000000000..a0f5e79b7 --- /dev/null +++ b/src/components/modules/groups/TopGroups.scss @@ -0,0 +1,33 @@ +.TopGroups { + td { + @include themify($themes) { + color: themed('textColorPrimary'); + } + } + .group-title { + width: 30%; + font-weight: bold; + font-size: 110%; + vertical-align: middle; + padding-right: 0.25rem; + } + .group-logo { + width: 13%; + vertical-align: middle; + img { + border-radius: 50%; + } + } + .group-stats { + vertical-align: middle; + font-size: 90%; + } + .group-type { + width: 25%; + display: inline-block; + } + .group-privacey { + width: 25%; + vertical-align: middle; + } +} diff --git a/src/components/modules/messages/ConversationList/index.jsx b/src/components/modules/messages/ConversationList/index.jsx index d0d4aba94..622145b5f 100644 --- a/src/components/modules/messages/ConversationList/index.jsx +++ b/src/components/modules/messages/ConversationList/index.jsx @@ -9,7 +9,7 @@ import './ConversationList.css'; export default class ConversationList extends React.Component { render() { const { topLeft, topRight, - conversationSelected, conversationLinkPattern, onConversationSearch, + conversationSelected, conversationLinkPattern, renderConversationAvatar, onConversationSearch, onConversationSelect, isSmall } = this.props; return ( @@ -26,6 +26,7 @@ export default class ConversationList extends React.Component { data={conversation} selected={conversationSelected === conversation.contact} conversationLinkPattern={conversationLinkPattern} + renderConversationAvatar={renderConversationAvatar} onConversationSelect={onConversationSelect} /> ) diff --git a/src/components/modules/messages/MessageList/index.js b/src/components/modules/messages/MessageList/index.js index 3af4c9f4d..907dcdd3d 100644 --- a/src/components/modules/messages/MessageList/index.js +++ b/src/components/modules/messages/MessageList/index.js @@ -1,4 +1,5 @@ import React from 'react'; +import cn from 'classnames' import throttle from 'lodash/throttle' import Compose from 'app/components/elements/messages/Compose'; @@ -79,7 +80,15 @@ export default class MessageList extends React.Component { } renderMessages = () => { - const { to, renderEmpty, messages, selectedMessages, onMessageSelect } = this.props; + const { to, renderEmpty, renderMessages, messages, selectedMessages, onMessageSelect } = this.props; + + let renderRes = false + if (renderMessages) { + renderRes = renderMessages({}) + } + if (renderRes !== false) { + return renderRes + } if (!to && renderEmpty) { return renderEmpty() @@ -154,7 +163,7 @@ export default class MessageList extends React.Component { const { account, to, topLeft, topCenter, topRight, replyingMessage, onCancelReply, onSendMessage, selectedMessages, onButtonImageClicked, onImagePasted, onPanelDeleteClick, onPanelReplyClick, onPanelEditClick, onPanelCloseClick, - isSmall } = this.props; + isSmall, composeStub } = this.props; const showImageBtn = !isSmall || this.state.inputEmpty return (
@@ -171,9 +180,13 @@ export default class MessageList extends React.Component { onCancelReply={onCancelReply} onSendMessage={onSendMessage} rightItems={[ - (showImageBtn ? : undefined), + (showImageBtn ? : undefined), (
- +
), ]} @@ -185,6 +198,7 @@ export default class MessageList extends React.Component { onImagePasted={onImagePasted} onChange={this.onChange} ref={this.props.composeRef} + stub={composeStub} />) : null}
); diff --git a/src/components/modules/messages/Messenger/Messenger.css b/src/components/modules/messages/Messenger/Messenger.css index d5db6318a..67d400675 100644 --- a/src/components/modules/messages/Messenger/Messenger.css +++ b/src/components/modules/messages/Messenger/Messenger.css @@ -85,3 +85,17 @@ transform: translateX(-50%) translateY(-50%); left: 50%; } + +.msgs-start-panel { + top: 50%; + transform: translateX(-50%) translateY(-50%); + left: 50%; + position: absolute; + z-index: 10; + background-color: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(5px); + box-shadow: 2px 2px 15px rgba(0, 0, 0, 0.25); + padding: 1rem; + border-radius: 10px; + border: 1px solid #bbb; +} diff --git a/src/components/modules/messages/Messenger/index.js b/src/components/modules/messages/Messenger/index.js index e3ad9747e..755666b20 100644 --- a/src/components/modules/messages/Messenger/index.js +++ b/src/components/modules/messages/Messenger/index.js @@ -3,6 +3,7 @@ import Dropzone from 'react-dropzone'; import ConversationList from '../ConversationList'; import MessageList from '../MessageList'; +import StartPanel from 'app/components/elements/messages/StartPanel' import isScreenSmall from 'app/utils/isScreenSmall' import './Messenger.css'; @@ -37,13 +38,14 @@ export default class Messages extends React.Component { } render() { - const { account, to, - contacts, conversationTopLeft, conversationTopRight, conversationLinkPattern, + const { account, to, toNew, + contacts, conversationTopLeft, conversationTopRight, conversationLinkPattern, renderConversationAvatar, onConversationSearch, onConversationSelect, - messagesTopLeft, messagesTopCenter, messagesTopRight, messages, replyingMessage, onCancelReply, onSendMessage, + messagesTopLeft, messagesTopCenter, messagesTopRight, messages, renderMessages, + replyingMessage, onCancelReply, onSendMessage, onButtonImageClicked, onImagePasted, selectedMessages, onMessageSelect, onPanelDeleteClick, onPanelReplyClick, onPanelEditClick, onPanelCloseClick, - composeRef + composeRef, composeStub } = this.props; const { isSmall } = this.state @@ -52,6 +54,7 @@ export default class Messages extends React.Component { @@ -83,8 +87,9 @@ export default class Messages extends React.Component { topRight={messagesTopRight} renderEmpty={() => { if ((localStorage.getItem('msgr_auth') && !account) || process.env.MOBILE_APP) return null - return () + return }} + renderMessages={renderMessages} messages={messages} replyingMessage={replyingMessage} onCancelReply={onCancelReply} @@ -98,6 +103,7 @@ export default class Messages extends React.Component { onButtonImageClicked={onButtonImageClicked} onImagePasted={onImagePasted} composeRef={composeRef} + composeStub={composeStub} />
: null} diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index a703aa019..6b0c40241 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -5,30 +5,36 @@ import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdow import { withRouter } from 'react-router' import golos from 'golos-lib-js' import { fetchEx } from 'golos-lib-js/lib/utils' +import { Aes } from 'golos-lib-js/lib/auth/ecc' import tt from 'counterpart' import debounce from 'lodash/debounce'; import BackButtonController from 'app/components/elements/app/BackButtonController' import AppUpdateChecker from 'app/components/elements/app/AppUpdateChecker' -import ExtLink from 'app/components/elements/ExtLink' import Icon from 'app/components/elements/Icon' import Logo from 'app/components/elements/Logo' import MarkNotificationRead from 'app/components/elements/MarkNotificationRead' import NotifiCounter from 'app/components/elements/NotifiCounter' import DialogManager from 'app/components/elements/common/DialogManager' import AddImageDialog from 'app/components/dialogs/AddImageDialog' +import ChatError from 'app/components/elements/messages/ChatError' +import LetteredAvatar from 'app/components/elements/messages/LetteredAvatar' import PageFocus from 'app/components/elements/messages/PageFocus' -import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import { renderStubs } from 'app/components/elements/Stub' import Userpic from 'app/components/elements/Userpic' import VerticalMenu from 'app/components/elements/VerticalMenu' import Messenger from 'app/components/modules/messages/Messenger' +import MessagesTopCenter from 'app/components/modules/MessagesTopCenter' import g from 'app/redux/GlobalReducer' import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' -import { getProfileImage, getLastSeen } from 'app/utils/NormalizeProfile'; -import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; +import { getRoleInGroup, opGroup } from 'app/utils/groups' +import { parseMentions } from 'app/utils/mentions' +import { getProfileImage, } from 'app/utils/NormalizeProfile'; +import { normalizeContacts, normalizeMessages, cacheMyOwnMsg } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; -import { notificationSubscribe, notificationShallowUnsubscribe, notificationTake, sendOffchainMessage } from 'app/utils/NotifyApiClient'; +import { notificationSubscribe, notificationSubscribeWs, notifyWsPing, + notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage, notifyWsHost, notifyUrl } from 'app/utils/NotifyApiClient'; import { flash, unflash } from 'app/components/elements/messages/FlashTitle'; import { addShortcut } from 'app/utils/app/ShortcutUtils' import { hideSplash } from 'app/utils/app/SplashUtils' @@ -38,6 +44,7 @@ import { proxifyImageUrl } from 'app/utils/ProxifyUrl' class Messages extends React.Component { constructor(props) { super(props); + window.errorLogs = window.errorLogs || [] this.state = { contacts: [], messages: [], @@ -46,30 +53,99 @@ class Messages extends React.Component { searchContacts: null, notifyErrors: 0, }; - this.preDecoded = {}; this.cachedProfileImages = {}; this.windowFocused = true; - this.newMessages = 0; + this.newMessages = {} if (process.env.MOBILE_APP) { this.stopService() } this.composeRef = React.createRef() } - markMessages() { - const { messages } = this.state; - if (!messages.length) return; + getToAcc = () => { + let { to } = this.props + if (to) to = to.replace('@', '') + return to + } + + getGroupName = () => { + const { the_group } = this.props + return the_group ? the_group.name : '' + } + + scrollToMention = () => { + const { username } = this.props + const { messages } = this.state + //alert('scrollToMention ' + messages.length) + let nonce + for (const msg of messages) { + if (msg.to === username && msg.read_date && msg.read_date.startsWith('1970')) { + nonce = msg.nonce + break + } + if (msg.mentions && msg.mentions.includes(username)) { + nonce = msg.nonce + } + } + if (nonce) { + const msgEl = document.getElementById('msgs-' + nonce) + if (msgEl) { + msgEl.scrollIntoView({ block: 'center' }) + const bubEl = msgEl.querySelector('.bubble') + let oldTrans + if (bubEl) { + oldTrans = bubEl.style.transition + } + msgEl.classList.toggle('highlight') + setTimeout(() => { + bubEl.style.transition = '0.5s' + if (msgEl) msgEl.classList.remove('highlight') + setTimeout(() => { + if (bubEl) bubEl.style.transition = oldTrans + }, 1000) + }, 500) + } + } + } + + markMessages = () => { + const { messages } = this.props + if (!messages || !messages.size) return + + const msgs = messages.toJS() + + const { account, accounts, } = this.props; + const to = this.getToAcc() - const { account, accounts, to } = this.props; + const isGroup = this.isGroup() - let OPERATIONS = golos.messages.makeDatedGroups(messages, (message_object, idx) => { - return message_object.toMark && !message_object._offchain; + let OPERATIONS = golos.messages.makeDatedGroups(msgs, (msg, idx) => { + if (msg._offchain) return false + if (msg.read_date.startsWith('19')) { + if (!isGroup) { + return msg.to === account.name + } else { + if (msg.to === account.name) return true + } + } + if (isGroup && msg.mentions.includes(account.name)) { + return true + } + return false }, (group, indexes, results) => { - const json = JSON.stringify(['private_mark_message', { - from: accounts[to].name, - to: account.name, + const op = { + from: isGroup ? '' : accounts[to].name, + to: isGroup ? '' : account.name, ...group, - }]); + } + if (isGroup) { + op.extensions = [[0, { + group: to, + requester: account.name, + mentions: [account.name], + }]] + } + const json = JSON.stringify(['private_mark_message', op]) return ['custom_json', { id: 'private_message', @@ -77,25 +153,26 @@ class Messages extends React.Component { json, } ]; - }, messages.length - 1, -1); + }, 0, msgs.length); this.props.sendOperations(account, accounts[to], OPERATIONS); } - markMessages2 = debounce(this.markMessages, 1000); + markMessages2 = debounce(this.markMessages, 1000) - flashMessage() { - ++this.newMessages; + flashMessage(nonce) { + this.newMessages[nonce] = true - let title = this.newMessages; - const plural = this.newMessages % 10; + const count = Object.keys(this.newMessages).length + let title = count + const plural = count % 10 if (plural === 1) { - if (this.newMessages === 11) + if (count === 11) title += tt('messages.new_message5'); else title += tt('messages.new_message1'); - } else if ((plural === 2 || plural === 3 || plural === 4) && (this.newMessages < 10 || this.newMessages > 20)) { + } else if ((plural === 2 || plural === 3 || plural === 4) && (count < 10 || count > 20)) { title += tt('messages.new_message234'); } else { title += tt('messages.new_message5'); @@ -105,16 +182,21 @@ class Messages extends React.Component { } notifyErrorsClear = () => { - if (this.state.notifyErrors) + if (this.state.notifyErrors) { + window.errorLogs = [] this.setState({ notifyErrors: 0, }); + } }; - notifyErrorsInc = (score) => { + notifyErrorsInc = (score, err, errDetails) => { this.setState({ notifyErrors: this.state.notifyErrors + score, }); + if (err) { + window.errorLogs.push({ err, details: errDetails }) + } }; checkLoggedOut = (username) => { @@ -163,7 +245,31 @@ class Messages extends React.Component { }, 'CorePlugin', 'stopService', []) } + async watchGroup(to) { + if (!to || to.startsWith('@')) { + return true + } + + const {username} = this.props + if (!username) { + console.log('watchGroup -', to, ' - no username') + return false + } + try { + await queueWatch(username, to) + console.log('watchGroup - ', to) + return true + } catch (err) { + console.error('watchGroup - ', to, err) + this.notifyErrorsInc(30, err, {watchGroup: notifyUrl()}) + } + return false + } + async setCallback(username, removeTaskIds) { + if (process.env.NO_NOTIFY) { // config-overrides.js, yarn run dev + return + } if (this.checkLoggedOut(username)) return if (this.paused) { setTimeout(() => { @@ -171,67 +277,70 @@ class Messages extends React.Component { }, 250) return } - let subscribed = null; + let subscribed = null try { - subscribed = await notificationSubscribe(username); + subscribed = await notificationSubscribeWs(username, (err, event) => { + for (const task of event.tasks) { + const { scope, data, timestamp } = task + const [ type, op ] = data + //alert(scope + ' ' + type + op +' ' + timestamp) + const isDonate = type === 'donate' + const toAcc = this.getToAcc() + const { group } = opGroup(op) + let updateMessage = group === this.state.to || (!group && (op.from === toAcc || + op.to === toAcc)) + const isMine = username === op.from; + if (type === 'private_message') { + if (op.update) { + this.props.messageEdited(op, timestamp, updateMessage, isMine); + } else { + this.props.messaged(op, timestamp, updateMessage, isMine, username) + if (this.nonce !== op.nonce) { + this.nonce = op.nonce + if (!isMine && !this.windowFocused) { + this.flashMessage(op.nonce) + } + } + } + } else if (type === 'private_delete_message') { + this.props.messageDeleted(op, updateMessage, isMine); + } else if (type === 'private_mark_message') { + this.props.messageRead(op, timestamp, updateMessage, isMine); + } else if (isDonate) { + this.props.messageDonated(op, updateMessage, isMine) + } + } + }) + console.log('WSS:', subscribed) } catch (ex) { - console.error('notificationSubscribe', ex); - this.notifyErrorsInc(15); + console.error('notificationSubscribe', ex) + this.notifyErrorsInc(15, ex, {subscribeWs: notifyWsHost()}) setTimeout(() => { - this.setCallback(username, removeTaskIds); - }, 5000); - return; - } - if (subscribed) { // if was not already subscribed - this.notifyErrorsClear(); + this.setCallback(username) + }, 5000) + return } - if (this.checkLoggedOut(username)) return - try { - this.notifyAbort = new fetchEx.AbortController() - window.notifyAbort = this.notifyAbort - const takeResult = await notificationTake(username, removeTaskIds, (type, op, timestamp, task_id) => { - const isDonate = type === 'donate' - let updateMessage = op.from === this.state.to || - op.to === this.state.to - const isMine = username === op.from; - if (type === 'private_message') { - if (op.update) { - this.props.messageEdited(op, timestamp, updateMessage, isMine); - } else if (this.nonce !== op.nonce) { - this.props.messaged(op, timestamp, updateMessage, isMine); - this.nonce = op.nonce - if (!isMine && !this.windowFocused) { - this.flashMessage(); - } + this.notifyErrorsClear() + const ping = async (firstCall = false) => { + if (!firstCall) { + try { + await notifyWsPing() + if (this.state.notifyErrors) { + // Queue can be cleared by Notify + setTimeout(() => { + this.setCallback(username) + }, 100) + return } - } else if (type === 'private_delete_message') { - this.props.messageDeleted(op, updateMessage, isMine); - } else if (type === 'private_mark_message') { - this.props.messageRead(op, timestamp, updateMessage, isMine); - } else if (isDonate) { - this.props.messageDonated(op, updateMessage, isMine) + this.notifyErrorsClear() + } catch (err) { + console.error('Notify ping failed', err) + this.notifyErrorsInc(10, err, {wsPing: notifyWsHost()}) } - }, this.notifyAbort); - removeTaskIds = takeResult.removeTaskIds - window.__lastTake = takeResult.__lastTake - setTimeout(() => { - this.setCallback(username, removeTaskIds); - }, 250); - } catch (ex) { - console.error('notificationTake', ex); - this.notifyErrorsInc(3); - let delay = 2000 - if (ex.message.includes('No such queue')) { - console.log('notificationTake: resubscribe forced...') - notificationShallowUnsubscribe() - delay = 250 } - setTimeout(() => { - this.setCallback(username, removeTaskIds) - }, delay); - return; + setTimeout(ping, 10000) } - this.notifyErrorsClear(); + this.watchGroup(this.props.to) } componentDidMount() { @@ -247,12 +356,14 @@ class Messages extends React.Component { document.addEventListener('resume', this.onResume) } this.props.loginUser() + this.checkUserAuth(true) + } + + checkUserAuth = (initial) => { const checkAuth = () => { - if (!this.props.username) { - this.props.checkMemo(this.props.currentUser); - } + this.props.checkAuth(this.props.currentUser, this.isChat()) } - if (!localStorage.getItem('msgr_auth')) { + if (!initial || !localStorage.getItem('msgr_auth')) { checkAuth() } else { setTimeout(() => { @@ -262,10 +373,18 @@ class Messages extends React.Component { } componentDidUpdate(prevProps) { - if (this.props.username !== prevProps.username && this.props.username) { + const loggedNow = this.props.username !== prevProps.username && this.props.username + if (this.props.to !== prevProps.to || (this.isChat() && loggedNow)) { + this.checkUserAuth() + } + if (prevProps.username && !this.props.username) { + this.checkUserAuth(true) + } + if (loggedNow) { this.props.fetchState(this.props.to); - this.setCallback(this.props.username); + this.setCallback(this.props.username) } else if (this.props.to !== this.state.to) { + this.watchGroup(this.props.to) this.props.fetchState(this.props.to) if (this.state.to) { this.leaveChat() @@ -277,35 +396,41 @@ class Messages extends React.Component { || this.props.contacts.size !== prevProps.contacts.size || this.props.memo_private !== prevProps.memo_private) { const { contacts, messages, accounts, currentUser } = this.props; - if (!this.props.checkMemo(currentUser)) { - this.setState({ - to: this.props.to, // protects from infinity loop - }); - return; - } const anotherChat = this.props.to !== this.state.to; + this.setState({ + to: this.props.to, // protects from infinity loop + }); + /*if (!this.props.checkMemo(currentUser)) { + return; + }*/ const anotherKey = this.props.memo_private !== prevProps.memo_private; const added = this.props.messages.size > this.state.messagesCount; let focusTimeout = prevProps.messages.size ? 100 : 1000; - const newContacts = contacts.size ? - normalizeContacts(contacts, accounts, currentUser, this.preDecoded, this.cachedProfileImages) : - this.state.contacts - this.setState({ - to: this.props.to, - contacts: newContacts, - messages: normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded), - messagesCount: messages.size, - }, () => { - hideSplash() - if (added) - this.markMessages2(); - setTimeout(() => { - if (anotherChat || anotherKey) { - this.focusInput(); + + const updateData = async () => { + const newContacts = contacts.size ? + await normalizeContacts(contacts, accounts, currentUser, this.cachedProfileImages) : + this.state.contacts + const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to) + this.setState({ + contacts: newContacts, + messages: decoded, + messagesCount: messages.size, + }, () => { + hideSplash() + if (this.props.fetched !== prevProps.fetched && this.isGroup()) { + this.scrollToMention() } - }, focusTimeout); - }) + if (added) + this.markMessages2(); + setTimeout(() => { + this.focusInput(); + }, focusTimeout); + }) + } + updateData() } + this.markMessages2() } componentWillUnmount() { @@ -333,12 +458,16 @@ class Messages extends React.Component { continue; } account.contact = account.name; - account.avatar = getProfileImage(account, this.cachedProfileImages); + const { url, isDefault } = getProfileImage(account, this.cachedProfileImages) + if (!isDefault) { + account.avatar = url + } contacts.push(account); } if (contacts.length === 0) { contacts = [{ contact: tt('messages.search_not_found'), + avatar: require('app/assets/images/user.png'), isSystemMessage: true }]; } @@ -387,16 +516,18 @@ class Messages extends React.Component { onSendMessage = (message, event) => { if (!message.length) return; - const { to, account, accounts, currentUser, messages } = this.props; + const { account, accounts, currentUser, messages, the_group } = this.props; + const to = this.getToAcc() const private_key = currentUser.getIn(['private_keys', 'memo_private']); let editInfo; if (this.editNonce) { - editInfo = { nonce: this.editNonce }; + editInfo = { from: this.editFrom, nonce: this.editNonce } } this.props.sendMessage({ senderAcc: account, memoKey: private_key, toAcc: accounts[to], + group: this.isGroup() && the_group, body: message, editInfo, type: 'text', replyingMessage: this.state.replyingMessage, notifyAbort: this.notifyAbort }) @@ -425,7 +556,20 @@ class Messages extends React.Component { let selectedMessages = {...this.state.selectedMessages}; let selectMessage = (msg, idx) => { - const isMine = account.name === msg.from; + const isMine = account.name === msg.from + let canIEdit = isMine + let canIDelete = true + if (this.isGroup()) { + const { the_group } = this.props + const { amModer, amMember, amBanned } = getRoleInGroup(the_group, account.name) + if (amModer) { + canIEdit = true + } else if (amBanned || (the_group.privacy !== 'public_group' && !amModer && !amMember)) { + canIEdit = false + } + canIDelete = canIEdit + } + let isImage = false; let isInvalid = true; const { message } = msg; @@ -434,7 +578,9 @@ class Messages extends React.Component { isInvalid = !!message.invalid; } selectedMessages[msg.nonce] = { - editable: isMine && !isImage && !isInvalid, idx }; + editable: canIEdit && !isImage && !isInvalid, + deletable: canIDelete, + idx }; }; if (event.shiftKey) { @@ -477,7 +623,8 @@ class Messages extends React.Component { onPanelDeleteClick = (event) => { const { messages } = this.state; - const { account, accounts, to } = this.props; + const { account, accounts, the_group } = this.props; + const to = this.getToAcc() // TODO: works wrong if few messages have same create_time /*let OPERATIONS = golos.messages.makeDatedGroups(messages, (message_object, idx) => { @@ -509,6 +656,14 @@ class Messages extends React.Component { if (!this.state.selectedMessages[message_object.nonce]) { continue; } + + const extensions = [] + if (this.isGroup()) { + extensions.push([0, { + group: the_group.name + }]) + } + const json = JSON.stringify(['private_delete_message', { requester: account.name, from: message_object.from, @@ -516,6 +671,7 @@ class Messages extends React.Component { start_date: '1970-01-01T00:00:00', stop_date: '1970-01-01T00:00:00', nonce: message_object.nonce, + extensions, }]); OPERATIONS.push(['custom_json', { @@ -526,7 +682,9 @@ class Messages extends React.Component { ]); } - this.props.sendOperations(account, accounts[to], OPERATIONS); + this.props.sendOperations(account, accounts[to], OPERATIONS, (err, errStr) => { + this.props.showError(errStr, 10000) + }) this.setState({ selectedMessages: {}, @@ -566,6 +724,7 @@ class Messages extends React.Component { selectedMessages: {}, }, () => { this.editNonce = message[0].nonce; + this.editFrom = message[0].from if (message[0].message.quote) { this.setState({ replyingMessage: {quote: message[0].message.quote}, @@ -594,10 +753,12 @@ class Messages extends React.Component { if (!url) return; - const { to, account, accounts, currentUser, messages } = this.props; + const { account, accounts, the_group, currentUser, messages } = this.props; + const to = this.getToAcc() const private_key = currentUser.getIn(['private_keys', 'memo_private']); this.props.sendMessage({ senderAcc: account, memoKey: private_key, toAcc: accounts[to], + group: this.isGroup() && the_group, body: url, type: 'image', meta: {width, height}, replyingMessage: this.state.replyingMessage, notifyAbort: this.notifyAbort }); @@ -721,7 +882,7 @@ class Messages extends React.Component {
- +
@@ -742,46 +903,18 @@ class Messages extends React.Component { }; _renderMessagesTopCenter = ({ isSmall }) => { - let messagesTopCenter = []; - const { to, accounts } = this.props; - if (accounts[to]) { - let checkmark - if (to === 'notify') { - checkmark = - - - } - messagesTopCenter.push(
- {to}{checkmark} -
); - const { notifyErrors } = this.state; - if (notifyErrors >= 30) { - messagesTopCenter.push(
- {isSmall ? - - {tt('messages.sync_error_short')} - { e.preventDefault(); this.props.fetchState(this.props.to) }}> - {tt('g.refresh').toLowerCase()}. - - : - {tt('messages.sync_error')} - } -
); - } else { - let lastSeen = getLastSeen(accounts[to]); - if (lastSeen) { - messagesTopCenter.push(
- { - - {tt('messages.last_seen')} - - - } -
); - } - } - } - return messagesTopCenter; + const { fetchState, to } = this.props + const toAcc = this.getToAcc() + const { notifyErrors, } = this.state + + return }; _renderMenuItems = (isSmall) => { @@ -801,11 +934,17 @@ class Messages extends React.Component { this.props.logout(username) } + const openMyGroups = (e) => { + e.preventDefault() + this.props.showMyGroups() + } + let user_menu = [ - {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : '')}, + {link: '#', onClick: openMyGroups, icon: 'voters', value: tt('g.groups') + (isSmall ? (' @' + username) : ''), addon: }, + {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog'), addon: }, {link: mentionsLink, extLink: 'blogs', icon: 'new/mention', value: tt('g.mentions'), addon: }, {link: donatesLink, extLink: 'wallet', icon: 'editor/coin', value: tt('g.rewards'), addon: }, - {link: walletLink, extLink: 'wallet', icon: 'new/wallet', value: tt('g.wallet'), addon: }, + {link: walletLink, extLink: 'wallet', icon: 'new/wallet', value: tt('g.wallet'), addon: }, {link: '#', onClick: this.props.toggleNightmode, icon: 'editor/eye', value: tt('g.night_mode')}, {link: '#', onClick: () => { this.props.changeLanguage(this.props.locale) @@ -836,14 +975,14 @@ class Messages extends React.Component { return (} >
- +
{!isSmall ?
@@ -853,14 +992,48 @@ class Messages extends React.Component { ); }; + isChat = () => { + const { to } = this.props + return to && to.startsWith('@') + } + + isGroup = () => { + const { to } = this.props + return to && !to.startsWith('@') + } + + _renderMessages = (messagesStub, { }) => { + const { to, the_group, accounts } = this.props + + if (to) { + const isGroup = this.isGroup() + if (isGroup) { + const noGroup = the_group === null + const groupError = noGroup || (the_group && the_group.error) + if (groupError) { + return + } + } else if (!isGroup && !accounts[this.getToAcc()]) { + return + } + } + + if (messagesStub && messagesStub.ui) { + return messagesStub.ui + } + + return false + } + handleFocusChange = isFocused => { this.windowFocused = isFocused; if (!isFocused) { - if (this.newMessages) { + if (Object.keys(this.newMessages).length) { flash(); } } else { - this.newMessages = 0; + this.newMessages = {} unflash(); } } @@ -906,7 +1079,7 @@ class Messages extends React.Component { } render() { - const { contacts, account, to, nodeError } = this.props; + const { contacts, account, accounts, to, nodeError } = this.props; let bbc, auc if (process.env.MOBILE_APP) { bbc = @@ -925,6 +1098,11 @@ class Messages extends React.Component { conversationTopLeft={this._renderConversationTopLeft} />
); + const toAcc = this.getToAcc() + + const { username, the_group } = this.props + const { composeStub, msgsStub } = renderStubs(the_group, to, username, accounts) + return (
{bbc} @@ -937,16 +1115,28 @@ class Messages extends React.Component { {Messenger ? ( { + if (contact.kind === 'group') return '/*' + return '/@*' + }} + renderConversationAvatar={(contact, defaultRender) => { + if (contact.avatar) { + return defaultRender(contact.avatar) + } + return + + + }} onConversationSearch={this.onConversationSearch} messages={this.state.messages} messagesTopLeft={this._renderMessagesTopLeft()} messagesTopCenter={this._renderMessagesTopCenter} messagesTopRight={this._renderMessagesTopRight} + renderMessages={(...args) => this._renderMessages(msgsStub, ...args)} replyingMessage={this.state.replyingMessage} onCancelReply={this.onCancelReply} onSendMessage={this.onSendMessage} @@ -960,6 +1150,7 @@ class Messages extends React.Component { onImagePasted={this.onImagePasted} onImageDropped={this.onImageDropped} composeRef={this.composeRef} + composeStub={composeStub} />) : null}
) @@ -973,12 +1164,12 @@ export default withRouter(connect( const contacts = state.global.get('contacts') const messages = state.global.get('messages') const nodeError = state.global.get('nodeError') + const fetched = state.global.get('fetched') const messages_update = state.global.get('messages_update') const username = state.user.getIn(['current', 'username']) let to = ownProps.match.params.to - if (to) to = to.replace('@', '') let memo_private = null if (currentUser) { @@ -987,24 +1178,29 @@ export default withRouter(connect( const locale = state.user.get('locale') + let the_group = state.global.get('the_group') + if (the_group && the_group.toJS) the_group = the_group.toJS() + return { to, contacts: contacts, messages: messages, messages_update, + the_group, account: currentUser && accounts && accounts.toJS()[currentUser.get('username')], currentUser, memo_private, accounts: accounts ? accounts.toJS() : {}, username, locale, - nodeError + nodeError, + fetched } }, dispatch => ({ loginUser: () => dispatch(user.actions.usernamePasswordLogin()), - checkMemo: (currentUser) => { + checkAuth: (currentUser, memoNeed) => { if (!currentUser) { hideSplash() dispatch(user.actions.showLogin({ @@ -1012,18 +1208,23 @@ export default withRouter(connect( })); return false; } - const private_key = currentUser.getIn(['private_keys', 'memo_private']); - if (!private_key) { - hideSplash() - dispatch(user.actions.showLogin({ - loginDefault: { username: currentUser.get('username'), authType: 'memo', unclosable: true } - })); - return false; + if (memoNeed) { + const private_key = currentUser.getIn(['private_keys', 'memo_private']) + if (!private_key) { + hideSplash() + dispatch(user.actions.showLogin({ + loginDefault: { username: currentUser.get('username'), authType: 'memo', } + })); + return false + } } return true; }, + + showMyGroups: () => dispatch(user.actions.showMyGroups()), + fetchState: (to) => { - const pathname = '/' + (to ? ('@' + to) : ''); + const pathname = '/' + (to || '') dispatch({type: 'FETCH_STATE', payload: { location: { pathname @@ -1031,20 +1232,21 @@ export default withRouter(connect( fake: true }}); }, - sendOperations: (senderAcc, toAcc, OPERATIONS) => { + sendOperations: (senderAcc, toAcc, OPERATIONS, onError = null) => { if (!OPERATIONS.length) return; dispatch( transaction.actions.broadcastOperation({ type: 'custom_json', trx: OPERATIONS, successCallback: null, - errorCallback: (e) => { - console.log(e); + errorCallback: (e, errStr) => { + if (onError) onError(e, errStr) + console.error(e) } }) ); }, - sendMessage ({ senderAcc, memoKey, toAcc, body, editInfo = undefined, type = 'text', meta = {}, replyingMessage = null, notifyAbort }) { + sendMessage: async function({ senderAcc, memoKey, toAcc, group, body, editInfo = undefined, type = 'text', meta = {}, replyingMessage = null, notifyAbort }) { let message = { app: 'golos-messenger', version: 1, @@ -1058,22 +1260,61 @@ export default withRouter(connect( throw new Error('Unknown message type: ' + type); } } + let to = toAcc ? toAcc.name : '' if (replyingMessage) { + if (group) { + to = replyingMessage.quote.from + if (to === senderAcc.name) to = '' + } message = {...message, ...replyingMessage}; } - const data = golos.messages.encode(memoKey, toAcc.memo_key, message, editInfo ? editInfo.nonce : undefined); + let mentions = [] + if (group) { + mentions = parseMentions(message) + } + + let data = null + try { + data = await golos.messages.encodeMsg({ group, + private_memo: !group && memoKey, + to_public_memo: !group && toAcc.memo_key, + msg: message, + nonce: editInfo ? editInfo.nonce : undefined, + }) + } catch (err) { + console.error(err) + this.showError((err && err.message) || err.toString(), 10000) + } const opData = { from: senderAcc.name, - to: toAcc.name, + to, nonce: editInfo ? editInfo.nonce : data.nonce, - from_memo_key: senderAcc.memo_key, - to_memo_key: toAcc.memo_key, + from_memo_key: data.from_memo_key, + to_memo_key: data.to_memo_key, checksum: data.checksum, update: editInfo ? true : false, encrypted_message: data.encrypted_message, - }; + } + + if (group) { + let requester + + if (editInfo && editInfo.from !== senderAcc.name) { + opData.from = editInfo.from + requester = senderAcc.name + } + + opData.extensions = [[0, { + group: group.name, + requester, + mentions + }]] + } + //alert(JSON.stringify(opData)) + + cacheMyOwnMsg(opData, group, message) if (!editInfo) { sendOffchainMessage(opData).catch(err => { @@ -1093,12 +1334,16 @@ export default withRouter(connect( json, }, successCallback: null, - errorCallback: (err) => { + errorCallback: (err, errStr) => { if (err && err.message) { - if (err.message.includes('blocked by')) { + const bm = 'blocked by user (@' + const bmIdx = err.message.indexOf(bm) + if (bmIdx > -1) { + const msg = err.message.substring(bmIdx + bm.length) + const blocker = msg.substring(0, msg.indexOf(')')) this.showError(tt( 'messages.blocked_BY', { - BY: toAcc.name + BY: blocker } ), 10000) return @@ -1106,18 +1351,19 @@ export default withRouter(connect( if (err.message.includes('do not bother')) { this.showError(tt( 'messages.do_not_bother_BY', { - BY: toAcc.name + BY: toAcc ? toAcc.name : '' } ), 10000) return } } console.error(err) + this.showError(errStr, 10000) }, })); }, - messaged: (message, timestamp, updateMessage, isMine) => { - dispatch(g.actions.messaged({message, timestamp, updateMessage, isMine})); + messaged: (message, timestamp, updateMessage, isMine, username) => { + dispatch(g.actions.messaged({message, timestamp, updateMessage, isMine, username})); }, messageEdited: (message, timestamp, updateMessage, isMine) => { dispatch(g.actions.messageEdited({message, timestamp, updateMessage, isMine})); diff --git a/src/components/pages/app/AppSettings.jsx b/src/components/pages/app/AppSettings.jsx index 6080b5ea2..dfb8ce402 100644 --- a/src/components/pages/app/AppSettings.jsx +++ b/src/components/pages/app/AppSettings.jsx @@ -14,6 +14,7 @@ class AppSettings extends React.Component { use_img_proxy: $GLS_Config.images.use_img_proxy, auth_service: $GLS_Config.auth_service.host, notify_service: $GLS_Config.notify_service.host, + notify_service_ws: $GLS_Config.notify_service.host_ws, blogs_service: $GLS_Config.blogs_service.host, } this.initialValues = initialValues @@ -78,6 +79,7 @@ class AppSettings extends React.Component { cfg.images.use_img_proxy = data.use_img_proxy cfg.auth_service.host = data.auth_service cfg.notify_service.host = data.notify_service + cfg.notify_service.host_ws = data.notify_service_ws cfg.blogs_service.host = data.blogs_service cfg = JSON.stringify(cfg) localStorage.setItem('app_settings', cfg) @@ -164,6 +166,17 @@ class AppSettings extends React.Component {
+
+
+ {tt('app_settings.notify_service_ws')} +
+ +
+
+
{tt('app_settings.blogs_service')} diff --git a/src/foundation-overrides.scss b/src/foundation-overrides.scss index c9e9f17a3..ea7d0629d 100644 --- a/src/foundation-overrides.scss +++ b/src/foundation-overrides.scss @@ -11,9 +11,15 @@ border-radius: 6px; background-color: #fff; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.15), 0 2px 12px 0 rgba(0,0,0,0.15); + &.fade { + opacity: 0; + transition: opacity 100ms linear; + } + &.in { + opacity: 1; + } } - div[role=dialog] { z-index: 500; } diff --git a/src/locales/en.json b/src/locales/en.json index 91abf9e1b..3f0dbb4e7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,4 +1,32 @@ { + "account_dropdown_jsx": { + "clear_history": "Clear Hstory", + "delete_chat": "Delete Chat", + "are_you_sure": "Are you sure you want to clear history?", + "are_you_sure_delete": "Are you sure you want to clear history and delete chat?", + "are_you_sure_delete_empty": "Are you sure you want to delete chat?" + }, + "account_name": { + "account_name_should_be_shorter": "Account name should be shorter.", + "account_name_should_be_longer": "Account name should be longer.", + "account_name_should_not_be_empty": "Account name should not be empty.", + "each_account_segment_should_start_with_a_letter": "Each account name segment should start with a letter.", + "each_account_segment_should_have_only_letters_digits_or_dashes": "Each account name segment should have only letters, digits or dashes.", + "each_account_segment_should_have_only_one_dash_in_a_row": "Each account name segment should have only one dash in a row.", + "each_account_segment_should_end_with_a_letter_or_digit": "Each account name segment should end with a letter or digit.", + "each_account_segment_should_be_longer": "Each account name segment should be longer." + }, + "account_name_jsx": { + "loading": "Loading...", + "no_options": "Cannot search..." + }, + "chain_errors": { + "exceeded_maximum_allowed_bandwidth": "Insufficient account bandwidth. Replenish the Golos Power or write to info@golos.id", + "insufficient1": "It costs ", + "insufficient2": ", but you have just ", + "insufficient3": ". ", + "insufficient_top_up": "Top Up" + }, "chainvalidation_js": { "account_name_should": "Account name should ", "not_be_empty": "not be empty.", @@ -33,6 +61,8 @@ "login_to_post": "Login to Post", "login_to_comment": "Login to Comment", "login_to_message": "Login with Memo Key or Password", + "login_active": "Enter password (or active-key)", + "is_is_for_operation": "This is to send an operation.", "login_to_your_steem_account": "Login to your Steem Account", "login_with_active_key_USERNAME": "Login with your Active key, please\n\nUsername:\n%(USERNAME)s\n\nActive-key:\n", "posting": "Posting", @@ -59,6 +89,11 @@ "join_our": "Join our", "account_frozen": "This account is temporarily frozen due to inactivity." }, + "login_dialog_jsx": { + "node_failure_NODE_ERROR": "Node failure. Node: %(NODE)s Error: %(ERROR)s", + "wrong_pass": "Wrong password.", + "wrong_pass_ROLE": "Wrong password. No access to %(ROLE)s permissions." + }, "messages": { "last_seen": "last seen ", "verified_golos_account": "Verified account of Golos staff", @@ -72,10 +107,130 @@ "new_message5": " notifications", "invalid_message": "(This message could not be displayed in Golos Messenger)", "sync_error": "Sync error. To receive new messages please refresh the page.", - "sync_error_short": "Sync error. To receive new messages touch ", + "sync_error_short": "Sync error. ", "blocked_BY": "You are blocked by @%(BY)s.", "do_not_bother_BY": "@%(BY)s wants to not be bothered by low-reputation users." }, + "msgs_group_dropdown": { + "join": "Join", + "retire": "Retire", + "cancel": "Cancel", + "public": "Public group", + "read_only": "Public only as read-only", + "private": "Private group", + "encrypted": "Messages are encrypted", + "not_encrypted": "Messages are not encrypted", + "banned": "You are banned.", + "pending": "You requested membership.", + "moder": "You are moderator.", + "owner": "You are owner.", + "are_you_sure_retire": "Are you sure you want to retire from ", + "joining_back_will_require_approval": "Joining it back will require approval." + }, + "msgs_start_panel": { + "start_chat": "Start chat", + "create_group": "Create group" + }, + "msgs_chat_error": { + "404_group": "We have not such group", + "404_acc": "No such account", + "404_but": "But we have many interesting things...", + "500_group": "Error", + "500_load_msgs": "Cannot load messages." + }, + "create_group_jsx": { + "title": "Title", + "name": "Link chat.golos.app/", + "logo": "Logo", + "admin": "Admin", + "encrypted": "Encrypt messages in group", + "encrypted_hint": "Шифрование позволяет сделать сообщения доступными только тем, кто имеет доступ к группе.", + "encrypted_dis": " (required for privacy)", + "access": "Access to group for...", + "access_hint": "Можно сделать группу открытой, или доступной только тем, чьи заявки на вступление в группу одобрит администрация.", + "access_all": "Everyone", + "all_read_only": "Everyone, but posting only for members", + "access_private": "Only members", + "next": "Next", + "back": "Back", + "submit": "Create", + "step_name": "Name", + "step_logo": "Logo", + "step_logo_mobile": "Logo", + "step_members": "Members", + "step_members_mobile": "Membs", + "step_create": "Create!", + "step_create_mobile": "Go!", + "group_already_exists": "Group already exists.", + "group_min_length": "Min is 3 symbols.", + "golos_power_too_low": "To create group you should have Golos Power at least ", + "golos_power_too_low2": "That is not enough ", + "golos_power_too_low3": "Your Golos Power is ", + "gbg_too_low": "Group creation costs ", + "gbg_too_low2": "That is not enough ", + "gbg_too_low3": "Your GBG balance is ", + "create_of_group_is": "Creating of group is ", + "free": "free", + "deposit_gp": "Increase Golos Power", + "logo_desc": "The group logo is like a user’s avatar... Not required, but \"must have\"...", + "logo_upload": "Upload logo", + "logo_link": "Add logo from the URL", + "members_desc": "Add people, set moderators of the group...", + "final_desc": "Now we are ready to create the group!", + "moders_list": "Moderators:", + "members_list": "Members:", + "image_wrong": "Cannot load this image.", + "image_timeout": "Cannot load this image, it is loading too long...", + "add_member": "+ Add Member..." + }, + "my_groups_jsx": { + "title": "My Groups", + "empty": "You have not any groups yet. ", + "empty2": "You can ", + "find": "find", + "find2": "interesting group", + "create": "create", + "create2": "your own one", + "create_more": "+ Create a group", + "more_groups": "More groups...", + "edit": "Edit", + "login_hint_GROUP": "(delete \"%(GROUP)s\" group)", + "members": "Members", + "cancel_pending": "Cancel request", + "are_you_sure_cancel": "Do you sure you don't want to join" + }, + "top_groups_jsx": { + "title": "Popular Groups", + "empty": "We have no groups yet. ", + "empty2": "Become the first - ", + "create": "create your own group!", + "public": "Public", + "read_only": "Read-only", + "private": "Private" + }, + "group_members_jsx": { + "title": "Members Of ", + "title2": " Group", + "check_pending": "Requests", + "check_pending_hint": "Pending Group Join Requests", + "check_banned": "Blocked", + "all": "All", + "moders": "Moderators", + "member": "Member", + "moder": "Moderator", + "owner": "Group Owner", + "make_member": "Make Simple Member", + "make_moder": "Make Moderator", + "ban": "Ban", + "unban": "Unban", + "banned": "Banned" + }, + "group_settings_jsx": { + "title_GROUP": "%(GROUP)s Group", + "submit": "Save", + "owner": "Owner", + "upload": "Upload" + }, "emoji_i18n": { "categoriesLabel": "Категории", "emojiUnsupportedMessage": "Ваш браузер не поддерживает эмодзи.", @@ -110,6 +265,15 @@ "flags": "Флаги" } }, + "stub_jsx": { + "read_only": "Only members can post messages in this group.", + "private_group": "This group is private. To read and write messages, you should be a member.", + "pending": "You issued request to join group.", + "banned": "You banned in this group.", + "join": "Make Request", + "blocked": "User blocked you.", + "blocking": "You are blocked by user." + }, "user_saga_js": { "image_upload": { "uploading": "Uploading", @@ -152,7 +316,8 @@ "current_node": "GOLOS node URL", "img_proxy_prefix": "Use Proxy For Images", "auth_service": "Golos Auth & Registration Service (for messenger)", - "notify_service": "Golos Notify Service (for messenger)", + "notify_service": "Golos Notify Service (for instant messages)", + "notify_service_ws": "Golos Notify Service WebSocket (for instant messages)", "blogs_service": "Blogs", "save": "Save", "node_error_NODE": "Cannot connect to %(NODE)s. It may be internet failure. Try ", @@ -172,21 +337,48 @@ "confirm": "Confirmation", "prompt": "Enter the data" }, + "plurals": { + "member_count": { + "zero": "0 members", + "one": "1 member", + "other": "%(count)s members" + }, + "mention_count": { + "zero": "0 mentions", + "one": "1 mention", + "other": "%(count)s mentions" + }, + "message_count": { + "zero": "0 messages", + "one": "1 message", + "other": "%(count)s messages" + }, + "reply_count": { + "zero": "0 replies", + "one": "1 reply", + "other": "%(count)s replies" + } + }, "g": { "and": "and", + "block": "Block", + "blocked": "Blocked", "blog": "Blog", "cancel": "Cancel", "delete": "Delete", "dismiss": "Dismiss", "edit": "Edit", + "groups": "Groups", "feed": "Feed", "incorrect_password": "Incorrect password", "posting_not_memo": "Use posting key now, not memo", "login": "Login", "logout": "Logout", "mentions": "Mentions", + "name": "Name", "night_mode": "Night Mode", "ok": "OK", + "or": "or", "refresh": "Refresh", "required": "Required", "replies": "Replies", @@ -195,6 +387,7 @@ "settings": "Settings", "sign_up": "Sign Up", "submit": "Submit", + "unblock": "Unblock", "username_does_not_exist": "Username does not exist", "wallet": "Wallet" } diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 65fb2f390..910190bc4 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -1,4 +1,32 @@ { + "account_dropdown_jsx": { + "clear_history": "Очистить историю", + "delete_chat": "Удалить чат", + "are_you_sure": "Вы уверены, что хотите очистить историю ", + "are_you_sure_delete": "Вы уверены, что хотите очистить историю и удалить чат с ", + "are_you_sure_delete_empty": "Вы уверены, что хотите удалить чат с " + }, + "account_name": { + "account_name_should_be_shorter": "Имя аккаунта должно быть короче.", + "account_name_should_be_longer": "Имя аккаунта должно быть длиннее.", + "account_name_should_not_be_empty": "Имя аккаунта не должно быть пустым.", + "each_account_segment_should_start_with_a_letter": "Каждый сегмент имени аккаунта должен начинаться с буквы.", + "each_account_segment_should_have_only_letters_digits_or_dashes": "Сегмент имени аккаунта может содержать только буквы, цифры и дефисы.", + "each_account_segment_should_have_only_one_dash_in_a_row": "Каждый сегмент имени аккаунта может содержать только один дефис.", + "each_account_segment_should_end_with_a_letter_or_digit": "Каждый сегмент имени аккаунта должен заканчиваться буквой или цифрой.", + "each_account_segment_should_be_longer": "Сегмент имени аккаунта должен быть длиннее." + }, + "account_name_jsx": { + "loading": "Загрузка...", + "no_options": "Не удается найти..." + }, + "chain_errors": { + "exceeded_maximum_allowed_bandwidth": "Недостаточно пропускной способности аккаунта. Пополните Силу Голоса или напишите на info@golos.id", + "insufficient1": "Нужно ", + "insufficient2": ", есть ", + "insufficient3": ". ", + "insufficient_top_up": "Пополнить" + }, "chainvalidation_js": { "account_name_should": "Имя аккаунта должно ", "not_be_empty": "не может быть пустым.", @@ -34,6 +62,8 @@ "login_to_post": "Авторизуйтесь, чтобы написать пост", "login_to_comment": "Пожалуйста, авторизуйтесь для выполнения следующих действий", "login_to_message": "Пожалуйста, авторизуйтесь с memo-ключом или паролем", + "login_active": "Введите пароль (или активный ключ)", + "is_is_for_operation": "Это нужно для отправки операции", "login_with_active_key_USERNAME": "Пожалуйста, авторизуйтесь Вашим активным ключом\n\nИмя:\n%(USERNAME)s\n\nActive-ключ:\n", "login_to_your_steem_account": "Войти в свой Голос аккаунт", "posting": "Постинг", @@ -60,6 +90,11 @@ "join_our": "Присоединяйтесь к нашему", "account_frozen": "Этот аккаунт временно деактивирован из-за неактивности." }, + "login_dialog_jsx": { + "node_failure_NODE_ERROR": "Ошибка ноды. Нода: %(NODE)s Ошибка: %(ERROR)s", + "wrong_pass": "Неверный пароль.", + "wrong_pass_ROLE": "Неверный пароль. Этот ключ не дает %(ROLE)s-доступа." + }, "messages": { "last_seen": "был(а) ", "verified_golos_account": "Этот аккаунт принадлежит Golos", @@ -73,9 +108,144 @@ "new_message5": " новых сообщений", "invalid_message": "(Это сообщение не может быть отображено в Golos Messenger)", "sync_error": "Ошибка синхронизации. Для получения новых сообщений обновляйте страницу.", - "sync_error_short": "Ошибка синхронизации. Для получения новых сообщений нажимайте ", + "sync_error_short": "Ошибка синхронизации. ", "blocked_BY": "Вы заблокированы пользователем @%(BY)s.", - "do_not_bother_BY": "@%(BY)s просит пользователей с низкой репутацией не беспокоить." + "do_not_bother_BY": "@%(BY)s просит пользователей с низкой репутацией не беспокоить.", + "too_low_gp": "Не хватает Силы Голоса. Для участия в группах нужно не менее ", + "too_low_gp2": ".", + "you_not_moder": "Вы не модератор." + }, + "msgs_group_dropdown": { + "join": "Вступить", + "retire": "Покинуть", + "cancel": "Отменить", + "public": "Открытая группа", + "read_only": "Группа, открытая только для чтения", + "private": "Закрытая группа", + "encrypted": "Сообщения шифруются", + "not_encrypted": "Сообщения не шифруются", + "banned": "Вы забанены.", + "pending": "Вы подали заявку на вступление.", + "moder": "Вы модератор.", + "owner": "Вы владелец.", + "are_you_sure_retire": "Вы уверены, что хотите покинуть группу", + "joining_back_will_require_approval": "Вступить обратно вы сможете только после одобрения заявки." + }, + "msgs_start_panel": { + "start_chat": "Начать чат", + "create_group": "Создать группу" + }, + "msgs_chat_error": { + "404_group": "Такой группы у нас нет", + "404_acc": "Такого пользователя нет", + "404_but": "Но у нас есть много интересного...", + "500_group": "Ошибка", + "500_load_msgs": "Не удалось загрузить сообщения." + }, + "create_group_jsx": { + "title": "Название", + "name": "Ссылка chat.golos.app/", + "name2": "Ссылка", + "name3": "chat.golos.app/", + "logo": "Логотип", + "admin": "Администратор", + "encrypted": "Шифровать сообщения в группе", + "encrypted_hint": "Шифрование позволяет сделать сообщения доступными только тем, кто имеет доступ к группе.", + "encrypted_dis": " (в приватной обязательно)", + "access": "Группа будет доступна...", + "access_hint": "Можно сделать группу открытой, или доступной только тем, чьи заявки на вступление в группу одобрит администрация.", + "access_all": "Всем", + "all_read_only": "Всем, но постить только участникам", + "access_private": "Только участникам", + "next": "Далее", + "back": "Назад", + "submit": "Создать", + "step_name": "Имя", + "step_logo": "Логотип", + "step_logo_mobile": "Лого", + "step_members": "Участники", + "step_members_mobile": "Люди", + "step_create": "Создать!", + "step_create_mobile": "Go!", + "group_already_exists": "Такая группа уже существует.", + "validating": "Проверка существования группы...", + "group_min_length": "Минимум 3 символа.", + "golos_power_too_low": "Для создания группы нужна Сила Голоса не менее ", + "golos_power_too_low2": "Вам не хватает ", + "golos_power_too_low3": "Ваша Сила Голоса - ", + "gbg_too_low": "Создание группы стоит ", + "gbg_too_low2": "Вам не хватает ", + "gbg_too_low3": "На вашем балансе - ", + "create_of_group_is": "Создание группы - ", + "free": "бесплатно", + "deposit_gp": "Пополнить Силу Голоса", + "logo_desc": "Логотип группы - это как аватарка у пользователя... Необязательно, но \"must have\"...", + "logo_upload": "Загрузить логотип", + "logo_link": "Добавить логотип ссылкой", + "members_desc": "Вы можете добавить в группу людей, назначить модераторов...", + "final_desc": "Теперь все готово к созданию группы!", + "moders_list": "Модераторы группы:", + "members_list": "Участники:", + "image_wrong": "Не удается загрузить картинку.", + "image_timeout": "Не удается загрузить картинку, она загружается слишком долго...", + "add_member": "+ Добавить участника...", + "cannot_set_members": "Группа создана успешно. Но, к сожалению, не получилось задать участников из-за ошибки.", + "cannot_set_members2": "Вы можете попытаться сделать это заново в настройках группы." + }, + "my_groups_jsx": { + "title": "Мои группы", + "empty": "У вас пока нет групп. ", + "empty2": "Вы можете ", + "find": "найти", + "find2": "интересную для себя группу", + "create": "создать", + "create2": "свою собственную", + "create_more": "+ Создать группу", + "more_groups": "Еще группы...", + "edit": "Изменить", + "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")", + "members": "Участники", + "cancel_pending": "Отменить заявку", + "are_you_sure_cancel": "Вы уверены, что хотите отказаться от вступления в группу" + }, + "top_groups_jsx": { + "title": "Топ популярных групп", + "empty": "У нас пока нет групп. ", + "empty2": "Станьте первым - ", + "create": "создайте свою группу!", + "public": "Открытая", + "read_only": "Открытая для чтения", + "private": "Закрытая" + }, + "group_members_jsx": { + "title": "Участники группы ", + "title2": "", + "check_pending": "Заявки", + "check_pending_hint": "Заявки на вступление в группу", + "check_banned": "Забаненные", + "all": "Все", + "moders": "Модераторы", + "member": "Обычный участник", + "moder": "Модератор", + "owner": "Владелец группы", + "make_member": "Сделать обычным участником", + "make_moder": "Сделать модератором", + "ban": "Заблокировать", + "unban": "Разблокировать", + "banned": "Заблокирован(-а)" + }, + "group_settings_jsx": { + "title_GROUP": "Группа %(GROUP)s", + "submit": "Сохранить", + "owner": "Владелец", + "upload": "Загрузить", + "name_cannot_change": "Ссылку на группу изменить нельзя.", + "preview": "(просмотр)", + "login_hint_GROUP": "(изменения группы \"%(GROUP)s\")", + "encrypted": "Сообщения в группе ", + "encrypted2": " шифруются.", + "encrypted3": " не шифруются.", + "encrypted_hint": "Изменить это нельзя." }, "emoji_i18n": { "categoriesLabel": "Категории", @@ -111,6 +281,15 @@ "flags": "Флаги" } }, + "stub_jsx": { + "read_only": "Писать сообщения в этой группе могут лишь ее члены.", + "private_group": "Это закрытая группа. Чтобы видеть сообщения и общаться в ней, надо стать ее членом.", + "pending": "Вы подали заявку на вступление в группу.", + "banned": "Вы забанены в этой группе.", + "join": "Подать заявку", + "blocked": "Пользователь заблокировал вас.", + "blocking": "Вы заблокировали пользователя." + }, "user_saga_js": { "image_upload": { "uploading": "Загрузка", @@ -154,6 +333,7 @@ "img_proxy_prefix": "Использовать прокси для изображений", "auth_service": "Golos Auth & Registration Service (для мгновенных сообщений)", "notify_service": "Golos Notify Service (для мгновенных сообщений)", + "notify_service_ws": "Golos Notify Service WebSocket (для мгновенных сообщений)", "blogs_service": "Блоги", "save": "Сохранить", "node_error_NODE": "Не удалось подключиться к ноде %(NODE)s. Возможно, это проблемы с интернетом. Попробуйте ", @@ -173,21 +353,49 @@ "confirm": "Подтверждение", "prompt": "Введите данные" }, + "plurals": { + "member_count": { + "zero": "0 участников", + "one": "1 участник", + "other": "%(count)s участник(-ов)" + }, + "mention_count": { + "zero": "0 упоминания", + "one": "1 упоминание", + "other": "%(count)s упоминания(-й)" + }, + "message_count": { + "zero": "0 сообщений", + "one": "1 сообщение", + "other": "%(count)s сообщения(-й)" + }, + "reply_count": { + "zero": "0 ответов", + "one": "1 ответ", + "other": "%(count)s ответа(-ов)" + } + }, "g": { "and": "и", + "block": "Заблокировать", + "blocked": "Заблокирован(-а)", "blog": "Блог", "cancel": "Отмена", "delete": "Удалить", "dismiss": "Скрыть", "edit": "Редактировать", + "groups": "Группы", "feed": "Лента", "incorrect_password": "Неправильный пароль", "posting_not_memo": "Сейчас нужно ввести posting-ключ. Вы ввели memo.", "login": "Войти", "logout": "Выйти", "mentions": "Упоминания", + "modified": " (изменено)", + "name": "Имя", "night_mode": "Ночной режим", "ok": "OK", + "or": "или", "refresh": "Обновить", "required": "Обязательно", "replies": "Ответы", @@ -195,7 +403,10 @@ "rewards": "Награды", "settings": "Настройки", "sign_up": "Регистрация", + "submit": "Отправить", + "unblock": "Разблокировать", "username_does_not_exist": "Такого имени не существует", - "wallet": "Кошелек" + "wallet": "Кошелек", + "wait": "Ждите..." } } \ No newline at end of file diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 9a947b961..e27de9470 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -1,13 +1,17 @@ import { call, put, select, fork, cancelled, takeLatest, takeEvery } from 'redux-saga/effects'; -import golos, { api } from 'golos-lib-js' +import golos, { api, auth } from 'golos-lib-js' import tt from 'counterpart' import g from 'app/redux/GlobalReducer' +import { getSpaceInCache, saveToCache } from 'app/utils/Normalizators' export function* fetchDataWatches () { yield fork(watchLocationChange) yield fork(watchFetchState) yield fork(watchFetchUiaBalances) + yield fork(watchFetchMyGroups) + yield fork(watchFetchTopGroups) + yield fork(watchFetchGroupMembers) } export function* watchLocationChange() { @@ -18,20 +22,37 @@ export function* watchFetchState() { yield takeLatest('FETCH_STATE', fetchState) } +const setMiniAccount = (state, account) => { + if (account) { + state.accounts[account.name] = account + } +} +const addMiniAccounts = (state, accounts) => { + if (accounts) { + for (const [n, acc] of Object.entries(accounts)) { + setMiniAccount(state, acc) + } + } +} + export function* fetchState(location_change_action) { try { - const { pathname } = location_change_action.payload.location - const { fake } = location_change_action.payload + const { fake, isFirstRendering } = location_change_action.payload const parts = pathname.split('/') const state = {} state.nodeError = null - state.contacts = []; - state.messages = []; - state.messages_update = '0'; - state.accounts = {} - state.assets = {} + state.fetched = '' + if (isFirstRendering || fake) { + state.contacts = []; + state.the_group = undefined + state.messages = []; + state.messages_update = '0'; + state.accounts = {} + state.assets = {} + state.groups = {} + } let hasErr = false @@ -53,29 +74,118 @@ export function* fetchState(location_change_action) { const account = yield select(state => state.user.getIn(['current', 'username'])); if (account) { - accounts.add(account); + const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])) - state.contacts = yield callSafe(state, [], 'getContactsAsync', [api, api.getContactsAsync], account, 'unknown', 100, 0) - if (hasErr) return + const path = parts[1] - if (parts[1]) { - const to = parts[1].replace('@', ''); - accounts.add(to); + const conCache = getSpaceInCache(null, 'contacts') - state.messages = yield callSafe(state, [], 'getThreadAsync', [api, api.getThreadAsync], account, to, {}); + if (path.startsWith('@') || !path) { + if (window._perfo) console.time('prof: getContactsAsync') + const con = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, + call: async (loginData) => { + return await api.getContactsAsync({ + ...loginData, + owner: account, limit: 100, + cache: Object.keys(conCache), + accounts: true, + relations: false, + }) + } + }) + if (window._perfo) console.log('procc:' + con._dec_processed) + state.contacts = con.contacts if (hasErr) return + if (window._perfo) console.timeEnd('prof: getContactsAsync') - if (state.messages.length) { - state.messages_update = state.messages[state.messages.length - 1].nonce; - } + addMiniAccounts(state, con.accounts) } - for (let contact of state.contacts) { - accounts.add(contact.contact); + + if (path) { + if (path.startsWith('@')) { + const to = path.replace('@', ''); + + const mess = yield callSafe(state, [], 'getThreadAsync', [api, api.getThreadAsync], { + from: account, + to, + accounts: true + }) + if (hasErr) return + + state.messages = mess.messages + + if (state.messages.length) { + state.messages_update = state.messages[state.messages.length - 1].nonce; + } + + addMiniAccounts(state, mess.accounts) + } else { + if (window._perfo) console.time('prof: getGroupsAsync') + let the_group = yield callSafe(state, [], 'getGroupsAsync', [api, api.getGroupsAsync], { + start_group: path, + limit: 1, + with_members: { + accounts: [account] + } + }) + if (hasErr) return + if (the_group[0] && the_group[0].name === path) { + the_group = the_group[0] + } else { + the_group = null + } + state.the_group = the_group + if (window._perfo) console.timeEnd('prof: getGroupsAsync') + + const space = the_group && getSpaceInCache({ group: the_group.name }) + if (window._perfo) console.time('prof: getThreadAsync') + let query = { + group: path, + cache: space ? Object.keys(space) : [], + accounts: true, + contacts: { + owner: account, limit: 100, + cache: Object.keys(conCache), + relations: false, + }, + } + const getThread = async (loginData) => { + query = {...query, ...loginData} + const th = await api.getThreadAsync(query) + return th + } + let thRes + if (the_group && the_group.is_encrypted) { + thRes = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, + call: getThread + }) + } else { + thRes = yield call(getThread) + } + + addMiniAccounts(state, thRes.accounts) + + if (window._perfo) console.log('proc:' + thRes._dec_processed) + if (the_group && thRes.error) { + the_group.error = thRes.error + } + state.contacts = thRes.contacts + if (thRes.messages) { + state.messages = thRes.messages + if (state.messages.length) { + state.messages_update = state.messages[state.messages.length - 1].nonce; + } + } + if (window._perfo) console.timeEnd('prof: getThreadAsync') + + state.fetched = path + } } } if (accounts.size > 0) { - let accs = yield callSafe(state, [], 'getAccountsAsync', [api, api.getAccountsAsync], Array.from(accounts)) + let accs = yield callSafe(state, [], 'getAccountsAsync', [api, api.getAccountsAsync], Array.from(accounts), + { current: account || '' }) if (hasErr) return for (let i in accs) { @@ -109,3 +219,108 @@ export function* fetchUiaBalances({ payload: { account } }) { console.error('fetchUiaBalances', err) } } + +export function* watchFetchMyGroups() { + yield takeLatest('global/FETCH_MY_GROUPS', fetchMyGroups) +} + +export function* fetchMyGroups({ payload: { account } }) { + try { + const groupsOwn = yield call([api, api.getGroupsAsync], { + member: account, + member_types: [], + start_group: '', + limit: 100, + with_members: { + accounts: [account] + } + }) + let groups = yield call([api, api.getGroupsAsync], { + member: account, + member_types: ['pending', 'member', 'moder'], + start_group: '', + limit: 100, + with_members: { + accounts: [account] + } + }) + groups = [...groupsOwn, ...groups] + groups.sort((a, b) => { + return b.pendings - a.pendings + }) + + yield put(g.actions.receiveMyGroups({ groups })) + } catch (err) { + console.error('fetchMyGroups', err) + } +} + +export function* watchFetchTopGroups() { + yield takeLatest('global/FETCH_TOP_GROUPS', fetchTopGroups) +} + +export function* fetchTopGroups({ payload: { account } }) { + try { + const groupsWithoutMe = [] + let start_group = '' + + for (let page = 1; page <= 3; ++page) { + if (page > 1) { + groupsWithoutMe.pop() + } + + const groups = yield call([api, api.getGroupsAsync], { + sort: 'by_popularity', + start_group, + limit: 100, + with_members: { + accounts: [account] + } + }) + + for (const gro of groups) { + start_group = gro.name + if ((gro.member_list.length && gro.member_list[0].account == account) || gro.owner == account) { + continue + } + groupsWithoutMe.push(gro) + } + + if (groupsWithoutMe.length >= 10 || groups.length < 100) { + break + } + } + + yield put(g.actions.receiveTopGroups({ groups: groupsWithoutMe })) + } catch (err) { + console.error('fetchTopGroups', err) + } +} + +export function* watchFetchGroupMembers() { + yield takeLatest('global/FETCH_GROUP_MEMBERS', fetchGroupMembers) +} + +export function* fetchGroupMembers({ payload: { group, creatingNew, memberTypes, sortConditions } }) { + try { + if (creatingNew) { + yield put(g.actions.receiveGroupMembers({ group, members: [], append: true })) + return + } + + yield put(g.actions.receiveGroupMembers({ group, loading: true })) + + const members = yield call([api, api.getGroupMembersAsync], { + group, + member_types: memberTypes, + sort_conditions: sortConditions, + start_member: '', + limit: 100, + accounts: true, + }) + + yield put(g.actions.receiveGroupMembers({ group, members })) + } catch (err) { + console.error('fetchGroupMembers', err) + } +} \ No newline at end of file diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index e9845952d..3e421ce00 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -2,7 +2,34 @@ import { Map, List, fromJS, fromJSGreedy } from 'immutable'; import createModule from 'redux-modules' import { Asset } from 'golos-lib-js/lib/utils' -import { processDatedGroup } from 'app/utils/MessageUtils' +import { session } from 'app/redux/UserSaga' +import { opGroup } from 'app/utils/groups' +import { processDatedGroup, opDeleteContact } from 'app/utils/MessageUtils' + +const updateInMyGroups = (state, group, groupUpdater, groupsUpserter = mg => mg) => { + state = state.update('my_groups', null, mg => { + if (!mg) return mg + const i = mg.findIndex(gro => gro.get('name') === group) + if (i === -1) return groupsUpserter(mg) + mg = mg.update(i, (gro) => { + if (!gro) return + + return groupUpdater(gro) + }) + return mg + }) + return state +} + +const updateTheGroup = (state, group, groupUpdater) => { + state = state.update('the_group', null, (gro) => { + if (!gro) return + if (gro.get('name') !== group) return gro + + return groupUpdater(gro) + }) + return state +} export default createModule({ name: 'global', @@ -25,17 +52,26 @@ export default createModule({ action: 'RECEIVE_STATE', reducer: (state, action) => { let payload = fromJS(action.payload); - // TODO reserved words used in account names, find correct solution - /*if (!Map.isMap(payload.get('accounts'))) { - const accounts = payload.get('accounts'); - payload = payload.set( - 'accounts', - fromJSGreedy(accounts) - ); - }*/ + payload = payload.update('accounts', accs => { + let newMap = Map() + accs.forEach((acc, name) => { + if (!acc.has('relations')) { + acc = acc.set('relations', Map()) + } + if (!acc.hasIn(['relations', 'me_to_them'])) { + acc = acc.setIn(['relations', 'me_to_them'], null) + } + if (!acc.hasIn(['relations', 'they_to_me'])) { + acc = acc.setIn(['relations', 'they_to_me'], null) + } + newMap = newMap.set(name, acc) + }) + return newMap + }) let new_state = state.set('messages', List()); new_state = new_state.set('contacts', List()); - return new_state.mergeDeep(payload); + new_state = new_state.mergeDeep(payload) + return new_state }, }, { @@ -48,7 +84,7 @@ export default createModule({ action: 'MESSAGED', reducer: ( state, - { payload: { message, timestamp, updateMessage, isMine } } + { payload: { message, timestamp, updateMessage, isMine, username } } ) => { message.create_date = timestamp; message.receive_date = timestamp; @@ -57,6 +93,10 @@ export default createModule({ message.donates = '0.000 GOLOS' message.donates_uia = 0 } + const { group, mentions } = opGroup(message) + message.group = group + message.mentions = mentions + message.read_date = (group && !message.to) ? timestamp : '1970-01-01T00:00:00'; let new_state = state; let messages_update = message.nonce; @@ -77,25 +117,43 @@ export default createModule({ new_state = new_state.updateIn(['contacts'], List(), contacts => { - let idx = contacts.findIndex(i => - i.get('contact') === message.to - || i.get('contact') === message.from); + let idx = contacts.findIndex(i => { + if (group) { + return i.get('kind') === 'group' && i.get('contact') === group + } + return i.get('kind') !== 'group' && + (i.get('contact') === message.to + || i.get('contact') === message.from) + }) + let newInbox = 0, newMentions = 0 + if (!isMine && !updateMessage) { + if (!group || message.to === username) { + newInbox++ + } else if (group && message.mentions && message.mentions.includes(username)) { + newMentions++ + } + } if (idx === -1) { - let contact = isMine ? message.to : message.from; + let contact = group || (isMine ? message.to : message.from) contacts = contacts.insert(0, fromJS({ contact, + kind: group ? 'group' : 'account', last_message: message, size: { - unread_inbox_messages: !isMine ? 1 : 0, + unread_inbox_messages: newInbox, + unread_mentions: newMentions, }, })); } else { contacts = contacts.update(idx, contact => { contact = contact.set('last_message', fromJS(message)); - if (!isMine && !updateMessage) { - let msgs = contact.getIn(['size', 'unread_inbox_messages']); - contact = contact.setIn(['size', 'unread_inbox_messages'], - msgs + 1); + if (newInbox) { + contact = contact.updateIn(['size', 'unread_inbox_messages'], + msgs => msgs + newInbox) + } + if (newMentions) { + contact = contact.updateIn(['size', 'unread_mentions'], + msgs => msgs + newMentions) } return contact }); @@ -107,6 +165,15 @@ export default createModule({ }); return contacts; }); + let { from_account } = message + if (from_account && from_account.name) { + from_account = fromJS(from_account) + new_state = new_state.updateIn( + ['accounts', from_account.get('name')], + Map(), + a => a.mergeDeep(from_account) + ) + } return new_state; }, }, @@ -146,12 +213,14 @@ export default createModule({ ) => { let new_state = state; let messages_update = message.nonce || Math.random(); + const { group, requester } = opGroup(message) if (updateMessage) { new_state = new_state.updateIn(['messages'], List(), messages => { return processDatedGroup(message, messages, (msg, idx) => { - return msg.set('read_date', timestamp); + msg = msg.set('read_date', timestamp) + return { updated: msg } }); }); } @@ -159,7 +228,7 @@ export default createModule({ List(), contacts => { let idx = contacts.findIndex(i => - i.get('contact') === (isMine ? message.to : message.from)); + i.get('contact') === (group || (isMine ? message.to : message.from))) if (idx !== -1) { contacts = contacts.update(idx, contact => { // to update read_date (need for isMine case), and more actualize text @@ -173,6 +242,9 @@ export default createModule({ // currently used only !isMine case const msgsKey = isMine ? 'unread_outbox_messages' : 'unread_inbox_messages'; contact = contact.setIn(['size', msgsKey], 0); + if (!isMine) { + contact = contact.setIn(['size', 'unread_mentions'], 0) + } return contact; }); } @@ -190,15 +262,39 @@ export default createModule({ ) => { let new_state = state; if (updateMessage) { - new_state = new_state.updateIn(['messages'], - List(), - messages => { - const idx = messages.findIndex(i => i.get('nonce') === message.nonce); - if (idx !== -1) { - messages = messages.delete(idx); - } - return messages; - }); + if (message.nonce) { + new_state = new_state.updateIn(['messages'], + List(), + messages => { + const idx = messages.findIndex(i => i.get('nonce') === message.nonce); + if (idx !== -1) { + messages = messages.delete(idx); + } + return messages; + }) + } else { + new_state = new_state.updateIn(['messages'], + List(), + messages => { + return processDatedGroup(message, messages, (msg, idx) => { + return { updated: null, fixIdx: idx - 1 } + }); + }) + } + } + const delCon = opDeleteContact(message) + if (delCon) { + new_state = new_state.updateIn(['contacts'], + List(), + contacts => { + let idx = contacts.findIndex(i => + i.get('contact') === message.to + || i.get('contact') === message.from) + if (idx !== -1) { + contacts = contacts.delete(idx) + } + return contacts + }) } return new_state; }, @@ -264,5 +360,220 @@ export default createModule({ return state.set('assets', fromJS(assets)) }, }, + { + action: 'FETCH_MY_GROUPS', + reducer: state => state + }, + { + action: 'RECEIVE_MY_GROUPS', + reducer: (state, { payload: { groups } }) => { + return state.set('my_groups', fromJS(groups)) + }, + }, + { + action: 'FETCH_TOP_GROUPS', + reducer: state => state + }, + { + action: 'RECEIVE_TOP_GROUPS', + reducer: (state, { payload: { groups } }) => { + return state.set('top_groups', fromJS(groups)) + }, + }, + { + action: 'FETCH_GROUP_MEMBERS', + reducer: state => state + }, + { + action: 'RECEIVE_GROUP_MEMBERS', + reducer: (state, { payload: { group, members, loading, append } }) => { + let new_state = state + new_state = state.updateIn(['groups', group], + Map(), + gro => { + gro = gro.updateIn(['members'], Map(), mems => { + mems = mems.set('loading', loading || false) + if (append) { + // Immutable's: update do not wants to add notSet, if array is empty... + if (!mems.has('data')) { + mems = mems.set('data', List()) + } + mems = mems.update('data', List(), data => { + for (const item of (members || [])) { + data = data.push(fromJS(item)) + } + return data + }) + } else { + mems = mems.set('data', fromJS(members || [])) + } + return mems + }) + return gro + }) + for (const mem of (members || [])) { + if (mem.account_data) { + const account = fromJS(mem.account_data) + new_state = new_state.updateIn( + ['accounts', account.get('name')], + Map(), + a => a.mergeDeep(account) + ) + } + } + return new_state + }, + }, + { + action: 'UPSERT_GROUP', + reducer: (state, { payload }) => { + const { creator, name, is_encrypted, privacy, json_metadata } = payload + let new_state = state + const groupUpdater = gro => { + gro = gro.set('json_metadata', json_metadata) + gro = gro.set('privacy', privacy) + return gro + } + const groupsUpserter = myGroups => { + const now = new Date().toISOString().split('.')[0] + myGroups = myGroups.insert(0, fromJS({ + owner: creator, + name, + json_metadata, + is_encrypted, + privacy, + created: now, + admins: 0, + moders: 0, + members: 0, + pendings: 0, + banneds: 0, + member_list: [] + })) + return myGroups + } + new_state = updateInMyGroups(new_state, name, groupUpdater, groupsUpserter) + new_state = updateTheGroup(new_state, name, groupUpdater) + return new_state + } + }, + { + action: 'UPDATE_GROUP_MEMBER', + reducer: (state, { payload: { group, member, member_type } }) => { + const now = new Date().toISOString().split('.')[0] + let new_state = state + let oldType + new_state = state.updateIn(['groups', group], + Map(), + gro => { + gro = gro.updateIn(['members', 'data'], List(), mems => { + const retiring = member_type === 'retired' + const idx = mems.findIndex(i => i.get('account') === member) + if (idx !== -1) { + oldType = mems.get(idx).get('member_type') + if (retiring) { + mems = mems.remove(idx) + } else { + mems = mems.update(idx, mem => { + mem = mem.set('member_type', member_type) + return mem + }) + } + } else if (!retiring) { + mems = mems.insert(0, fromJS({ + group, + account: member, + json_metadata: '{}', + member_type, + invited: member, + joined: now, + updated: now, + })) + } + return mems + }) + return gro + }) + const groupUpdater = gro => { + if (!gro.has('member_list')) { + gro = gro.set('member_list', List()) + } + gro = gro.update('member_list', List(), data => { + let newList = List() + let found + data.forEach((mem, i) => { + if (mem.get('account') === member) { + found = true + if (!oldType) oldType = mem.get('member_type') + if (member_type !== 'retired') { + const newMem = mem.set('member_type', member_type) + newList = newList.push(newMem) + } + } else { + newList = newList.push(mem) + } + }) + if (!found) { + newList = newList.push(fromJS({ + account: member, + member_type, + })) + } + return newList + }) + + const updateByType = (t, updater) => { + if (t === 'member') { + gro = gro.update('members', updater) + } else if (t === 'moder') { + gro = gro.update('moders', updater) + } else if (t === 'pending') { + gro = gro.update('pendings', updater) + } else if (t === 'banned') { + gro = gro.update('banneds', updater) + } + } + updateByType(oldType, n => --n) + updateByType(member_type, n => ++n) + + return gro + } + new_state = updateInMyGroups(new_state, group, groupUpdater) + new_state = updateTheGroup(new_state, group, groupUpdater) + new_state = new_state.updateIn(['accounts', member], + Map(), + acc => { + acc = acc.set('member_type', member_type) + return acc + }) + return new_state + }, + }, + { + action: 'UPDATE_BLOCKING', + reducer: (state, { payload: { blocker, blocking, block } }) => { + let username + const sess = session.load() + if (sess) username = sess[0] + const account = blocker === username ? blocking : blocker + + let new_state = state.updateIn(['accounts', account], + Map(), + acc => { + if (!acc.has('relations')) { + acc = acc.set('relations', Map()) + } + const path = ['relations', blocker === username ? 'me_to_them' : 'they_to_me'] + if (block) { + acc = acc.setIn(path, 'blocking') + } else { + acc = acc.deleteIn(path) + } + return acc + }) + + return new_state + }, + }, ], }) diff --git a/src/redux/PollDataSaga.js b/src/redux/PollDataSaga.js index 379d9dcba..bc087cde0 100644 --- a/src/redux/PollDataSaga.js +++ b/src/redux/PollDataSaga.js @@ -11,6 +11,10 @@ const wait = ms => ( let webpush_params = null; export default function* pollData() { + if (process.env.NO_NOTIFY) { // config-overrides.js, yarn run dev + console.warn('Notifications disabled in environment variables') + return + } while(true) { if (document.visibilityState !== 'hidden') { const username = yield select(state => state.user.getIn(['current', 'username'])); diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index 63ae9c7e4..5c4ccc8ac 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -4,6 +4,8 @@ import golos from 'golos-lib-js' import g from 'app/redux/GlobalReducer' import user from 'app/redux/UserReducer' +import { messageOpToObject } from 'app/utils/Normalizators' +import { translateError } from 'app/utils/translateError' export function* transactionWatches() { yield fork(watchForBroadcast) @@ -15,8 +17,23 @@ export function* watchForBroadcast() { const hook = { preBroadcast_custom_json, + accepted_custom_json, } +function* accepted_custom_json({operation}) { + const json = JSON.parse(operation.json) + if (operation.id === 'private_message') { + if (json[0] === 'private_group') { + yield put(g.actions.upsertGroup(json[1])) + } else if (json[0] === 'private_group_member') { + const { name, member, member_type } = json[1] + yield put(g.actions.updateGroupMember({ group: name, member, member_type, })) + } + } + return operation +} + + function* preBroadcast_custom_json({operation}) { const json = JSON.parse(operation.json) if (operation.id === 'private_message') { @@ -28,17 +45,19 @@ function* preBroadcast_custom_json({operation}) { updater: msgs => { const idx = msgs.findIndex(i => i.get('nonce') === json[1].nonce); if (idx === -1) { - msgs = msgs.insert(0, fromJS({ - nonce: json[1].nonce, - checksum: json[1].checksum, - from: json[1].from, - read_date: '1970-01-01T00:00:00', - create_date: new Date().toISOString().split('.')[0], - receive_date: '1970-01-01T00:00:00', - encrypted_message: json[1].encrypted_message, - donates: '0.000 GOLOS', - donates_uia: 0 - })) + let group = '' + let mentions = [] + const exts = json[1].extensions || [] + for (const [key, val ] of exts) { + if (key === 0) { + group = val.group + mentions = val.mentions + break + } + } + const newMsg = messageOpToObject(json[1], group, mentions) + msgs = msgs.insert(0, fromJS(newMsg)) + messages_update = json[1].nonce; } else { messages_update = json[1].nonce; msgs = msgs.update(idx, msg => { @@ -114,9 +133,15 @@ function* broadcastOperation( return; } - const posting_private = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])); - if (!posting_private) { - alert('Not authorized') + if (!keys) keys = ['posting'] + keys = [...new Set(keys)] // remove duplicate + const idxP = keys.indexOf('posting') + if (idxP !== -1) { + const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])); + if (!posting) { + alert('Not authorized') + } + keys[idxP] = posting } let operations = trx || [ @@ -144,11 +169,23 @@ function* broadcastOperation( } try { const res = yield golos.broadcast.sendAsync( - tx, [posting_private]) + tx, keys) + for (const [type, operation] of operations) { + if (hook['accepted_' + type]) { + try { + yield call(hook['accepted_' + type], {operation}) + } catch (error) { + console.error(error) + } + } + } } catch (err) { console.error('Broadcast error', err) if (errorCallback) { - errorCallback(err) + let errStr = err.toString() + errStr = translateError(errStr, err.payload) + errStr = errStr.substring(0, 160) + errorCallback(err, errStr) } return } diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index 20fe4bfd1..691307dd3 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -5,6 +5,11 @@ const defaultState = fromJS({ current: null, show_login_modal: false, show_donate_modal: false, + show_create_group_modal: false, + show_my_groups_modal: false, + show_top_groups_modal: false, + show_group_settings_modal: false, + show_group_members_modal: false, show_app_download_modal: false, loginLoading: false, pub_keys_used: null, @@ -128,6 +133,31 @@ export default createModule({ { action: 'HIDE_CONNECTION_ERROR_MODAL', reducer: state => state.set('hide_connection_error_modal', true) }, { action: 'SHOW_DONATE', reducer: state => state.set('show_donate_modal', true) }, { action: 'HIDE_DONATE', reducer: state => state.set('show_donate_modal', false) }, + { action: 'SHOW_CREATE_GROUP', reducer: (state, { payload: { redirectAfter }}) => { + state = state.set('show_create_group_modal', true) + state = state.set('create_group_redirect_after', redirectAfter) + return state + }}, + { action: 'HIDE_CREATE_GROUP', reducer: state => state.set('show_create_group_modal', false) }, + { action: 'SHOW_MY_GROUPS', reducer: state => state.set('show_my_groups_modal', true) }, + { action: 'HIDE_MY_GROUPS', reducer: state => state.set('show_my_groups_modal', false) }, + { action: 'SHOW_TOP_GROUPS', reducer: state => state.set('show_top_groups_modal', true) }, + { action: 'HIDE_TOP_GROUPS', reducer: state => state.set('show_top_groups_modal', false) }, + { action: 'SHOW_GROUP_SETTINGS', reducer: (state, { payload: { group }}) => { + state = state.set('show_group_settings_modal', true) + state = state.set('current_group', fromJS(group)) + return state + }}, + { action: 'HIDE_GROUP_SETTINGS', reducer: state => state.set('show_group_settings_modal', false) }, + { action: 'SHOW_GROUP_MEMBERS', reducer: (state, { payload: { group, show_pendings }}) => { + state = state.set('show_group_members_modal', true) + state = state.set('group_members_modal', fromJS({ + group, + show_pendings, + })) + return state + }}, + { action: 'HIDE_GROUP_MEMBERS', reducer: state => state.set('show_group_members_modal', false) }, { action: 'SHOW_APP_DOWNLOAD', reducer: state => state.set('show_app_download_modal', true) }, { action: 'HIDE_APP_DOWNLOAD', reducer: state => state.set('show_app_download_modal', false) }, { action: 'SET_DONATE_DEFAULTS', reducer: (state, {payload}) => state.set('donate_defaults', fromJS(payload)) }, diff --git a/src/redux/UserSaga.js b/src/redux/UserSaga.js index fc3c054e5..b2fa49b8f 100644 --- a/src/redux/UserSaga.js +++ b/src/redux/UserSaga.js @@ -1,7 +1,7 @@ import { Map, fromJS } from 'immutable' import { call, put, select, fork, takeLatest, takeEvery } from 'redux-saga/effects' import { auth, api, config } from 'golos-lib-js' -import { Session, signData } from 'golos-lib-js/lib/auth' +import { Session, PageSession, signData } from 'golos-lib-js/lib/auth' import { PrivateKey, Signature, hash } from 'golos-lib-js/lib/auth/ecc' import g from 'app/redux/GlobalReducer' @@ -11,7 +11,8 @@ import uploadImageWatch from 'app/redux/UserSaga_UploadImage' import { authApiLogin, authApiLogout } from 'app/utils/AuthApiClient' import { notifyApiLogin, notifyApiLogout, notificationUnsubscribe } from 'app/utils/NotifyApiClient' -const session = new Session('msgr_auth') +export const session = new Session('msgr_auth') +export const pageSession = new PageSession('msgr_auth') export function* userWatches() { yield fork(loginWatch) @@ -133,10 +134,15 @@ function* usernamePasswordLogin(action) { yield put(user.actions.setUser({ username, private_keys, })) } + const { errorLogs } = window + if (postingWif) { let alreadyAuthorized = false; try { const res = yield notifyApiLogin(username, localStorage.getItem('X-Auth-Session')); + + errorLogs.push({ details: { notifyApiLogin1: res } }) + alreadyAuthorized = (res.status === 'ok'); } catch(error) { // Does not need to be fatal @@ -147,6 +153,9 @@ function* usernamePasswordLogin(action) { let authorized = false; try { const res = yield authApiLogin(username, null); + + errorLogs.push({ details: { authApiLogin1: res } }) + if (!res.already_authorized) { console.log('login_challenge', res.login_challenge); @@ -155,6 +164,9 @@ function* usernamePasswordLogin(action) { posting: postingWif, }); const res2 = yield authApiLogin(username, signatures); + + errorLogs.push({ details: { authApiLogin2: res2 } }) + if (res2.guid) { localStorage.setItem('guid', res2.guid) } @@ -173,6 +185,8 @@ function* usernamePasswordLogin(action) { try { const res = yield notifyApiLogin(username, localStorage.getItem('X-Auth-Session')); + errorLogs.push({ details: { notifyApiLogin2: res } }) + if (res.status !== 'ok') { throw new Error(res); } @@ -244,7 +258,6 @@ function* getAccountHandler({ payload: { usernames, resolve, reject }}) { } const accounts = yield call([api, api.getAccountsAsync], usernames) - for (let account of accounts) { yield put(g.actions.receiveAccount({ account })) } diff --git a/src/utils/MessageUtils.js b/src/utils/MessageUtils.js index 144a89bf6..f69da6d14 100644 --- a/src/utils/MessageUtils.js +++ b/src/utils/MessageUtils.js @@ -6,12 +6,20 @@ export function displayQuoteMsg(body) { } export function processDatedGroup(group, messages, for_each) { + let deleteIt if (group.nonce) { const idx = messages.findIndex(i => i.get('nonce') === group.nonce); if (idx !== -1) { messages = messages.update(idx, (msg) => { - return for_each(msg, idx); - }); + const { updated, fixIdx } = for_each(msg, idx) + if (!updated) { + deleteIt = idx + } + return updated || msg + }) + if (deleteIt !== undefined) { + messages = messages.delete(idx) + } } } else { let inRange = false; @@ -26,9 +34,36 @@ export function processDatedGroup(group, messages, for_each) { break; } if (inRange) { - messages = messages.set(idx, for_each(msg, idx)); + deleteIt = undefined + messages = messages.update(idx, (msg) => { + const { updated, fixIdx } = for_each(msg, idx) + if (!updated) { + deleteIt = idx + } + if (fixIdx !== undefined) { + idx = fixIdx + } + return updated || msg + }) + if (deleteIt !== undefined) { + messages = messages.delete(idx) + } } } } return messages; } + +export function opDeleteContact(op) { + let delete_contact + if (!op) return delete_contact + const { extensions } = op + if (extensions) { + for (const ext of extensions) { + if (ext && ext[0] === 1) { + delete_contact = ext[1] && ext[1].delete_contact + } + } + } + return delete_contact +} diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 9200d4da5..8c9eea862 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -1,20 +1,122 @@ import golos from 'golos-lib-js' import tt from 'counterpart' +import { getGroupLogo } from 'app/utils/groups' import { getProfileImage } from 'app/utils/NormalizeProfile'; -function getProfileImageLazy(account, cachedProfileImages) { - if (!account) - return getProfileImage(null); - let cached = cachedProfileImages[account.name]; - if (cached) - return cached; - const image = getProfileImage(account); - cachedProfileImages[account.name] = image; +const { decodeMsgs } = golos.messages + +function getProfileImageLazy(contact, account, cachedProfileImages) { + if (!contact || !contact.contact) + return getProfileImage(null) + const now = Date.now() + let cached = cachedProfileImages[contact.contact]; + if (cached && now - cached.time < 60*1000) + return cached.image + //console.log('getProfileImageLazy', contact.contact) + const image = contact.kind === 'group' ? + getGroupLogo(contact.object_meta) : getProfileImage(account) + cachedProfileImages[contact.contact] = { image, time: now } return image; } -export function normalizeContacts(contacts, accounts, currentUser, preDecoded, cachedProfileImages) { +const getCache = () => { + if (!window.preDecoded) window.preDecoded = {} + return window.preDecoded +} + +export const getSpaceInCache = (msg, spaceKey = '') => { + const preDecoded = getCache() + const key = spaceKey || (msg.group ? msg.group : '') + if (!preDecoded[key]) preDecoded[key] = {} + const space = preDecoded[key] + return space +} + +export const getContactsSpace = (msg) => { + return getSpaceInCache(msg, 'contacts') +} + +const zeroDate = '1970-01-01T00:00:00' + +const cacheKey = (msg) => { + let key = [msg.nonce] + let recDate = msg.receive_date + if (recDate === msg.create_date) { + recDate = zeroDate + } + if (msg.group) { + key.push(recDate) + key.push(msg.from) + key.push(msg.to) + } else { + key.push(recDate) + } + key = key.join('|') + return key +} + +export const saveToCache = (msg, contact = false, general = true) => { + if (!msg.message) return false + if (msg.group && msg.decrypt_date !== msg.receive_date) return false + const key = cacheKey(msg) + if (general) { + const space = getSpaceInCache(msg) + space[key] = { message: msg.message } + } + if (contact) { + const cont = getContactsSpace(msg) + cont[key] = { message: msg.message } + } + return true +} + +const loadFromCache = (msg, contact = false) => { + const space = getSpaceInCache(msg) + const key = cacheKey(msg) + const pd = space[key] + if (pd) { + msg.message = pd.message + return true + } + if (contact) { + const cont = getContactsSpace(msg) + const pdc = cont[key] + if (pdc) { + msg.message = pdc.message + return true + } + } + return false +} + +export function messageOpToObject(op, group, mentions = []) { + const obj = { + nonce: op.nonce, + checksum: op.checksum, + from: op.from, + from_memo_key: op.from_memo_key, + to_memo_key: op.to_memo_key, + group, + read_date: zeroDate, + create_date: new Date().toISOString().split('.')[0], + receive_date: zeroDate, + encrypted_message: op.encrypted_message, + donates: '0.000 GOLOS', + donates_uia: 0, + mentions, + } + return obj +} + +export function cacheMyOwnMsg(op, group, message) { + const newMsg = messageOpToObject(op, group ? group.name : '') + newMsg.message = message + newMsg.decrypt_date = newMsg.receive_date + saveToCache(newMsg, true) +} + +export async function normalizeContacts(contacts, accounts, currentUser, cachedProfileImages) { if (!currentUser || !accounts) return []; @@ -22,53 +124,77 @@ export function normalizeContacts(contacts, accounts, currentUser, preDecoded, c if (!currentAcc) return []; - const private_key = currentUser.getIn(['private_keys', 'memo_private']); + const posting = currentUser.getIn(['private_keys', 'posting_private']) + const private_memo = currentUser.getIn(['private_keys', 'memo_private']); const tt_invalid_message = tt('messages.invalid_message'); let contactsCopy = contacts ? [...contacts.toJS()] : []; + let messages = [] for (let contact of contactsCopy) { let account = accounts && accounts[contact.contact]; - contact.avatar = getProfileImageLazy(account, cachedProfileImages); + + const isGroup = contact.kind === 'group' + const { url, isDefault } = getProfileImageLazy(contact, + account, + cachedProfileImages) + if (!isDefault || isGroup) { + contact.avatar = url + } if (contact.last_message.create_date.startsWith('1970')) { contact.last_message.message = { body: '', }; continue; } - let public_key; - if (currentAcc.memo_key === contact.last_message.to_memo_key) { - public_key = contact.last_message.from_memo_key; - } else { - public_key = contact.last_message.to_memo_key; - } + messages.push(contact.last_message) + } - try { - golos.messages.decode(private_key, public_key, [contact.last_message], - (msg, idx, results) => { + try { + await decodeMsgs({ msgs: messages, private_memo, + login: { + account: currentAcc.name, keys: { posting }, + }, + before_decode: (msg, idx, results) => { + if (!msg.isGroup) { if (msg.read_date.startsWith('19') && msg.from === currentAcc.name) { msg.unread = true; } - let pd = preDecoded[msg.nonce + '' + msg.receive_date]; - if (pd) { - msg.message = pd; - return true; - } - return false; - }, (msg) => { - preDecoded[msg.nonce + '' + msg.receive_date] = msg.message; - }, (msg, idx, exception) => { - msg.message = { body: tt_invalid_message, invalid: true, }; - }, 0, 1); - } catch (ex) { - console.log(ex); - } + } + + if (window._perfo) console.log('FCC1') + if (loadFromCache(msg, true)) { + return true + } + if (window._perfo) console.log('FCC2') + return false; + }, + for_each: (msg) => { + saveToCache(msg, true) + }, + on_error: (msg, idx, exception) => { + console.error(exception) + msg.message = { body: tt_invalid_message, invalid: true, }; + saveToCache(msg, true, false) + }, + begin_idx: 0, + end_idx: messages.length, + }) + } catch (ex) { + console.log(ex); } + return contactsCopy } -export function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { - if (!to || !accounts[to]) { +export async function normalizeMessages(messages, accounts, currentUser, to) { + let isGroup = false + if (to) { + if (to[0] !== '@') isGroup = true + to = to.replace('@', '') + } + + if (!to || (!isGroup && !accounts[to])) { return []; } @@ -76,46 +202,64 @@ export function normalizeMessages(messages, accounts, currentUser, to, preDecode let id = 0; try { - const private_key = currentUser.getIn(['private_keys', 'memo_private']); - let currentAcc = accounts[currentUser.get('username')]; const tt_invalid_message = tt('messages.invalid_message'); - let messagesCopy2 = golos.messages.decode(private_key, accounts[to].memo_key, messagesCopy, - (msg, i, results) => { + const posting = currentUser.getIn(['private_keys', 'posting_private']) + const privateMemo = currentUser.getIn(['private_keys', 'memo_private']); + + if (window._perfo) console.log('ttt', Date.now()) + const decoded = await decodeMsgs({ msgs: messagesCopy, + private_memo: !isGroup && privateMemo, + login: { + account: currentAcc.name, keys: { posting }, + }, + before_decode: (msg, i, results) => { msg.id = ++id; msg.author = msg.from; msg.date = new Date(msg.create_date + 'Z'); - if (msg.to === currentAcc.name) { - if (msg.read_date.startsWith('19')) { - msg.toMark = true; - } - } else { - if (msg.read_date.startsWith('19')) { - msg.unread = true; + if (msg.read_date.startsWith('19')) { + if (!isGroup) { + if (msg.to === currentAcc.name) { + msg.toMark = true + } else { + msg.unread = true + } + } else { + if (msg.to === currentAcc.name) { + msg.toMark = true + } else if (msg.to) { + msg.unread = true + } + if (!msg.toMark && msg.mentions.includes(currentAcc.name)) { + msg.toMark = true + } } } + //msg.decrypt_date = null - let pd = preDecoded[msg.nonce + '' + msg.receive_date]; - if (pd) { - msg.message = pd; - results.push(msg); - return true; + if (window._perfo) console.log('FC1') + if (loadFromCache(msg)) { + results.push(msg) + return true } + if (window._perfo) console.log('FC2') return false; }, - (msg) => { - preDecoded[msg.nonce + '' + msg.receive_date] = msg.message; + for_each: (msg, i) => { + saveToCache(msg) }, - (msg, i, err) => { - console.log(err); - msg.message = {body: tt_invalid_message, invalid: true}; + on_error: (msg, i, err) => { + console.error(err, msg) + msg.message = {body: tt_invalid_message, invalid: true} }, - messagesCopy.length - 1, -1); - - return messagesCopy2; + begin_idx: messagesCopy.length - 1, + end_idx: -1, + }) + if (window._perfo) console.log('ttte', Date.now()) + return decoded } catch (ex) { console.log(ex); return []; diff --git a/src/utils/NormalizeProfile.js b/src/utils/NormalizeProfile.js index a3c0f3ad4..e2f5dbfad 100644 --- a/src/utils/NormalizeProfile.js +++ b/src/utils/NormalizeProfile.js @@ -14,14 +14,14 @@ export function getProfileImage(account, size = 48) { if (url && /^(https?:)\/\//.test(url)) { size = size > 75 ? '200x200' : '75x75'; url = proxifyImageUrl(url, size); - return url; + return { url } } } } catch (e) { console.error(e); } } - return require('app/assets/images/user.png'); + return { url: require('app/assets/images/user.png'), isDefault: true } } /** @@ -32,6 +32,6 @@ export function getLastSeen(account) { account.last_bandwidth_update, // all operations account.created, ]; - const last = max(dates); - return last.startsWith('19') ? null : last; + const last = account.last_seen || max(dates); + return (!last || last.startsWith('19')) ? null : last; } diff --git a/src/utils/NotifyApiClient.js b/src/utils/NotifyApiClient.js index 58068c580..602892e90 100644 --- a/src/utils/NotifyApiClient.js +++ b/src/utils/NotifyApiClient.js @@ -14,12 +14,20 @@ const notifyAvailable = () => { && $GLS_Config.notify_service && $GLS_Config.notify_service.host; }; -const notifyUrl = (pathname) => { +export function notifyWsHost() { + return notifyAvailable() && $GLS_Config.notify_service.host_ws +} + +export function notifyUrl(pathname = '') { return new URL(pathname, window.$GLS_Config.notify_service.host).toString(); -}; +} + +function notifySession() { + return localStorage.getItem('X-Session') +} function setSession(request) { - request.headers['X-Session'] = localStorage.getItem('X-Session'); + request.headers['X-Session'] = notifySession() } function saveSession(response) { @@ -34,6 +42,103 @@ function saveSession(response) { localStorage.setItem('X-Session', session); } + +async function connectNotifyWs() { + if (!window.notifyWs || window.notifyWs.readyState !== 1) { + window.notifyWsReq = { id: 0, requests: {}, callbacks: {} } + if (window.notifyWs) { + window.notifyWs.close() + } + await new Promise((resolve, reject) => { + const notifyWs = new WebSocket(notifyWsHost()) + window.notifyWs = notifyWs + + const timeout = setTimeout(() => { + if (notifyWs && !notifyWs.isOpen) { + reject(new Error('Cannot connect Notify WS')) + } + }, 5000) + + notifyWs.addEventListener('open', () => { + notifyWs.isOpen = true + clearTimeout(timeout) + resolve() + }) + + notifyWs.addEventListener('сlose', () => { + if (!notifyWs.isOpen) { + clearTimeout(timeout) + const err = new Error('notifyWs - cannot connect') + reject(err) + } else { + console.log('NOTW close') + } + }) + + notifyWs.addEventListener('message', (msg) => { + if (window._notifyDebug) { + console.log('notifyWs message:', msg) + } + const data = JSON.parse(msg.data) + const id = data.id + const request = window.notifyWsReq.requests[id] + if (request) { + const cleanRequest = () => { + delete window.notifyWsReq.requests[id] + } + + if (data.err) { + request.callback(new Error(data.err.code + ': ' + data.err.msg), data) + cleanRequest() + return + } + request.callback(null, data.data) + cleanRequest() + } else if (!id && data.data && data.data.event) { + const { event } = data.data + const callback = window.notifyWsReq.callbacks[event] + if (callback) { + callback.callback(null, data.data) + } + } + }) + }) + } +} + +async function notifyWsSend(api, args, callback = null, eventCallback = null) { + try { + await connectNotifyWs() + const id = window.notifyWsReq.id++ + let msg = { + api, + args, + id + } + msg = JSON.stringify(msg) + if (callback) { + window.notifyWsReq.requests[id] = { callback } + } + if (eventCallback) { + const { event, callback } = eventCallback + window.notifyWsReq.callbacks[event] = { callback } + } + window.notifyWs.send(msg) + } catch (err) { + if (callback) { + callback(err, null) + } + } +} + +export async function notifyWsPing() { + await connectNotifyWs() + if (!window.notifyWs || window.notifyWs.readyState !== 1) { + throw new Error('Ping detected what Notify WS not ready') + } + window.notifyWs.send(JSON.stringify({ ping: 1 })) +} + export function notifyApiLogin(account, authSession) { if (!notifyAvailable()) return; let request = Object.assign({}, request_base, { @@ -105,6 +210,25 @@ export async function notificationSubscribe(account, scopes = 'message,donate_ms throw new Error('Cannot subscribe'); } +export async function notificationSubscribeWs(account, callback, scopes = 'message,donate_msgs', sidKey = '__subscriber_id') { + if (!notifyWsHost()) throw new Error('No notify_service host_ws in config') + const xSession = notifySession() + return await new Promise(async (resolve, reject) => { + await notifyWsSend('queues/subscribe', { + account, + 'X-Session': xSession, + scopes, + }, (err, res) => { + if (err) { + reject(err) + return + } + window[sidKey] = res.subscriber_id + resolve(res) + }, { event: 'queue', callback}) + }) +} + export async function notificationUnsubscribe(account, sidKey = '__subscriber_id') { if (!notifyAvailable()) return; if (!window[sidKey]) return; @@ -178,6 +302,53 @@ export async function notificationTake(account, removeTaskIds, forEach, abortCon } } +export async function queueWatch(account, group, sidKey = '__subscriber_id') { + if (!notifyAvailable()) return + let url = notifyUrl(`/queues/watch/@${account}/${window[sidKey]}/group?o_scope=*&o=${group}`) + let response + try { + let request = Object.assign({}, request_base, { + method: 'get', + }) + setSession(request) + response = await fetchEx(url, request) + if (response.ok) { + saveSession(response) + } + const result = await response.json() + if (result.status === 'ok') { + return + } else { + throw new Error('error: ' + result.error) + } + } catch (ex) { + throw ex + } +} + +export async function queueWatchWs(account, group, sidKey = '__subscriber_id') { + if (!notifyWsHost()) return null + const xSession = notifySession() + return await new Promise(async (resolve, reject) => { + await notifyWsSend('queues/subscribe', { + account, + 'X-Session': xSession, + objects: { + [group]: { + type: 'group', + scope: '*', + }, + }, + }, (err, res) => { + if (err) { + reject(err) + return + } + resolve(res) + }) + }) +} + export async function sendOffchainMessage(op) { if (!notifyAvailable()) return; let url = notifyUrl(`/msgs/send_offchain`); @@ -194,7 +365,7 @@ export async function sendOffchainMessage(op) { } const result = await response.json(); if (result.status === 'ok') { - return; + return result } else { throw new Error('error: ' +result.error); } @@ -204,11 +375,13 @@ export async function sendOffchainMessage(op) { } } -if (process.env.BROWSER) { +//if (process.env.BROWSER) { window.getNotifications = getNotifications; window.markNotificationRead = markNotificationRead; window.notificationSubscribe = notificationSubscribe; + window.notificationSubscribeWs = notificationSubscribeWs window.notificationUnsubscribe = notificationUnsubscribe; window.notificationShallowUnsubscribe = notificationShallowUnsubscribe window.notificationTake = notificationTake; -} + window.queueWatch = queueWatch +//} diff --git a/src/utils/ServerApiClient.js b/src/utils/ServerApiClient.js index 8d9fff818..7e8d04c8b 100644 --- a/src/utils/ServerApiClient.js +++ b/src/utils/ServerApiClient.js @@ -3,7 +3,7 @@ import { fetchEx } from 'golos-lib-js/lib/utils' export function getHost() { const { location, } = window; if (process.env.NODE_ENV === 'development') { - return location.protocol + '//'+ location.hostname + ':8080'; + return location.protocol + '//'+ location.hostname + ':8088'; } return location.origin; } diff --git a/src/utils/app/SplashUtils.js b/src/utils/app/SplashUtils.js index fdd8b6658..1e0c4f305 100644 --- a/src/utils/app/SplashUtils.js +++ b/src/utils/app/SplashUtils.js @@ -1,10 +1,13 @@ export function hideSplash() { - if (process.env.MOBILE_APP) { - try { + try { + if (process.env.MOBILE_APP) { navigator.splashscreen.hide() - } catch (err) { - console.error('hideSplash', err) + } else if (process.env.DESKTOP_APP) { + if (window.appSplash) + window.appSplash.contentLoaded() } + } catch (err) { + console.error('hideSplash', err) } } diff --git a/src/utils/app/UpdateUtils.js b/src/utils/app/UpdateUtils.js index 2933db93d..f89da6693 100644 --- a/src/utils/app/UpdateUtils.js +++ b/src/utils/app/UpdateUtils.js @@ -55,10 +55,15 @@ export async function checkUpdates(timeout = 2000) { if (versions[0]) { const [ v, obj ] = versions[0] if (obj.exe) { + let exeLink = new URL(obj.exe_url, updaterHost()).toString() + if (!isDesktop) { + exeLink = new URL('/api/html/' + path + '/' + v, updaterHost()) + exeLink = exeLink.toString() + } return { version: v, exe: obj.exe, - exeLink: new URL(obj.exe_url, updaterHost()).toString(), + exeLink, txt: obj.txt, txtLink: new URL(obj.txt_url, updaterHost()).toString(), title: tt('app_update.notify_VERSION', { VERSION: v }), @@ -84,6 +89,6 @@ export async function getChangelog(txtLink) { return res } catch (err) { console.error('getChangelog', err) - return '' + throw err } } diff --git a/src/utils/groups.js b/src/utils/groups.js new file mode 100644 index 000000000..b99994d98 --- /dev/null +++ b/src/utils/groups.js @@ -0,0 +1,88 @@ +import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' + +const getGroupMeta = (json_metadata) => { + let meta + if (json_metadata) { + meta = JSON.parse(json_metadata) + } + meta = meta || {} // node allows null, object, array... or empty json_metadata + return meta +} + +const getGroupTitle = (meta, name, maxLength = 20) => { + const title = meta.title || name + let titleShr = title + if (titleShr.length > maxLength) { + titleShr = titleShr.substring(0, maxLength - 3) + '...' + } + return titleShr +} + +const getGroupLogo = (json_metadata) => { + const meta = getGroupMeta(json_metadata) + + let { logo } = meta + let isDefault = false + if (logo && /^(https?:)\/\//.test(logo)) { + const size = '75x75' + logo = proxifyImageUrlWithStrip(logo, size) + } else { + logo = require('app/assets/images/group.png') + isDefault = true + } + return {url: logo, isDefault } +} + +const getMemberType = (member_list, username) => { + const mem = member_list.find(pgm => pgm.account === username) + const { member_type } = (mem || {}) + return member_type +} + +const getRoleInGroup = (group, username) => { + if (group.toJS) group = group.toJS() + const { owner, member_list } = group + const memberType = member_list && getMemberType(member_list, username) + + const amOwner = owner === username + const amModer = amOwner || memberType === 'moder' + const amPending = memberType === 'pending' + const amMember = memberType === 'member' + const amBanned = memberType === 'banned' + + return { amOwner, amModer, amPending, amMember, amBanned } +} + +const opGroup = (op) => { + let group = '' + let requester = '' + let mentions = [] + if (!op) return { group, requester, mentions } + const { extensions, memo } = op + if (extensions) { + for (const ext of extensions) { + if (ext && ext[0] === 0 && ext[1]) { + group = ext[1].group || group + mentions = ext[1].mentions || mentions + requester = ext[1].requester || requester + } + } + } + if (group) return { group, requester, mentions } + if (memo) { // donate + const { target } = memo + if (target && target.group) { + group = target.group + } + } + return { group, requester, mentions } +} + +export { + getGroupMeta, + getGroupTitle, + getGroupLogo, + getMemberType, + getRoleInGroup, + opGroup, +} diff --git a/src/utils/initConfig.js b/src/utils/initConfig.js index 290276805..2dce1ebd2 100644 --- a/src/utils/initConfig.js +++ b/src/utils/initConfig.js @@ -23,7 +23,10 @@ const loadMobileConfig = async () => { if (cfg) { try { cfg = JSON.parse(cfg) - // Add here migrations in future, if need + // Add here migrations + if (cfg.notify_service && !cfg.notify_service.host_ws) { + delete cfg.notify_service + } cfg = { ...defaultCfg, ...cfg } } catch (err) { console.error('Cannot parse app_settings', err) @@ -38,6 +41,7 @@ const loadMobileConfig = async () => { if (cfg.images.use_img_proxy === undefined) { cfg.images.use_img_proxy = true } + cfg.app_version = defaultCfg.app_version window.$GLS_Config = cfg await initGolos() } diff --git a/src/utils/mentions.js b/src/utils/mentions.js new file mode 100644 index 000000000..a4232b6b8 --- /dev/null +++ b/src/utils/mentions.js @@ -0,0 +1,18 @@ + +export const accountNameRegEx = /^@[a-z0-9.-]+$/ + +// TODO: can be renderMsg which also supports links, and rendering +export function parseMentions(message) { + let mentions = new Set() + const { body } = message + const lines = body.split('\n') + for (const line of lines) { + const words = line.split(' ') + for (let word of words) { + if (word.length > 3 && accountNameRegEx.test(word)) { + mentions.add(word.slice(1)) + } + } + } + return [...mentions] +} diff --git a/src/utils/misc.js b/src/utils/misc.js index 23688d782..b777af5d0 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -5,6 +5,29 @@ const renderPart = (part, params) => { return part } +const delay = (msec) => new Promise(resolve => setTimeout(resolve, msec)) + +const maxDate = () => { + return new Date(4294967295 * 1000) +} + +const maxDateStr = () => { + return maxDate().toISOString().split('.')[0] +} + +const isBlockedByMe = (acc) => { + return acc && acc.relations && acc.relations.me_to_them === 'blocking' +} + +const isBlockingMe = (acc) => { + return acc && acc.relations && acc.relations.they_to_me === 'blocking' +} + export { - renderPart + renderPart, + delay, + maxDate, + maxDateStr, + isBlockedByMe, + isBlockingMe, } diff --git a/src/utils/translateError.js b/src/utils/translateError.js index 011d77a4c..92c78d421 100644 --- a/src/utils/translateError.js +++ b/src/utils/translateError.js @@ -1,6 +1,41 @@ import tt from 'counterpart' +import { Asset } from 'golos-lib-js/lib/utils' -export function translateError(string) { +const getErrorData = (errPayload, errName, depth = 0) => { + //console.error('getErrorData', errPayload) + if (depth > 50) { + throw new Error('getErrorData - infinity loop detected...') + } + if (!errPayload) { + return null + } + console.error(errPayload.name) + if (errPayload.name === errName) { + let { stack } = errPayload + stack = stack && stack[0] + return stack ? stack.data : null + } + const { error, data } = errPayload + if (error) { + return getErrorData(error, errName, ++depth) + } + if (data) { + if (data.error) { + return getErrorData(data.error, errName, ++depth) + } + if (Array.isArray(data.stack)) { + for (const s of data.stack) { + const res = getErrorData(s, errName, ++depth) + if (res) { + return res + } + } + } + } + return null +} + +export function translateError(string, errPayload) { if (typeof(string) != 'string') return string switch (string) { case 'Account not found': @@ -28,6 +63,64 @@ export function translateError(string) { case 'Cannot increase reward of post within the last minute before payout': return tt('g.cannot_increase_reward_of_post_within_the_last_minute_before_payout') default: - return string + break + } + + if (string.includes( + 'Account exceeded maximum allowed bandwidth per vesting share' + )) { + string = tt('chain_errors.exceeded_maximum_allowed_bandwidth') + return string } + + if (string.includes( + 'Account does not have sufficient funds' + )) { + string = tt('donate_jsx.insufficient_funds') + '.' + + let errData + try { + errData = getErrorData(errPayload, 'insufficient_funds') + if (errData && errData.required) { + let { required, exist } = errData + string += ' ' + tt('chain_errors.insufficient1') + string += Asset(required).floatString + exist = Asset(exist) + if (exist.gt(0)) { + string += tt('chain_errors.insufficient2') + string += exist.floatString + string += tt('chain_errors.insufficient3') + } else { + string += '.' + } + return string + } + } catch (err) { + console.error('getErrorData', err) + } + } + + if (string.includes( + 'Too low golos power' + )) { + let errData + try { + errData = getErrorData(errPayload, 'logic_exception') + if (errData && errData.r) { + string = tt('messages.too_low_gp') + string += Asset(errData.r).floatString + string += tt('messages.too_low_gp2') + return string + } + } catch (err) { + console.error('getErrorData', err) + } + } + + if (string.includes('You should be moder')) { + string = tt('messages.you_not_moder') + return string + } + + return string } diff --git a/yarn.lock b/yarn.lock index 06c7c8178..84575d8e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1031,6 +1031,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" + integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.16.7", "@babel/template@^7.3.3": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" @@ -1109,6 +1116,94 @@ dependencies: postcss-value-parser "^4.2.0" +"@emotion/babel-plugin@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" + integrity sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/serialize" "^1.1.2" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + +"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + +"@emotion/hash@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43" + integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== + +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + +"@emotion/react@^11.8.1": + version "11.11.4" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.4.tgz#3a829cac25c1f00e126408fab7f891f00ecc3c1d" + integrity sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/cache" "^11.11.0" + "@emotion/serialize" "^1.1.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + hoist-non-react-statics "^3.3.1" + +"@emotion/serialize@^1.1.2", "@emotion/serialize@^1.1.3": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.4.tgz#fc8f6d80c492cfa08801d544a05331d1cc7cd451" + integrity sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ== + dependencies: + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/unitless" "^0.8.1" + "@emotion/utils" "^1.2.1" + csstype "^3.0.2" + +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + +"@emotion/unitless@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + +"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963" + integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== + +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + "@eslint/eslintrc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.1.0.tgz#583d12dbec5d4f22f333f9669f7d0b7c7815b4d3" @@ -1124,6 +1219,26 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.0.0": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.3.tgz#5e7bb92843f47fd1d8dcb9b3cc3c243aaed54f95" + integrity sha512-1ZpCvYf788/ZXOhRQGFxnYQOVgeU+pi0i+d0Ow34La7qjIXETi6RNswGVKkA6KcDO8/+Ysu2E/CeUmmeEBDvTg== + dependencies: + "@floating-ui/utils" "^0.2.3" + +"@floating-ui/dom@^1.0.1": + version "1.6.6" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.6.tgz#be54c1ab2d19112ad323e63dbeb08185fed0ffd3" + integrity sha512-qiTYajAnh3P+38kECeffMSQgbvXty2VB6rS+42iWR4FPIlZjLK84E9qtLnMTLIpPz2znD/TaFqaiavMUrS+Hcw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.3" + +"@floating-ui/utils@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.3.tgz#506fcc73f730affd093044cb2956c31ba6431545" + integrity sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww== + "@formatjs/ecma402-abstract@1.11.3": version "1.11.3" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.3.tgz#f25276dfd4ef3dac90da667c3961d8aa9732e384" @@ -1925,6 +2040,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-transition-group@^4.4.0": + version "4.4.10" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac" + integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@16 || 17": version "17.0.39" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.39.tgz#d0f4cde092502a6db00a1cded6e6bf2abb7633ce" @@ -2495,14 +2617,15 @@ assert-plus@^1.0.0: integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= assert@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-2.0.0.tgz#95fc1c616d48713510680f2eaf2d10dd22e02d32" - integrity sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A== + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== dependencies: - es6-object-assign "^1.1.0" - is-nan "^1.2.1" - object-is "^1.0.1" - util "^0.12.0" + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" ast-types-flow@^0.0.7: version "0.0.7" @@ -2548,10 +2671,12 @@ autoprefixer@^10.4.2: picocolors "^1.0.0" postcss-value-parser "^4.2.0" -available-typed-arrays@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" - integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" axe-core@^4.3.5: version "4.4.1" @@ -2941,6 +3066,17 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" +call-bind@^1.0.5, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -3299,6 +3435,11 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +convert-source-map@^1.5.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -3420,9 +3561,9 @@ core-js@^2.4.0: integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.17.3: - version "3.23.3" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.23.3.tgz#3b977612b15da6da0c9cc4aec487e8d24f371112" - integrity sha512-oAKwkj9xcWNBAvGbT//WiCdOMpb9XQG92/Fe3ABFM/R16BsHgePG00mFOgKf7IsCtfj8tA1kHtf/VwErhriz5Q== + version "3.37.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.0.tgz#d8dde58e91d156b2547c19d8a4efd5c7f6c426bb" + integrity sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug== core-js@^3.19.2: version "3.21.0" @@ -3505,11 +3646,11 @@ cross-env@^7.0.3: cross-spawn "^7.0.1" cross-fetch@^3.0.0: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== dependencies: - node-fetch "2.6.7" + node-fetch "^2.6.12" cross-spawn@^6.0.5: version "6.0.5" @@ -3838,6 +3979,15 @@ default-gateway@^6.0.3: dependencies: execa "^5.0.0" +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -3850,11 +4000,12 @@ define-properties@^1.1.3: dependencies: object-keys "^1.0.12" -define-properties@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" - integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== dependencies: + define-data-property "^1.0.1" has-property-descriptors "^1.0.0" object-keys "^1.1.1" @@ -4014,6 +4165,14 @@ dom-helpers@^3.2.1: dependencies: "@babel/runtime" "^7.1.2" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -4235,34 +4394,17 @@ es-abstract@^1.17.2, es-abstract@^1.19.0, es-abstract@^1.19.1: string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.1" -es-abstract@^1.19.5, es-abstract@^1.20.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" - integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== dependencies: - call-bind "^1.0.2" - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - function.prototype.name "^1.1.5" - get-intrinsic "^1.1.1" - get-symbol-description "^1.0.0" - has "^1.0.3" - has-property-descriptors "^1.0.0" - has-symbols "^1.0.3" - internal-slot "^1.0.3" - is-callable "^1.2.4" - is-negative-zero "^2.0.2" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - is-string "^1.0.7" - is-weakref "^1.0.2" - object-inspect "^1.12.0" - object-keys "^1.1.1" - object.assign "^4.1.2" - regexp.prototype.flags "^1.4.3" - string.prototype.trimend "^1.0.5" - string.prototype.trimstart "^1.0.5" - unbox-primitive "^1.0.2" + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== es-module-lexer@^0.9.0: version "0.9.3" @@ -4278,11 +4420,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-object-assign@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" - integrity sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw== - escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -4751,12 +4888,12 @@ file-loader@^6.2.0: loader-utils "^2.0.0" schema-utils "^3.0.0" -file-selector@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.4.0.tgz#59ec4f27aa5baf0841e9c6385c8386bef4d18b17" - integrity sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg== +file-selector@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" + integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw== dependencies: - tslib "^2.0.3" + tslib "^2.4.0" filelist@^1.0.1: version "1.0.2" @@ -4799,6 +4936,11 @@ find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" @@ -4889,9 +5031,8 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -"formik@https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465": +formik@./git-deps/formik/packages/formik: version "2.2.9" - resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465#a696d8404c7b8751188a426f347589fdc24f4ba7" dependencies: deepmerge "^2.1.1" hoist-non-react-statics "^3.3.0" @@ -4969,26 +5110,16 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -function.prototype.name@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" - integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.0" - functions-have-names "^1.2.2" +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -functions-have-names@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -5008,6 +5139,17 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -5114,10 +5256,10 @@ globby@^11.0.1, globby@^11.0.4: "gls-messenger-native-core@file:native_core": version "1.0.0" -golos-lib-js@^0.9.34: - version "0.9.34" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.34.tgz#6c03fca60fc6749cb240e21793aa5f01ec389f80" - integrity sha512-0UYh5/r5T8yz8rD40AEsoJH6txNeat+MrHF1LlgjnHIfJdI0qqUko+pNv75XlX5WEOOhyy6Ho3BjLimLtITtAg== +golos-lib-js@^0.9.76: + version "0.9.76" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.76.tgz#c589b26a8f77916529f2fb6e1020bb87f4cb0a7f" + integrity sha512-E9A9BnVoOoPjklxGJVxB3xKgLbLSCaXfW0lN4pipAKuokGEVFy8DPEwlUsFgmY9Jf9JFcwl5h6q2c1dzEuBGkQ== dependencies: abort-controller "^3.0.0" assert "^2.0.0" @@ -5141,6 +5283,13 @@ golos-lib-js@^0.9.34: stream-browserify "^3.0.0" ws "^8.2.3" +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + graceful-fs@4.1.15: version "4.1.15" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" @@ -5173,11 +5322,6 @@ has-bigints@^1.0.1: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== -has-bigints@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -5188,12 +5332,17 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== dependencies: - get-intrinsic "^1.1.1" + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" @@ -5212,6 +5361,13 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -5228,6 +5384,13 @@ hash-base@^3.0.0: readable-stream "^3.6.0" safe-buffer "^5.2.0" +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -5250,7 +5413,7 @@ history@4.10.1, history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -5610,7 +5773,12 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.3: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -5668,7 +5836,7 @@ is-module@^1.0.0: resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= -is-nan@^1.2.1: +is-nan@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== @@ -5676,7 +5844,7 @@ is-nan@^1.2.1: call-bind "^1.0.0" define-properties "^1.1.3" -is-negative-zero@^2.0.1, is-negative-zero@^2.0.2: +is-negative-zero@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== @@ -5741,13 +5909,6 @@ is-shared-array-buffer@^1.0.1: resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== -is-shared-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" - integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== - dependencies: - call-bind "^1.0.2" - is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -5767,23 +5928,19 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-typed-array@^1.1.3, is-typed-array@^1.1.9: - version "1.1.9" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67" - integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A== +is-typed-array@^1.1.3: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - es-abstract "^1.20.0" - for-each "^0.3.3" - has-tostringtag "^1.0.0" + which-typed-array "^1.1.14" is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -is-weakref@^1.0.1, is-weakref@^1.0.2: +is-weakref@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== @@ -6791,6 +6948,11 @@ memfs@^3.1.2, memfs@^3.4.1: dependencies: fs-monkey "1.0.3" +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -6955,10 +7117,10 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -node-fetch@2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: whatwg-url "^5.0.0" @@ -7063,11 +7225,6 @@ object-inspect@^1.11.0, object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== -object-inspect@^1.12.0: - version "1.12.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" - integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== - object-is@^1.0.1: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" @@ -7076,6 +7233,14 @@ object-is@^1.0.1: call-bind "^1.0.2" define-properties "^1.1.3" +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -7091,6 +7256,16 @@ object.assign@^4.1.0, object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" +object.assign@^4.1.4: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + object.entries@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" @@ -7478,6 +7653,11 @@ portfinder@^1.0.28: debug "^3.1.1" mkdirp "^0.5.5" +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + postcss-attribute-case-insensitive@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.0.tgz#39cbf6babf3ded1e4abf37d09d6eda21c644105c" @@ -8069,7 +8249,7 @@ prop-types-extra@^1.0.1, prop-types-extra@^1.1.1: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8249,13 +8429,13 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" -react-dropzone@^12.0.4: - version "12.0.4" - resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-12.0.4.tgz#b88eeaa2c7118f7fd042404682b17a1d466f2fcf" - integrity sha512-fcqHEYe1MzAghU6/Hz86lHDlBNsA+lO48nAcm7/wA+kIzwS6uuJbUG33tBZjksj7GAZ1iUQ6NHwjUURPmSGang== +react-dropzone@^14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" + integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug== dependencies: attr-accept "^2.2.2" - file-selector "^0.4.0" + file-selector "^0.6.0" prop-types "^15.8.1" react-error-overlay@^6.0.10: @@ -8268,9 +8448,8 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -"react-foundation-components@git+https://github.com/golos-blockchain/react-foundation-components.git#6606fd5529f1ccbc77cd8d33a8ce139fdf8f9a11": +react-foundation-components@./git-deps/react-foundation-components: version "0.14.0" - resolved "git+https://github.com/golos-blockchain/react-foundation-components.git#6606fd5529f1ccbc77cd8d33a8ce139fdf8f9a11" dependencies: babel-runtime "^6.25.0" classnames "^2.2.5" @@ -8428,6 +8607,21 @@ react-scripts@^5.0.0: optionalDependencies: fsevents "^2.3.2" +react-select@^5.8.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.0.tgz#bd5c467a4df223f079dd720be9498076a3f085b5" + integrity sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA== + dependencies: + "@babel/runtime" "^7.12.0" + "@emotion/cache" "^11.4.0" + "@emotion/react" "^11.8.1" + "@floating-ui/dom" "^1.0.1" + "@types/react-transition-group" "^4.4.0" + memoize-one "^6.0.0" + prop-types "^15.6.0" + react-transition-group "^4.3.0" + use-isomorphic-layout-effect "^1.1.2" + react-textarea-autosize@^8.3.3: version "8.3.3" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8" @@ -8437,6 +8631,16 @@ react-textarea-autosize@^8.3.3: use-composed-ref "^1.0.0" use-latest "^1.0.0" +react-transition-group@^4.3.0: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -8466,7 +8670,7 @@ readable-stream@^2.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.5.0, readable-stream@^3.6.0: +readable-stream@^3.0.6: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -8475,6 +8679,15 @@ readable-stream@^3.0.6, readable-stream@^3.5.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^3.5.0, readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -8563,6 +8776,11 @@ regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.9: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regenerator-transform@^0.14.2: version "0.14.5" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4" @@ -8583,15 +8801,6 @@ regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.1: call-bind "^1.0.2" define-properties "^1.1.3" -regexp.prototype.flags@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" - integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - functions-have-names "^1.2.2" - regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" @@ -8890,7 +9099,12 @@ semver@7.0.0, semver@~7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: +semver@^5.5.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -8970,6 +9184,18 @@ serve-static@1.14.2: parseurl "~1.3.3" send "0.17.2" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -9108,7 +9334,7 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, sourc resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.5.0: +source-map@^0.5.0, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -9153,6 +9379,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +speakingurl@^14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" + integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== + sprintf-js@^1.0.3, sprintf-js@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" @@ -9255,15 +9486,6 @@ string.prototype.trimend@^1.0.4: call-bind "^1.0.2" define-properties "^1.1.3" -string.prototype.trimend@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" - integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.19.5" - string.prototype.trimstart@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" @@ -9272,15 +9494,6 @@ string.prototype.trimstart@^1.0.4: call-bind "^1.0.2" define-properties "^1.1.3" -string.prototype.trimstart@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef" - integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.19.5" - string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -9356,6 +9569,11 @@ stylehacks@^5.0.3: browserslist "^4.16.6" postcss-selector-parser "^6.0.4" +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -9638,6 +9856,11 @@ tslib@^2.2.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslib@^2.4.0: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" @@ -9728,16 +9951,6 @@ unbox-primitive@^1.0.1: has-symbols "^1.0.2" which-boxed-primitive "^1.0.2" -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== - dependencies: - call-bind "^1.0.2" - has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" - uncontrollable@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-5.1.0.tgz#7e9a1c50ea24e3c78b625e52d21ff3f758c7bd59" @@ -9845,6 +10058,11 @@ use-isomorphic-layout-effect@^1.0.0: resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225" integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ== +use-isomorphic-layout-effect@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-latest@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232" @@ -9867,16 +10085,15 @@ util.promisify@~1.0.0: has-symbols "^1.0.1" object.getownpropertydescriptors "^2.1.0" -util@^0.12.0: - version "0.12.4" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" - integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== +util@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== dependencies: inherits "^2.0.3" is-arguments "^1.0.4" is-generator-function "^1.0.7" is-typed-array "^1.1.3" - safe-buffer "^5.1.2" which-typed-array "^1.1.2" utila@~0.4: @@ -10162,17 +10379,16 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" -which-typed-array@^1.1.2: - version "1.1.8" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" - integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== +which-typed-array@^1.1.14, which-typed-array@^1.1.2: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - es-abstract "^1.20.0" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" for-each "^0.3.3" - has-tostringtag "^1.0.0" - is-typed-array "^1.1.9" + gopd "^1.0.1" + has-tostringtag "^1.0.2" which@^1.2.9, which@^1.3.1: version "1.3.1" @@ -10403,9 +10619,9 @@ ws@^7.4.6: integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== ws@^8.2.3: - version "8.8.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.0.tgz#8e71c75e2f6348dbf8d78005107297056cb77769" - integrity sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ== + version "8.17.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" + integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== ws@^8.4.2: version "8.5.0"