From 07fd58197f0f9601bd4c61e7e690503a9f943fc5 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sun, 20 Aug 2023 20:42:42 +0000 Subject: [PATCH 01/50] HF 29 - Add delegate_vs, and sponsor counters to messenger --- src/components/pages/Messages.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index a703aa019..74225e8f8 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -721,7 +721,7 @@ class Messages extends React.Component {
- +
@@ -802,10 +802,10 @@ class Messages extends React.Component { } let user_menu = [ - {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : '')}, + {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : ''), 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) @@ -843,7 +843,7 @@ class Messages extends React.Component {
- +
{!isSmall ?
From a0bb7ace3244446a78105dcf8d5a38de8d259201 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sun, 22 Oct 2023 00:52:23 +0000 Subject: [PATCH 02/50] Add notificounters for NFT --- src/components/pages/Messages.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 74225e8f8..d8b8e8dd8 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -721,7 +721,7 @@ class Messages extends React.Component {
- +
@@ -805,7 +805,7 @@ class Messages extends React.Component { {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : ''), 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) @@ -843,7 +843,7 @@ class Messages extends React.Component {
- +
{!isSmall ?
From 6a7b4b553b083f8f5f1ef4ce21542f5e166cc285 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 20 Nov 2023 11:32:29 +0000 Subject: [PATCH 03/50] Referral notifications --- src/components/pages/Messages.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index d8b8e8dd8..4a0ed9379 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -721,7 +721,7 @@ class Messages extends React.Component {
- +
@@ -802,7 +802,7 @@ class Messages extends React.Component { } let user_menu = [ - {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : ''), addon: }, + {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : ''), 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: }, @@ -843,7 +843,7 @@ class Messages extends React.Component {
- +
{!isSmall ?
From 4e90fb0b7d798de8126b149beed02ec3f10854ae Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 8 Feb 2024 16:40:47 +0000 Subject: [PATCH 04/50] Fix Formik --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index aa2bd11fa..06c7c8178 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4891,7 +4891,7 @@ form-data@^3.0.0: "formik@https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465": version "2.2.9" - resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465#1dfdbd4ea0331625cd063e826dbb86330fe9244c" + resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465#a696d8404c7b8751188a426f347589fdc24f4ba7" dependencies: deepmerge "^2.1.1" hoist-non-react-statics "^3.3.0" From 7615ffec20f478aa60e9470563b85d87c68d9836 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 29 Apr 2024 00:17:32 +0300 Subject: [PATCH 05/50] Private groups --- package.json | 5 +- src/assets/icons/chevron-right.svg | 5 + src/assets/icons/info_o.svg | 1 + src/components/all.scss | 1 + src/components/elements/Icon.jsx | 3 +- .../messages/StartPanel/StartPanel.scss | 5 + .../elements/messages/StartPanel/index.jsx | 56 +++ .../elements/messages/Stepper/Stepper.scss | 28 ++ .../elements/messages/Stepper/index.jsx | 57 +++ src/components/modules/CreateGroup.jsx | 288 ++++++++++++++ src/components/modules/CreateGroup.scss | 25 ++ src/components/modules/Modals.jsx | 13 + .../modules/messages/Messenger/Messenger.css | 13 + .../modules/messages/Messenger/index.js | 3 +- src/locales/en.json | 31 ++ src/locales/ru-RU.json | 31 ++ src/redux/UserReducer.js | 3 + yarn.lock | 362 ++++++++++-------- 18 files changed, 759 insertions(+), 171 deletions(-) create mode 100644 src/assets/icons/chevron-right.svg create mode 100644 src/assets/icons/info_o.svg create mode 100644 src/components/elements/messages/StartPanel/StartPanel.scss create mode 100644 src/components/elements/messages/StartPanel/index.jsx create mode 100644 src/components/elements/messages/Stepper/Stepper.scss create mode 100644 src/components/elements/messages/Stepper/index.jsx create mode 100644 src/components/modules/CreateGroup.jsx create mode 100644 src/components/modules/CreateGroup.scss diff --git a/package.json b/package.json index c370f6e81..10250a779 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "emoji-picker-element": "^1.10.1", "formik": "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.34", + "golos-lib-js": "^0.9.69", "history": "4.10.1", "immutable": "^4.0.0", "koa": "^2.13.4", @@ -43,7 +43,8 @@ "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", 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/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/components/all.scss b/src/components/all.scss index 5e942a191..0f9e5ce22 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -20,6 +20,7 @@ // modules @import './modules/LoginForm.scss'; +@import './modules/CreateGroup.scss'; @import './modules/Modals.scss'; @import "./pages/Messages"; diff --git a/src/components/elements/Icon.jsx b/src/components/elements/Icon.jsx index 120f3e412..39345243c 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')], @@ -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')], diff --git a/src/components/elements/messages/StartPanel/StartPanel.scss b/src/components/elements/messages/StartPanel/StartPanel.scss new file mode 100644 index 000000000..63dac7e5a --- /dev/null +++ b/src/components/elements/messages/StartPanel/StartPanel.scss @@ -0,0 +1,5 @@ +.msgs-start-panel { + .button { + display: block; + } +} diff --git a/src/components/elements/messages/StartPanel/index.jsx b/src/components/elements/messages/StartPanel/index.jsx new file mode 100644 index 000000000..3dd02c37c --- /dev/null +++ b/src/components/elements/messages/StartPanel/index.jsx @@ -0,0 +1,56 @@ +import React from 'react' +import tt from 'counterpart' +import {connect} from 'react-redux' + +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()) + }, + }) +)(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..fef8b68a6 --- /dev/null +++ b/src/components/elements/messages/Stepper/Stepper.scss @@ -0,0 +1,28 @@ +.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; + } + } + &.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..39186556f --- /dev/null +++ b/src/components/elements/messages/Stepper/index.jsx @@ -0,0 +1,57 @@ +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] + } + } + + 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.setState({ + currentStep: 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' + stepObjs.push(
+
+ {content} +
) + } + + return
+ {stepObjs} +
+ } +} + +export default Stepper diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx new file mode 100644 index 000000000..97defcc3c --- /dev/null +++ b/src/components/modules/CreateGroup.jsx @@ -0,0 +1,288 @@ +import React from 'react' +import {connect} from 'react-redux' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Map } from 'immutable' +import { api, formatter } from 'golos-lib-js' +import { Asset, Price, AssetEditor } from 'golos-lib-js/lib/utils' +import tt from 'counterpart' +import getSlug from 'speakingurl' + +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' + +const STEPS = { + name: tt('create_group_jsx.step_name'), + logo: tt('create_group_jsx.step_logo'), + admin: tt('create_group_jsx.step_admin'), + create: tt('create_group_jsx.step_create') +} + +class CreateGroup extends React.Component { + constructor(props) { + super(props) + this.state = { + step: 'name', + initialValues: { + title: '', + name: '', + is_encrypted: true, + privacy: 'public_group' + } + } + this.stepperRef = React.createRef() + } + + componentDidMount = async () => { + try { + const dgp = await api.getDynamicGlobalProperties() + const { min_golos_power_to_emission } = dgp + const minVS = await Asset(min_golos_power_to_emission[0]) + + const acc = this.props.currentAccount.toJS() + let { vesting_shares } = acc + vesting_shares = Asset(vesting_shares) + + if (vesting_shares.gte(minVS)) { + this.setState({ + loaded: true + }) + return + } + + const minGolos = await Asset(min_golos_power_to_emission[1]) + const vsGolos = formatter.vestToGolos(vesting_shares, dgp.total_vesting_shares, dgp.total_vesting_fund_steem) + const delta = minGolos.minus(vsGolos) + this.setState({ + loaded: true, + createError: { + minGolos, + vsGolos, + delta, + accName: acc.name + } + }) + } catch (err) { + console.error(err) + this.setState({ + loaded: true, + createError: { + message: err.message + } + }) + } + } + + validate = async (values) => { + const 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 + try { + console.time('x') + group = await api.getGroupsAsync({ + start_group: values.name, + limit: 1 + }) + console.timeEnd('x') + } catch (err) { + console.error(err) + } + if (group && group[0]) { + errors.name = tt('create_group_jsx.group_already_exists') + } + } + } + return errors + } + + _onSubmit = () => { + } + + goNext = (e, setFieldValue) => { + e.preventDefault() + const step = this.stepperRef.current.nextStep() + this.setState({ + step + }) + } + + onTitleChange = (e, setFieldValue, setFieldTouched) => { + const { value } = e.target + if (value.trimLeft() !== value) { + return + } + setFieldValue('title', value) + let link = getSlug(value) + setFieldValue('name', link) + setFieldTouched('name', true) + this.setState({ + showName: true + }) + } + + onNameChange = (e, setFieldValue) => { + const { value } = e.target + for (const c of value) { + if ((c > 'z' || c < 'a') && c !== '-' && c !== '_') { + return + } + } + setFieldValue('name', value) + } + + onPrivacyChange = (e, setFieldValue) => { + setFieldValue('privacy', e.target.value) + setFieldValue('is_encrypted', true) + } + + render() { + const { showName, step, loaded, createError } = this.state + + let form + if (!loaded) { + form =
+ +
+ } else if (createError) { + const { message, minGolos, delta, vsGolos, accName } = createError + if (message) { + form =
+ {message} +
+ } else { + form =
+ {tt('create_group_jsx.golos_power_too_low') + minGolos.floatString + '. '}
+ {tt('create_group_jsx.golos_power_too_low2')} + {delta.floatString}.
+ + + +
+ } + } else + form = ( + {({ + handleSubmit, isSubmitting, isValid, values, setFieldValue, setFieldTouched, handleChange, + }) => { + const disabled = !isValid + return ( +
+ + {step === 'name' ? +
+
+ {tt('create_group_jsx.title')} +
+
+ this.onTitleChange(e, setFieldValue, setFieldTouched)} + autoFocus + /> + +
+
+ + {showName ?
+
+ {tt('create_group_jsx.name')} +
+
+ this.onNameChange(e, setFieldValue)} + /> + +
+
: null} + +
+
+ {tt('create_group_jsx.access')} + +
+
+ this.onPrivacyChange(e, setFieldValue)} + > + + + + + +
+
+ +
+
+ + +
+
+
: + } + + + {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')]) + + return { ...ownProps, + currentUser, + currentAccount, + } + }, + dispatch => ({ + }) +)(CreateGroup) diff --git a/src/components/modules/CreateGroup.scss b/src/components/modules/CreateGroup.scss new file mode 100644 index 000000000..f7eddd78c --- /dev/null +++ b/src/components/modules/CreateGroup.scss @@ -0,0 +1,25 @@ +.CreateGroup { + .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; + } +} diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index 8fcb103c1..c3643426d 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; 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 Donate from 'app/components/modules/Donate' import LoginForm from 'app/components/modules/LoginForm'; import AppDownload from 'app/components/modules/app/AppDownload' @@ -17,6 +18,7 @@ class Modals extends React.Component { static propTypes = { show_login_modal: PropTypes.bool, show_donate_modal: PropTypes.bool, + show_create_group_modal: PropTypes.bool, show_app_download_modal: PropTypes.bool, hideDonate: PropTypes.func.isRequired, hideAppDownload: PropTypes.func.isRequired, @@ -34,9 +36,11 @@ class Modals extends React.Component { const { show_login_modal, show_donate_modal, + show_create_group_modal, show_app_download_modal, hideLogin, hideDonate, + hideCreateGroup, hideAppDownload, notifications, removeNotification, @@ -60,6 +64,10 @@ class Modals extends React.Component { } + {show_create_group_modal && + + + } {show_app_download_modal && @@ -81,6 +89,7 @@ export default connect( 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_app_download_modal: state.user.get('show_app_download_modal'), loginUnclosable, notifications: state.app.get('notifications'), @@ -95,6 +104,10 @@ export default connect( if (e) e.preventDefault() dispatch(user.actions.hideDonate()) }, + hideCreateGroup: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideCreateGroup()) + }, hideAppDownload: e => { if (e) e.preventDefault() dispatch(user.actions.hideAppDownload()) diff --git a/src/components/modules/messages/Messenger/Messenger.css b/src/components/modules/messages/Messenger/Messenger.css index d5db6318a..6dc153449 100644 --- a/src/components/modules/messages/Messenger/Messenger.css +++ b/src/components/modules/messages/Messenger/Messenger.css @@ -85,3 +85,16 @@ 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); + padding: 1rem; + border-radius: 10px; + border: 1px solid black; +} diff --git a/src/components/modules/messages/Messenger/index.js b/src/components/modules/messages/Messenger/index.js index e3ad9747e..2f04d2255 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'; @@ -83,7 +84,7 @@ export default class Messages extends React.Component { topRight={messagesTopRight} renderEmpty={() => { if ((localStorage.getItem('msgr_auth') && !account) || process.env.MOBILE_APP) return null - return () + return }} messages={messages} replyingMessage={replyingMessage} diff --git a/src/locales/en.json b/src/locales/en.json index 91abf9e1b..9bcf9a2a6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -76,6 +76,37 @@ "blocked_BY": "You are blocked by @%(BY)s.", "do_not_bother_BY": "@%(BY)s wants to not be bothered by low-reputation users." }, + "msgs_start_panel": { + "start_chat": "Start chat", + "create_group": "Create group" + }, + "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_admin": "Administrator", + "step_create": "Create!", + "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 ", + "deposit_gp": "Increase Golos Power" + }, "emoji_i18n": { "categoriesLabel": "Категории", "emojiUnsupportedMessage": "Ваш браузер не поддерживает эмодзи.", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 65fb2f390..84bce760a 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -77,6 +77,37 @@ "blocked_BY": "Вы заблокированы пользователем @%(BY)s.", "do_not_bother_BY": "@%(BY)s просит пользователей с низкой репутацией не беспокоить." }, + "msgs_start_panel": { + "start_chat": "Начать чат", + "create_group": "Создать группу" + }, + "create_group_jsx": { + "title": "Название", + "name": "Ссылка 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_admin": "Администратор", + "step_create": "Создать!", + "group_already_exists": "Такая группа уже существует.", + "group_min_length": "Минимум 3 символа.", + "golos_power_too_low": "Для создания группы нужна Сила Голоса не менее ", + "golos_power_too_low2": "Вам не хватает ", + "golos_power_too_low3": "Ваша Сила Голоса - ", + "deposit_gp": "Пополнить Силу Голоса" + }, "emoji_i18n": { "categoriesLabel": "Категории", "emojiUnsupportedMessage": "Ваш браузер не поддерживает эмодзи.", diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index 20fe4bfd1..e60902e38 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -5,6 +5,7 @@ const defaultState = fromJS({ current: null, show_login_modal: false, show_donate_modal: false, + show_create_group_modal: false, show_app_download_modal: false, loginLoading: false, pub_keys_used: null, @@ -128,6 +129,8 @@ 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 => state.set('show_create_group_modal', true) }, + { action: 'HIDE_CREATE_GROUP', reducer: state => state.set('show_create_group_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/yarn.lock b/yarn.lock index 06c7c8178..949eb17f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2495,14 +2495,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 +2549,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 +2944,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" @@ -3420,9 +3434,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 +3519,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 +3852,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 +3873,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" @@ -4235,34 +4259,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 +4285,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" @@ -4969,26 +4971,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 +5000,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 +5117,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.69: + version "0.9.69" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.69.tgz#d7b9d17fab1d0967b2e99923ef7c85740da7a157" + integrity sha512-6kxDJUiSj8itwMAEP8klnJSijxyZ1Xz2RVmGGh7BAMTb1WT+YDUoZJFHFv4Cldt7usHs7OAIFKv4bQQzJCpM1w== dependencies: abort-controller "^3.0.0" assert "^2.0.0" @@ -5141,6 +5144,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 +5183,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 +5193,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 +5222,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 +5245,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" @@ -5610,7 +5634,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 +5697,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 +5705,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 +5770,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 +5789,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== @@ -6955,10 +6973,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 +7081,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 +7089,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 +7112,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 +7509,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" @@ -8466,7 +8502,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 +8511,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" @@ -8583,15 +8628,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 +8926,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 +9011,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" @@ -9153,6 +9206,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 +9313,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 +9321,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" @@ -9728,16 +9768,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" @@ -9867,16 +9897,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 +10191,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 +10431,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" From 2a1fd8b0a306ac6248f5b199ff4695e325d46e85 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 1 Jun 2024 08:13:15 +0300 Subject: [PATCH 06/50] HF 30 - Add NFT notifications --- src/components/pages/Messages.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 4a0ed9379..6a7ff25bc 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -721,7 +721,7 @@ class Messages extends React.Component {
- +
@@ -805,7 +805,7 @@ class Messages extends React.Component { {link: accountLink, extLink: 'blogs', icon: 'new/blogging', value: tt('g.blog') + (isSmall ? (' @' + username) : ''), 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) @@ -843,7 +843,7 @@ class Messages extends React.Component {
- +
{!isSmall ?
From d23818eb13459d95e8038f66b5e9f8126d74ef35 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 5 Jun 2024 08:57:05 +0300 Subject: [PATCH 07/50] HF 30 - Private message groups --- package.json | 2 +- src/components/modules/CreateGroup.jsx | 145 +++--------------- src/components/modules/CreateGroup.scss | 3 + src/components/modules/groups/GroupLogo.jsx | 144 ++++++++++++++++++ src/components/modules/groups/GroupName.jsx | 155 ++++++++++++++++++++ src/locales/en.json | 5 +- src/locales/ru-RU.json | 6 +- yarn.lock | 25 ++-- 8 files changed, 345 insertions(+), 140 deletions(-) create mode 100644 src/components/modules/groups/GroupLogo.jsx create mode 100644 src/components/modules/groups/GroupName.jsx diff --git a/package.json b/package.json index 10250a779..5683ff3d6 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-dom-confetti": "^0.2.0", - "react-dropzone": "^12.0.4", + "react-dropzone": "^14.2.3", "react-foundation-components": "git+https://github.com/golos-blockchain/react-foundation-components.git#6606fd5529f1ccbc77cd8d33a8ce139fdf8f9a11", "react-intl": "^5.24.6", "react-notification": "^6.8.5", diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index 97defcc3c..16c33654c 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -5,7 +5,6 @@ import { Map } from 'immutable' import { api, formatter } from 'golos-lib-js' import { Asset, Price, AssetEditor } from 'golos-lib-js/lib/utils' import tt from 'counterpart' -import getSlug from 'speakingurl' import g from 'app/redux/GlobalReducer' import transaction from 'app/redux/TransactionReducer' @@ -15,13 +14,15 @@ 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 from 'app/components/modules/groups/GroupLogo' -const STEPS = { +const STEPS = () => { return { name: tt('create_group_jsx.step_name'), logo: tt('create_group_jsx.step_logo'), admin: tt('create_group_jsx.step_admin'), create: tt('create_group_jsx.step_create') -} +} } class CreateGroup extends React.Component { constructor(props) { @@ -32,7 +33,9 @@ class CreateGroup extends React.Component { title: '', name: '', is_encrypted: true, - privacy: 'public_group' + privacy: 'public_group', + + logo: '', } } this.stepperRef = React.createRef() @@ -80,28 +83,9 @@ class CreateGroup extends React.Component { validate = async (values) => { const 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 - try { - console.time('x') - group = await api.getGroupsAsync({ - start_group: values.name, - limit: 1 - }) - console.timeEnd('x') - } catch (err) { - console.error(err) - } - if (group && group[0]) { - errors.name = tt('create_group_jsx.group_already_exists') - } - } + const { step } = this.state + if (step === 'name') { + await validateNameStep(values, errors, (validating) => this.setState({ validating })) } return errors } @@ -117,37 +101,8 @@ class CreateGroup extends React.Component { }) } - onTitleChange = (e, setFieldValue, setFieldTouched) => { - const { value } = e.target - if (value.trimLeft() !== value) { - return - } - setFieldValue('title', value) - let link = getSlug(value) - setFieldValue('name', link) - setFieldTouched('name', true) - this.setState({ - showName: true - }) - } - - onNameChange = (e, setFieldValue) => { - const { value } = e.target - for (const c of value) { - if ((c > 'z' || c < 'a') && c !== '-' && c !== '_') { - return - } - } - setFieldValue('name', value) - } - - onPrivacyChange = (e, setFieldValue) => { - setFieldValue('privacy', e.target.value) - setFieldValue('is_encrypted', true) - } - render() { - const { showName, step, loaded, createError } = this.state + const { step, loaded, createError, validating } = this.state let form if (!loaded) { @@ -181,83 +136,19 @@ class CreateGroup extends React.Component { {({ handleSubmit, isSubmitting, isValid, values, setFieldValue, setFieldTouched, handleChange, }) => { - const disabled = !isValid + const disabled = !isValid || validating return (
- {step === 'name' ? -
-
- {tt('create_group_jsx.title')} -
-
- this.onTitleChange(e, setFieldValue, setFieldTouched)} - autoFocus - /> - -
-
- - {showName ?
-
- {tt('create_group_jsx.name')} -
-
- this.onNameChange(e, setFieldValue)} - /> - -
-
: null} - -
-
- {tt('create_group_jsx.access')} - -
-
- this.onPrivacyChange(e, setFieldValue)} - > - - - - - -
-
- -
-
- - -
-
-
: - } + {step === 'name' ? : + step === 'logo' ? : + } - + {isSubmitting ?
: - } diff --git a/src/components/modules/CreateGroup.scss b/src/components/modules/CreateGroup.scss index f7eddd78c..3780db74f 100644 --- a/src/components/modules/CreateGroup.scss +++ b/src/components/modules/CreateGroup.scss @@ -1,4 +1,7 @@ .CreateGroup { + h3 { + padding-left: 0.75rem; + } .next-button { width: 48px; height: 48px; diff --git a/src/components/modules/groups/GroupLogo.jsx b/src/components/modules/groups/GroupLogo.jsx new file mode 100644 index 000000000..9c2290e74 --- /dev/null +++ b/src/components/modules/groups/GroupLogo.jsx @@ -0,0 +1,144 @@ +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' + +class GroupLogo extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + uploadLogo = (file, name, setFieldValue) => { + const { notify } = this.props + const { uploadImage } = this.props + this.setState({ uploading: true }) + uploadImage(file, progress => { + if (progress.url) { + alert(progress.url) + } + if (progress.error) { + const { error } = progress; + notify(error, 10000) + } + this.setState({ uploading: false }) + }) + } + + _onDrop = (acceptedFiles, rejectedFiles, setFieldValue) => { + 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, setFieldValue) + }; + + _onInputKeyDown = e => { + if (e.which === keyCodes.ENTER) { + e.preventDefault(); + //this.props.onClose({ + //e.target.value, + //}); + } + }; + + + render() { + const { values, setFieldValue, setFieldTouched } = 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')} +
+
+
+
+ + {({getRootProps, getInputProps}) => (
+ + + + {tt('create_group_jsx.logo_upload')} + +
)} +
+
+
+
+ {tt('create_group_jsx.logo_link')}: +
+ + {({ field, form }) => } + +
+
+
+ + } +} + +export default connect( + // mapStateToProps + (state, 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/GroupName.jsx b/src/components/modules/groups/GroupName.jsx new file mode 100644 index 000000000..4ad41173a --- /dev/null +++ b/src/components/modules/groups/GroupName.jsx @@ -0,0 +1,155 @@ +import React from 'react' +import { Field, ErrorMessage, } from 'formik' +import getSlug from 'speakingurl' +import tt from 'counterpart' +import { api } from 'golos-lib-js' + +import Icon from 'app/components/elements/Icon' + +export async function validateNameStep(values, errors, setValidating) { + 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 { + setValidating(true) + 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]) { + errors.name = tt('create_group_jsx.group_already_exists') + } + setValidating(false) + } + } +} + +export default class GroupName extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + onTitleChange = (e, setFieldValue, setFieldTouched) => { + const { value } = e.target + if (value.trimLeft() !== value) { + return + } + setFieldValue('title', value) + let link = getSlug(value) + setFieldValue('name', link) + setFieldTouched('name', true) + this.setState({ + showName: true + }) + } + + onNameChange = (e, setFieldValue) => { + 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; + } + } + setFieldValue('name', value) + } + + onPrivacyChange = (e, setFieldValue) => { + setFieldValue('privacy', e.target.value) + setFieldValue('is_encrypted', true) + } + + render() { + const { values, setFieldValue, setFieldTouched } = this.props + const { showName } = this.state + return +
+
+ {tt('create_group_jsx.title')} +
+
+ this.onTitleChange(e, setFieldValue, setFieldTouched)} + autoFocus + /> + +
+
+ + {showName ?
+
+ {tt('create_group_jsx.name')} +
+
+ this.onNameChange(e, setFieldValue)} + /> + +
+
: null} + +
+
+ {tt('create_group_jsx.access')} + +
+
+ this.onPrivacyChange(e, setFieldValue)} + > + + + + + +
+
+ +
+
+ + +
+
+
+ } +} diff --git a/src/locales/en.json b/src/locales/en.json index 9bcf9a2a6..347048d48 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -105,7 +105,10 @@ "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 ", - "deposit_gp": "Increase Golos Power" + "deposit_gp": "Increase Golos Power", + "logo_desc": "The group logo is like a user’s avatar...", + "logo_upload": "Upload logo", + "logo_link": "Add logo from the URL" }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 84bce760a..d9d60424c 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -102,11 +102,15 @@ "step_admin": "Администратор", "step_create": "Создать!", "group_already_exists": "Такая группа уже существует.", + "validating": "Проверка существования группы...", "group_min_length": "Минимум 3 символа.", "golos_power_too_low": "Для создания группы нужна Сила Голоса не менее ", "golos_power_too_low2": "Вам не хватает ", "golos_power_too_low3": "Ваша Сила Голоса - ", - "deposit_gp": "Пополнить Силу Голоса" + "deposit_gp": "Пополнить Силу Голоса", + "logo_desc": "Логотип группы - это как аватарка у пользователя...", + "logo_upload": "Загрузить логотип", + "logo_link": "Добавить логотип ссылкой" }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/yarn.lock b/yarn.lock index 949eb17f4..cd4d7610b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4753,12 +4753,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" @@ -8285,13 +8285,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: @@ -9678,6 +9678,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" From 6d9fada37a57e4e791bc927457ada84dd27b9f35 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 12 Jun 2024 13:56:49 +0300 Subject: [PATCH 08/50] HF 30 - Private message groups --- package.json | 2 +- .../messages/StartPanel/StartPanel.scss | 1 + .../elements/messages/Stepper/Stepper.scss | 4 + .../elements/messages/Stepper/index.jsx | 24 +++- src/components/modules/CreateGroup.jsx | 43 +++++-- src/components/modules/CreateGroup.scss | 12 ++ src/components/modules/Modals.jsx | 8 +- src/components/modules/groups/GroupAdmin.jsx | 104 +++++++++++++++ src/components/modules/groups/GroupFinal.jsx | 30 +++++ src/components/modules/groups/GroupLogo.jsx | 119 ++++++++++++++---- src/components/modules/groups/GroupName.jsx | 32 ++--- src/locales/en.json | 18 ++- src/locales/ru-RU.json | 18 ++- src/utils/misc.js | 5 +- yarn.lock | 4 +- 15 files changed, 361 insertions(+), 63 deletions(-) create mode 100644 src/components/modules/groups/GroupAdmin.jsx create mode 100644 src/components/modules/groups/GroupFinal.jsx diff --git a/package.json b/package.json index 5683ff3d6..92aff8533 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "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": "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?3b21166c33ade760d562091e1fa0b71d172a7aaf", "git-rev-sync": "^3.0.2", "golos-lib-js": "^0.9.69", "history": "4.10.1", diff --git a/src/components/elements/messages/StartPanel/StartPanel.scss b/src/components/elements/messages/StartPanel/StartPanel.scss index 63dac7e5a..3e991bd09 100644 --- a/src/components/elements/messages/StartPanel/StartPanel.scss +++ b/src/components/elements/messages/StartPanel/StartPanel.scss @@ -1,5 +1,6 @@ .msgs-start-panel { .button { display: block; + width: 100%; } } diff --git a/src/components/elements/messages/Stepper/Stepper.scss b/src/components/elements/messages/Stepper/Stepper.scss index fef8b68a6..ea322122e 100644 --- a/src/components/elements/messages/Stepper/Stepper.scss +++ b/src/components/elements/messages/Stepper/Stepper.scss @@ -17,6 +17,10 @@ .bar { background-color: #0078C4; } + cursor: pointer; + &:hover { + color: #0078C4; + } } &.current { color: #0078C4; diff --git a/src/components/elements/messages/Stepper/index.jsx b/src/components/elements/messages/Stepper/index.jsx index 39186556f..eced88131 100644 --- a/src/components/elements/messages/Stepper/index.jsx +++ b/src/components/elements/messages/Stepper/index.jsx @@ -12,6 +12,17 @@ class Stepper extends React.Component { } } + _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) @@ -19,9 +30,7 @@ class Stepper extends React.Component { let found for (const [key, content] of entr) { if (found) { - this.setState({ - currentStep: key - }) + this._goToStep(key) return key } found = key === currentStep @@ -42,7 +51,14 @@ class Stepper extends React.Component { const isCurrent = key === currentStep foundCurrent = foundCurrent || isCurrent const cn = foundCurrent ? (isCurrent ? 'current' : '') : 'left' - stepObjs.push(
+ let onClick + if (!foundCurrent) { + onClick = (e) => { + e.preventDefault() + this._goToStep(key) + } + } + stepObjs.push(
{content}
) diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index 16c33654c..b995a6b24 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -15,13 +15,15 @@ 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 from 'app/components/modules/groups/GroupLogo' +import GroupLogo, { validateLogoStep } from 'app/components/modules/groups/GroupLogo' +import GroupAdmin, { validateAdminStep } from 'app/components/modules/groups/GroupAdmin' +import GroupFinal from 'app/components/modules/groups/GroupFinal' const STEPS = () => { return { name: tt('create_group_jsx.step_name'), logo: tt('create_group_jsx.step_logo'), admin: tt('create_group_jsx.step_admin'), - create: tt('create_group_jsx.step_create') + final: tt('create_group_jsx.step_create') } } class CreateGroup extends React.Component { @@ -29,6 +31,7 @@ class CreateGroup extends React.Component { super(props) this.state = { step: 'name', + validators: 0, initialValues: { title: '', name: '', @@ -36,6 +39,8 @@ class CreateGroup extends React.Component { privacy: 'public_group', logo: '', + + admin: '', } } this.stepperRef = React.createRef() @@ -81,12 +86,24 @@ class CreateGroup extends React.Component { } } + setValidating = (validating) => { + this.setState({ + validators: this.state.validators + (validating ? 1 : -1), + }) + } + validate = async (values) => { const errors = {} const { step } = this.state + this.setValidating(true) if (step === 'name') { - await validateNameStep(values, errors, (validating) => this.setState({ validating })) + await validateNameStep(values, errors) + } else if (step === 'logo') { + await validateLogoStep(values, errors) + } else if (step === 'admin') { + await validateAdminStep(values, errors) } + this.setValidating(false) return errors } @@ -96,13 +113,16 @@ class CreateGroup extends React.Component { goNext = (e, setFieldValue) => { e.preventDefault() const step = this.stepperRef.current.nextStep() + } + + onStep = ({ step }) => { this.setState({ step }) } render() { - const { step, loaded, createError, validating } = this.state + const { step, loaded, createError, validators } = this.state let form if (!loaded) { @@ -134,20 +154,23 @@ class CreateGroup extends React.Component { onSubmit={this._onSubmit} > {({ - handleSubmit, isSubmitting, isValid, values, setFieldValue, setFieldTouched, handleChange, + handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, }) => { - const disabled = !isValid || validating + const disabled = !isValid || !!validators return ( - {step === 'name' ? : - step === 'logo' ? : + {step === 'name' ? : + step === 'logo' ? : + step === 'admin' ? : + step === 'final' ? : } - + {isSubmitting ?
: - diff --git a/src/components/modules/CreateGroup.scss b/src/components/modules/CreateGroup.scss index 3780db74f..c647e4164 100644 --- a/src/components/modules/CreateGroup.scss +++ b/src/components/modules/CreateGroup.scss @@ -24,5 +24,17 @@ } .error { margin-bottom: 0px !important; + margin-top: 0.5rem; } + + .image-loader { + margin-top: 14px; + } + + .image-preview { + max-width: 75px; + max-height: 75px; + margin-top: 0.75rem; + border: none; + } } diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index c3643426d..a20d76004 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -29,9 +29,13 @@ 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, @@ -64,7 +68,7 @@ class Modals extends React.Component { } - {show_create_group_modal && + {show_create_group_modal && } diff --git a/src/components/modules/groups/GroupAdmin.jsx b/src/components/modules/groups/GroupAdmin.jsx new file mode 100644 index 000000000..56f8b3a6f --- /dev/null +++ b/src/components/modules/groups/GroupAdmin.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' +import { api } from 'golos-lib-js' +import { validateAccountName } from 'golos-lib-js/lib/utils' + +import Input from 'app/components/elements/common/Input'; + +export async function validateAdminStep(values, errors) { + if (!values.admin) { + errors.admin = tt('g.required') + } else { + const nameError = validateAccountName(values.admin) + if (nameError.error) { + errors.admin = tt('account_name.' + nameError.error) + } else { + try { + let accs = await api.getAccountsAsync([values.admin]) + accs = accs[0] + if (!accs) { + errors.admin = tt('g.username_does_not_exist') + } + } catch (err) { + console.error(err) + errors.admin = 'Blockchain unavailable :(' + } + } + } +} + +class GroupAdmin extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + componentDidMount() { + this.load() + } + + componentDidUpdate() { + this.load() + } + + load = () => { + const { loaded } = this.state + if (!loaded) { + const { username, applyFieldValue } = this.props + if (username) { + applyFieldValue('admin', username) + this.setState({ + loaded: true + }) + } + } + } + + onChange = async (e) => { + e.preventDefault() + const { applyFieldValue } = this.props + applyFieldValue('admin', e.target.value) + } + + render() { + const { uploading } = this.state + + return +
+
+ {tt('create_group_jsx.admin_desc')} +
+
+
+
+ this.onChange(e)} + > + + +
+
+
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + const currentUser = state.user.get('current') + const username = currentUser && currentUser.get('username') + return { + ...ownProps, + username, + } + }, + dispatch => ({ + }) +)(GroupAdmin) diff --git a/src/components/modules/groups/GroupFinal.jsx b/src/components/modules/groups/GroupFinal.jsx new file mode 100644 index 000000000..e9baba97b --- /dev/null +++ b/src/components/modules/groups/GroupFinal.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' + +class GroupFinal extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + render() { + return +
+
+ {tt('create_group_jsx.final_desc')} +
+
+
+ } +} + +export default connect( + // mapStateToProps + (state, ownProps) => { + }, + dispatch => ({ + }) +)(GroupFinal) diff --git a/src/components/modules/groups/GroupLogo.jsx b/src/components/modules/groups/GroupLogo.jsx index 9c2290e74..145f6e150 100644 --- a/src/components/modules/groups/GroupLogo.jsx +++ b/src/components/modules/groups/GroupLogo.jsx @@ -7,6 +7,60 @@ 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 = {} @@ -15,13 +69,12 @@ class GroupLogo extends React.Component { super(props) } - uploadLogo = (file, name, setFieldValue) => { - const { notify } = this.props - const { uploadImage } = this.props + uploadLogo = (file, name) => { + const { notify, uploadImage, applyFieldValue } = this.props this.setState({ uploading: true }) uploadImage(file, progress => { if (progress.url) { - alert(progress.url) + applyFieldValue('logo', progress.url) } if (progress.error) { const { error } = progress; @@ -31,7 +84,7 @@ class GroupLogo extends React.Component { }) } - _onDrop = (acceptedFiles, rejectedFiles, setFieldValue) => { + onDrop = (acceptedFiles, rejectedFiles) => { const file = acceptedFiles[0] if (!file) { @@ -43,10 +96,10 @@ class GroupLogo extends React.Component { return } - this.uploadLogo(file, file.name, setFieldValue) + this.uploadLogo(file, file.name) }; - _onInputKeyDown = e => { + onInputKeyDown = e => { if (e.which === keyCodes.ENTER) { e.preventDefault(); //this.props.onClose({ @@ -55,10 +108,29 @@ class GroupLogo extends React.Component { } }; + 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 { values, setFieldValue, setFieldTouched } = this.props - const { uploading } = this.state + const { isValidating } = this.props + const { uploading, } = this.state const selectorStyleCover = uploading ? { @@ -86,36 +158,37 @@ class GroupLogo extends React.Component {
this.onDrop(af, rf)} > - {({getRootProps, getInputProps}) => (
+ {({getRootProps, getInputProps}) => (
- + {tt('create_group_jsx.logo_upload')}
)} -
+
-
+
{tt('create_group_jsx.logo_link')}:
- this.onChange(e)} > - {({ field, form }) => } + {!isValidating && }
+ {this._renderPreview()}
diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx index 4ad41173a..81dfc990f 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -6,7 +6,7 @@ import { api } from 'golos-lib-js' import Icon from 'app/components/elements/Icon' -export async function validateNameStep(values, errors, setValidating) { +export async function validateNameStep(values, errors) { if (!values.title) { errors.title = tt('g.required') } @@ -14,7 +14,6 @@ export async function validateNameStep(values, errors, setValidating) { if (values.name.length < 3) { errors.name = tt('create_group_jsx.group_min_length') } else { - setValidating(true) let group for (let i = 0; i < 3; ++i) { try { @@ -33,7 +32,6 @@ export async function validateNameStep(values, errors, setValidating) { if (group && group[0]) { errors.name = tt('create_group_jsx.group_already_exists') } - setValidating(false) } } } @@ -45,21 +43,21 @@ export default class GroupName extends React.Component { super(props) } - onTitleChange = (e, setFieldValue, setFieldTouched) => { + onTitleChange = (e) => { const { value } = e.target if (value.trimLeft() !== value) { return } - setFieldValue('title', value) + const { applyFieldValue } = this.props + applyFieldValue('title', value) let link = getSlug(value) - setFieldValue('name', link) - setFieldTouched('name', true) + applyFieldValue('name', link) this.setState({ showName: true }) } - onNameChange = (e, setFieldValue) => { + onNameChange = (e) => { const { value } = e.target for (let i = 0; i < value.length; ++i) { const c = value[i] @@ -73,16 +71,18 @@ export default class GroupName extends React.Component { if (!is_alpha && !is_digit && !is_dash && !is_ul) return; } } - setFieldValue('name', value) + const { applyFieldValue } = this.props + applyFieldValue('name', value) } - onPrivacyChange = (e, setFieldValue) => { - setFieldValue('privacy', e.target.value) - setFieldValue('is_encrypted', true) + onPrivacyChange = (e) => { + const { applyFieldValue } = this.props + applyFieldValue('privacy', e.target.value) + applyFieldValue('is_encrypted', true) } render() { - const { values, setFieldValue, setFieldTouched } = this.props + const { values } = this.props const { showName } = this.state return
@@ -94,7 +94,7 @@ export default class GroupName extends React.Component { type='text' name='title' maxLength='48' - onChange={e => this.onTitleChange(e, setFieldValue, setFieldTouched)} + onChange={e => this.onTitleChange(e)} autoFocus /> @@ -110,7 +110,7 @@ export default class GroupName extends React.Component { type='text' name='name' maxLength='32' - onChange={e => this.onNameChange(e, setFieldValue)} + onChange={e => this.onNameChange(e)} />
@@ -125,7 +125,7 @@ export default class GroupName extends React.Component { this.onPrivacyChange(e, setFieldValue)} + onChange={e => this.onPrivacyChange(e)} > diff --git a/src/locales/en.json b/src/locales/en.json index 347048d48..d77869cc6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,4 +1,14 @@ { + "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." + }, "chainvalidation_js": { "account_name_should": "Account name should ", "not_be_empty": "not be empty.", @@ -106,9 +116,13 @@ "golos_power_too_low2": "That is not enough ", "golos_power_too_low3": "Your Golos Power is ", "deposit_gp": "Increase Golos Power", - "logo_desc": "The group logo is like a user’s avatar...", + "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" + "logo_link": "Add logo from the URL", + "admin_desc": "Admin of group will be...", + "final_desc": "Now we are ready to create the group!", + "image_wrong": "Cannot load this image.", + "image_timeout": "Cannot load this image, it is loading too long..." }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index d9d60424c..80116c47a 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -1,4 +1,14 @@ { + "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": "Сегмент имени аккаунта должен быть длиннее." + }, "chainvalidation_js": { "account_name_should": "Имя аккаунта должно ", "not_be_empty": "не может быть пустым.", @@ -108,9 +118,13 @@ "golos_power_too_low2": "Вам не хватает ", "golos_power_too_low3": "Ваша Сила Голоса - ", "deposit_gp": "Пополнить Силу Голоса", - "logo_desc": "Логотип группы - это как аватарка у пользователя...", + "logo_desc": "Логотип группы - это как аватарка у пользователя... Необязательно, но \"must have\"...", "logo_upload": "Загрузить логотип", - "logo_link": "Добавить логотип ссылкой" + "logo_link": "Добавить логотип ссылкой", + "admin_desc": "Администратором группы будeт...", + "final_desc": "Теперь все готово к созданию группы!", + "image_wrong": "Не удается загрузить картинку.", + "image_timeout": "Не удается загрузить картинку, она загружается слишком долго..." }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/src/utils/misc.js b/src/utils/misc.js index 23688d782..3d39bc97c 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -5,6 +5,9 @@ const renderPart = (part, params) => { return part } +const delay = (msec) => new Promise(resolve => setTimeout(resolve, msec)) + export { - renderPart + renderPart, + delay } diff --git a/yarn.lock b/yarn.lock index cd4d7610b..840f5b117 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4891,9 +4891,9 @@ 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@https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?3b21166c33ade760d562091e1fa0b71d172a7aaf": version "2.2.9" - resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?b697b6ef3f13c795bb862b35589fffde442ab465#a696d8404c7b8751188a426f347589fdc24f4ba7" + resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?3b21166c33ade760d562091e1fa0b71d172a7aaf#0f72ba16b0610fc14a6d42dfe2556600df5a3132" dependencies: deepmerge "^2.1.1" hoist-non-react-statics "^3.3.0" From c01be706c1bc73604f5f14a9ec59888f168312b5 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 19 Jun 2024 16:52:28 +0300 Subject: [PATCH 09/50] HF 30 - Private message groups --- src/assets/icons/team.svg | 1 + src/assets/icons/voters.svg | 1 + src/components/all.scss | 1 + src/components/dialogs/LoginDialog/index.jsx | 186 ++++++++++++++++++ src/components/dialogs/LoginDialog/index.scss | 12 ++ src/components/elements/Icon.jsx | 4 +- .../elements/common/DialogManager/index.jsx | 2 +- .../elements/common/DialogManager/index.scss | 5 + .../messages/StartPanel/StartPanel.scss | 8 + .../elements/messages/StartPanel/index.jsx | 11 +- src/components/modules/CreateGroup.jsx | 105 ++++++++-- src/components/modules/CreateGroup.scss | 8 + src/components/modules/Modals.jsx | 2 +- src/components/modules/groups/GroupFinal.jsx | 13 +- src/components/modules/groups/GroupLogo.jsx | 3 + src/components/modules/groups/GroupName.jsx | 2 +- .../modules/messages/Messenger/Messenger.css | 3 +- src/components/pages/Messages.jsx | 3 +- src/locales/en.json | 10 + src/locales/ru-RU.json | 11 ++ src/redux/TransactionSaga.js | 16 +- src/redux/UserSaga.js | 5 +- src/utils/translateError.js | 10 +- 23 files changed, 389 insertions(+), 33 deletions(-) create mode 100644 src/assets/icons/team.svg create mode 100644 src/assets/icons/voters.svg create mode 100644 src/components/dialogs/LoginDialog/index.jsx create mode 100644 src/components/dialogs/LoginDialog/index.scss 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/components/all.scss b/src/components/all.scss index 0f9e5ce22..7d4ceb357 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -17,6 +17,7 @@ @import "./dialogs/DialogFrame/index"; @import "./dialogs/CommonDialog/index"; @import "./dialogs/AddImageDialog/index"; +@import "./dialogs/LoginDialog/index"; // modules @import './modules/LoginForm.scss'; diff --git a/src/components/dialogs/LoginDialog/index.jsx b/src/components/dialogs/LoginDialog/index.jsx new file mode 100644 index 000000000..c184dac5e --- /dev/null +++ b/src/components/dialogs/LoginDialog/index.jsx @@ -0,0 +1,186 @@ +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) { + let dm, oldZ = '' + + DialogManager.showDialog({ + component: LoginDialog, + adaptive: true, + props: { + username, + authType, + }, + onClose: (data) => { + if (dm) dm.style.zIndex = oldZ + if (onClose) onClose(data) + }, + }); + + setTimeout(() => { + dm = document.getElementsByClassName('DialogManager')[0] + oldZ = dm ? dm.style.zIndex : '' + if (dm) dm.style.zIndex = 1000 + }, 1) +} + +export default class LoginDialog extends React.PureComponent { + static propTypes = { + onClose: PropTypes.func.isRequired, + }; + + state = { + password: '', + error: '', + saveLogin: false + } + + componentDidMount() { + let { saveLogin } = 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(); + } + + 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 } = this.state + return ( + +
+
+ {tt('loginform_jsx.is_is_for_operation')} +
+ +
+ {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 39345243c..aff269505 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -30,7 +30,7 @@ const icons = new Map([ // ['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')], @@ -60,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')], 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..42b706099 100644 --- a/src/components/elements/common/DialogManager/index.scss +++ b/src/components/elements/common/DialogManager/index.scss @@ -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/messages/StartPanel/StartPanel.scss b/src/components/elements/messages/StartPanel/StartPanel.scss index 3e991bd09..49d2b8919 100644 --- a/src/components/elements/messages/StartPanel/StartPanel.scss +++ b/src/components/elements/messages/StartPanel/StartPanel.scss @@ -2,5 +2,13 @@ .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 index 3dd02c37c..d25894bc5 100644 --- a/src/components/elements/messages/StartPanel/index.jsx +++ b/src/components/elements/messages/StartPanel/index.jsx @@ -2,6 +2,7 @@ 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' @@ -36,8 +37,14 @@ class StartPanel extends React.Component {
- - + +
) diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index b995a6b24..de710fce9 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -18,6 +18,8 @@ import GroupName, { validateNameStep } from 'app/components/modules/groups/Group import GroupLogo, { validateLogoStep } from 'app/components/modules/groups/GroupLogo' import GroupAdmin, { validateAdminStep } from 'app/components/modules/groups/GroupAdmin' import GroupFinal from 'app/components/modules/groups/GroupFinal' +import DialogManager from 'app/components/elements/common/DialogManager' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' const STEPS = () => { return { name: tt('create_group_jsx.step_name'), @@ -86,16 +88,20 @@ class CreateGroup extends React.Component { } } - setValidating = (validating) => { - this.setState({ - validators: this.state.validators + (validating ? 1 : -1), + 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 - this.setValidating(true) + await this.setValidating(true) if (step === 'name') { await validateNameStep(values, errors) } else if (step === 'logo') { @@ -103,16 +109,48 @@ class CreateGroup extends React.Component { } else if (step === 'admin') { await validateAdminStep(values, errors) } - this.setValidating(false) + await this.setValidating(false) return errors } - _onSubmit = () => { + _onSubmit = (data, actions) => { + const { currentUser } = this.props + const creator = currentUser.get('username') + data.creator = creator + + this.setState({ + submitError: '' + }) + + showLoginDialog(creator, (res) => { + const password = res && res.password + if (!password) { + actions.setSubmitting(false) + return + } + this.props.privateGroup({ + password, + ...data, + onSuccess: () => { + alert('success') + actions.setSubmitting(false) + }, + onError: (err, errStr) => { + this.setState({ submitError: errStr }) + actions.setSubmitting(false) + } + }) + }, 'active') + } goNext = (e, setFieldValue) => { + const { step } = this.state + if (step === 'final') { + return + } e.preventDefault() - const step = this.stepperRef.current.nextStep() + this.stepperRef.current.nextStep() } onStep = ({ step }) => { @@ -122,7 +160,7 @@ class CreateGroup extends React.Component { } render() { - const { step, loaded, createError, validators } = this.state + const { step, loaded, createError, validators, submitError } = this.state let form if (!loaded) { @@ -160,15 +198,16 @@ class CreateGroup extends React.Component { return ( - {step === 'name' ? : + {!isSubmitting ? (step === 'name' ? : step === 'logo' ? : step === 'admin' ? : - step === 'final' ? : - } + step === 'final' ? : + ) : null} - - {isSubmitting ?
+ {!isSubmitting && } + {/*submitError &&
{submitError}
*/} + {isSubmitting ?
:
diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx new file mode 100644 index 000000000..a87169f4f --- /dev/null +++ b/src/components/modules/groups/MyGroups.jsx @@ -0,0 +1,213 @@ +import React from 'react' +import {connect} from 'react-redux' +import { Formik, Form, Field, ErrorMessage, } from 'formik' +import { Map } from 'immutable' +import { api, formatter } 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 { session } from 'app/redux/UserSaga' +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 FormikAgent from 'app/components/elements/donate/FormikUtils' +import DialogManager from 'app/components/elements/common/DialogManager' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import { getGroupLogo, getGroupMeta } from 'app/utils/groups' + +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() + } + + createGroup = (e) => { + e.preventDefault() + this.props.showCreateGroup() + } + + _renderGroupLogo = (group, meta) => { + const { json_metadata } = group + + const logo = getGroupLogo(json_metadata) + 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 + })) + } + + _renderGroup = (group) => { + const { name, json_metadata } = group + + const meta = getGroupMeta(json_metadata) + + let title = meta.title || name + let titleShr = title + if (titleShr.length > 20) { + titleShr = titleShr.substring(0, 17) + '...' + } + + const kebabItems = [] + + kebabItems.push({ link: '#', onClick: e => { + this.deleteGroup(e, group, titleShr) + }, value: tt('g.delete') }) + + return + + {this._renderGroupLogo(group, meta)} + + {titleShr} + + { + e.preventDefault() + }}> + + + {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.create')} + +
+ } else { + hasGroups = true + groups = [] + for (const g of my_groups) { + groups.push(this._renderGroup(g)) + } + groups = + + {groups} + +
+ } + } + + let button + if (hasGroups) { + button = + } + + return
+
+

{tt('my_groups_jsx.title')}

+
+ {button} + {groups} +
+ } +} + +export default connect( + (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]) + const my_groups = state.global.get('my_groups') + + return { ...ownProps, + currentUser, + currentAccount, + 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 })) + }, + 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, + password, + 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..2bfb29ea2 --- /dev/null +++ b/src/components/modules/groups/MyGroups.scss @@ -0,0 +1,27 @@ +.MyGroups { + .group-logo { + width: 67px; + img { + width: 48px; + height: 48px; + } + } + .group-title { + font-weight: bold; + font-size: 110%; + @include themify($themes) { + color: themed('textColorPrimary'); + } + } + .group-buttons { + float: right; + padding-top: 0.8rem; + .button { + vertical-align: middle; + margin-bottom: 0px; + } + .DropdownMenu.show > .VerticalMenu { + transform: translateX(-100%); + } + } +} diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index b46ef4e05..b4bc6e77c 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -801,8 +801,13 @@ 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: 'voters', value: tt('g.groups') + (isSmall ? (' @' + username) : ''), addon: }, + {link: '#', onClick: openMyGroups, icon: 'voters', value: tt('g.groups') + (isSmall ? (' @' + username) : '') }, {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: }, @@ -1023,6 +1028,9 @@ export default withRouter(connect( } return true; }, + + showMyGroups: () => dispatch(user.actions.showMyGroups()), + fetchState: (to) => { const pathname = '/' + (to ? ('@' + to) : ''); dispatch({type: 'FETCH_STATE', payload: { diff --git a/src/locales/en.json b/src/locales/en.json index 3356a2e22..75f6df4d6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -134,6 +134,12 @@ "image_wrong": "Cannot load this image.", "image_timeout": "Cannot load this image, it is loading too long..." }, + "my_groups_jsx": { + "title": "My Groups", + "empty": "You have not any groups yet. ", + "empty2": "You can ", + "create": "create your own group" + }, "emoji_i18n": { "categoriesLabel": "Категории", "emojiUnsupportedMessage": "Ваш браузер не поддерживает эмодзи.", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 5e9db9e1f..3e5003cb9 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -48,7 +48,7 @@ "login_to_comment": "Пожалуйста, авторизуйтесь для выполнения следующих действий", "login_to_message": "Пожалуйста, авторизуйтесь с memo-ключом или паролем", "login_active": "Введите пароль (или активный ключ)", - "is_is_for_operation": "Это нужно для отправки операции.", + "is_is_for_operation": "Это нужно для отправки операции", "login_with_active_key_USERNAME": "Пожалуйста, авторизуйтесь Вашим активным ключом\n\nИмя:\n%(USERNAME)s\n\nActive-ключ:\n", "login_to_your_steem_account": "Войти в свой Голос аккаунт", "posting": "Постинг", @@ -136,6 +136,15 @@ "image_wrong": "Не удается загрузить картинку.", "image_timeout": "Не удается загрузить картинку, она загружается слишком долго..." }, + "my_groups_jsx": { + "title": "Мои группы", + "empty": "У вас пока нет групп. ", + "empty2": "Вы можете ", + "create": "создать свою группу", + "create_more": "Создать еще группу", + "edit": "Изменить", + "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")" + }, "emoji_i18n": { "categoriesLabel": "Категории", "emojiUnsupportedMessage": "Ваш браузер не поддерживает эмодзи.", @@ -256,6 +265,7 @@ "settings": "Настройки", "sign_up": "Регистрация", "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..caa8fb5fa 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -8,6 +8,7 @@ export function* fetchDataWatches () { yield fork(watchLocationChange) yield fork(watchFetchState) yield fork(watchFetchUiaBalances) + yield fork(watchFetchMyGroups) } export function* watchLocationChange() { @@ -109,3 +110,25 @@ 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 groups = yield call([api, api.getGroupsAsync], { + member: account, + member_types: ['pending', 'member', 'moder', 'admin'], + start_group: '', + limit: 100, + with_members: { + accounts: [account] + } + }) + + yield put(g.actions.receiveMyGroups({ groups })) + } catch (err) { + console.error('fetchMyGroups', err) + } +} diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index e9845952d..9ab16b7d9 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -264,5 +264,15 @@ 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)) + }, + }, ], }) diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index e60902e38..ead90c9b5 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -6,6 +6,7 @@ const defaultState = fromJS({ show_login_modal: false, show_donate_modal: false, show_create_group_modal: false, + show_my_groups_modal: false, show_app_download_modal: false, loginLoading: false, pub_keys_used: null, @@ -129,8 +130,14 @@ 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 => state.set('show_create_group_modal', true) }, + { 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_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/utils/groups.js b/src/utils/groups.js new file mode 100644 index 000000000..2de29c40b --- /dev/null +++ b/src/utils/groups.js @@ -0,0 +1,28 @@ +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 getGroupLogo = (json_metadata) => { + const meta = getGroupMeta(json_metadata) + + let { logo } = meta + if (logo && /^(https?:)\/\//.test(logo)) { + const size = '75x75' + logo = proxifyImageUrlWithStrip(logo, size) + } else { + logo = require('app/assets/images/user.png') + } + return logo +} + +export { + getGroupMeta, + getGroupLogo +} From 50cbc0b66488fc2488a21760c946439448a30184 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 22 Jun 2024 21:35:26 +0300 Subject: [PATCH 11/50] HF 30 - Private message groups --- src/components/all.scss | 1 + src/components/modules/CreateGroup.scss | 72 +++---- src/components/modules/Modals.jsx | 13 ++ .../modules/groups/GroupSettings.jsx | 194 ++++++++++++++++++ .../modules/groups/GroupSettings.scss | 5 + src/components/modules/groups/MyGroups.jsx | 25 ++- src/components/modules/groups/MyGroups.scss | 4 + src/locales/en.json | 13 +- src/locales/ru-RU.json | 13 +- src/redux/UserReducer.js | 7 + src/utils/groups.js | 12 +- 11 files changed, 311 insertions(+), 48 deletions(-) create mode 100644 src/components/modules/groups/GroupSettings.jsx create mode 100644 src/components/modules/groups/GroupSettings.scss diff --git a/src/components/all.scss b/src/components/all.scss index 8bec9535b..493d1fbe7 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -23,6 +23,7 @@ @import './modules/LoginForm.scss'; @import './modules/CreateGroup.scss'; @import './modules/groups/MyGroups.scss'; +@import './modules/groups/GroupSettings.scss'; @import './modules/Modals.scss'; @import "./pages/Messages"; diff --git a/src/components/modules/CreateGroup.scss b/src/components/modules/CreateGroup.scss index a525b8b9c..93391d0b6 100644 --- a/src/components/modules/CreateGroup.scss +++ b/src/components/modules/CreateGroup.scss @@ -1,39 +1,39 @@ .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%; - } + 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-loader { + margin-top: 14px; + } .image-preview { max-width: 75px; @@ -42,7 +42,7 @@ border: none; } .submit-loader { - margin-left: 0.5rem; - margin-top: 0.5rem; + margin-left: 0.5rem; + margin-top: 0.5rem; } } diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index 977054011..8ff3ffd22 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -6,6 +6,7 @@ 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 MyGroups from 'app/components/modules/groups/MyGroups' import Donate from 'app/components/modules/Donate' import LoginForm from 'app/components/modules/LoginForm'; @@ -21,6 +22,7 @@ class Modals extends React.Component { show_donate_modal: PropTypes.bool, show_create_group_modal: PropTypes.bool, show_my_groups_modal: PropTypes.bool, + show_group_settings_modal: PropTypes.bool, show_app_download_modal: PropTypes.bool, hideDonate: PropTypes.func.isRequired, hideAppDownload: PropTypes.func.isRequired, @@ -44,11 +46,13 @@ class Modals extends React.Component { show_donate_modal, show_create_group_modal, show_my_groups_modal, + show_group_settings_modal, show_app_download_modal, hideLogin, hideDonate, hideCreateGroup, hideMyGroups, + hideGroupSettings, hideAppDownload, notifications, removeNotification, @@ -80,6 +84,10 @@ class Modals extends React.Component { } + {show_group_settings_modal && + + + } {show_app_download_modal && @@ -103,6 +111,7 @@ export default connect( 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_group_settings_modal: state.user.get('show_group_settings_modal'), show_app_download_modal: state.user.get('show_app_download_modal'), loginUnclosable, notifications: state.app.get('notifications'), @@ -125,6 +134,10 @@ export default connect( if (e) e.preventDefault() dispatch(user.actions.hideMyGroups()) }, + hideGroupSettings: e => { + if (e) e.preventDefault() + dispatch(user.actions.hideGroupSettings()) + }, hideAppDownload: e => { if (e) e.preventDefault() dispatch(user.actions.hideAppDownload()) diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx new file mode 100644 index 000000000..61f749042 --- /dev/null +++ b/src/components/modules/groups/GroupSettings.jsx @@ -0,0 +1,194 @@ +import React from 'react' +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 { session } from 'app/redux/UserSaga' +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 { getGroupLogo, getGroupMeta, getGroupTitle } from 'app/utils/groups' + +class GroupSettings extends React.Component { + constructor(props) { + super(props) + this.state = { + loaded: false + } + } + + componentDidMount() { + const { currentGroup } = this.props + const group = currentGroup.toJS() + const { name, member_list, privacy, json_metadata } = group + const meta = getGroupMeta(json_metadata) + let admin + for (const mem of member_list) { + if (mem.member_type === 'admin') { + admin = mem.account + } + break + } + const initialValues = { + title: meta.title, + logo: meta.logo, + admin, + privacy + } + 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) + } + + onAdminChange = (e, { applyFieldValue }) => { + const { value } = e.target + applyFieldValue('admin', value) + } + + validate = async () => { + + } + + onSubmit = async () => { + + } + + closeMe = (e) => { + e.preventDefault() + this.props.closeMe() + } + + 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 } = this.state + + let form + if (!initialValues) { + form = + } else { + form = + {({ + handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, + }) => { + const disabled = !isValid + 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.onLogoChange(e, { applyFieldValue })} + validateOnBlur={false} + /> + {tt('group_settings_jsx.upload')} +
+
+
+
+
+ {tt('create_group_jsx.admin')} + this.onAdminChange(e, { applyFieldValue })} + validateOnBlur={false} + /> +
+
+
+ + +
+ + )}}
+ } + + 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 => ({ + }) +)(GroupSettings) diff --git a/src/components/modules/groups/GroupSettings.scss b/src/components/modules/groups/GroupSettings.scss new file mode 100644 index 000000000..927dd8513 --- /dev/null +++ b/src/components/modules/groups/GroupSettings.scss @@ -0,0 +1,5 @@ +.GroupSettings { + h3 { + padding-left: 0.75rem; + } +} diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index a87169f4f..b43f78f07 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -1,9 +1,7 @@ import React from 'react' import {connect} from 'react-redux' -import { Formik, Form, Field, ErrorMessage, } from 'formik' import { Map } from 'immutable' import { api, formatter } from 'golos-lib-js' -import { Asset, Price, AssetEditor } from 'golos-lib-js/lib/utils' import tt from 'counterpart' import g from 'app/redux/GlobalReducer' @@ -14,7 +12,6 @@ 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 FormikAgent from 'app/components/elements/donate/FormikUtils' import DialogManager from 'app/components/elements/common/DialogManager' import { showLoginDialog } from 'app/components/dialogs/LoginDialog' import { getGroupLogo, getGroupMeta } from 'app/utils/groups' @@ -73,6 +70,11 @@ class MyGroups extends React.Component { })) } + showGroupSettings = (e, group) => { + e.preventDefault() + this.props.showGroupSettings({ group }) + } + _renderGroup = (group) => { const { name, json_metadata } = group @@ -99,11 +101,15 @@ class MyGroups extends React.Component { { e.preventDefault() }}> - - {kebabItems.length ? @@ -147,7 +153,7 @@ class MyGroups extends React.Component { let button if (hasGroups) { - button = } @@ -183,6 +189,9 @@ export default connect( showCreateGroup() { dispatch(user.actions.showCreateGroup({ redirectAfter: false })) }, + showGroupSettings({ group }) { + dispatch(user.actions.showGroupSettings({ group })) + }, deleteGroup: ({ owner, name, password, onSuccess, onError }) => { const opData = { diff --git a/src/components/modules/groups/MyGroups.scss b/src/components/modules/groups/MyGroups.scss index 2bfb29ea2..bee27a8d5 100644 --- a/src/components/modules/groups/MyGroups.scss +++ b/src/components/modules/groups/MyGroups.scss @@ -23,5 +23,9 @@ .DropdownMenu.show > .VerticalMenu { transform: translateX(-100%); } + .btn-title { + margin-left: 5px; + vertical-align: middle; + } } } diff --git a/src/locales/en.json b/src/locales/en.json index 75f6df4d6..c318f2e3e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -138,7 +138,17 @@ "title": "My Groups", "empty": "You have not any groups yet. ", "empty2": "You can ", - "create": "create your own group" + "create": "create your own group", + "create_more": "+ Create a group", + "edit": "Edit", + "login_hint_GROUP": "(delete \"%(GROUP)s\" group)", + "members": "Members" + }, + "group_settings_jsx": { + "title_GROUP": "%(GROUP)s Group", + "submit": "Save", + "owner": "Owner", + "upload": "Upload" }, "emoji_i18n": { "categoriesLabel": "Категории", @@ -243,6 +253,7 @@ "delete": "Delete", "dismiss": "Dismiss", "edit": "Edit", + "groups": "Groups", "feed": "Feed", "incorrect_password": "Incorrect password", "posting_not_memo": "Use posting key now, not memo", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 3e5003cb9..a2a539b64 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -104,6 +104,8 @@ "create_group_jsx": { "title": "Название", "name": "Ссылка chat.golos.app/", + "name2": "Ссылка", + "name3": "chat.golos.app/", "logo": "Логотип", "admin": "Администратор", "encrypted": "Шифровать сообщения в группе", @@ -141,9 +143,16 @@ "empty": "У вас пока нет групп. ", "empty2": "Вы можете ", "create": "создать свою группу", - "create_more": "Создать еще группу", + "create_more": "+ Создать еще группу", "edit": "Изменить", - "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")" + "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")", + "members": "Участники" + }, + "group_settings_jsx": { + "title_GROUP": "Группа %(GROUP)s", + "submit": "Сохранить", + "owner": "Владелец", + "upload": "Загрузить" }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index ead90c9b5..50e75e0d2 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -7,6 +7,7 @@ const defaultState = fromJS({ show_donate_modal: false, show_create_group_modal: false, show_my_groups_modal: false, + show_group_settings_modal: false, show_app_download_modal: false, loginLoading: false, pub_keys_used: null, @@ -138,6 +139,12 @@ export default createModule({ { 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_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_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/utils/groups.js b/src/utils/groups.js index 2de29c40b..91e36d48b 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -9,6 +9,15 @@ const getGroupMeta = (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) @@ -24,5 +33,6 @@ const getGroupLogo = (json_metadata) => { export { getGroupMeta, - getGroupLogo + getGroupTitle, + getGroupLogo, } From 58e7238c59c1c034d4c79ee3833066c06e402fcb Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sun, 23 Jun 2024 20:13:44 +0300 Subject: [PATCH 12/50] HF 30 - Private message groups --- src/components/modules/CreateGroup.jsx | 1 - .../modules/groups/GroupSettings.jsx | 213 ++++++++++++++++-- .../modules/groups/GroupSettings.scss | 14 ++ src/locales/ru-RU.json | 9 +- src/redux/FetchDataSaga.js | 1 + src/redux/TransactionSaga.js | 57 +++++ 6 files changed, 271 insertions(+), 24 deletions(-) diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index fdda26a6c..889e50474 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -146,7 +146,6 @@ class CreateGroup extends React.Component { } }) }, 'active') - } goNext = (e, setFieldValue) => { diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx index 61f749042..410b905ce 100644 --- a/src/components/modules/groups/GroupSettings.jsx +++ b/src/components/modules/groups/GroupSettings.jsx @@ -1,4 +1,5 @@ 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' @@ -15,7 +16,10 @@ 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 { validateAdminStep } from 'app/components/modules/groups/GroupAdmin' import { getGroupLogo, getGroupMeta, getGroupTitle } from 'app/utils/groups' +import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' class GroupSettings extends React.Component { constructor(props) { @@ -28,7 +32,7 @@ class GroupSettings extends React.Component { componentDidMount() { const { currentGroup } = this.props const group = currentGroup.toJS() - const { name, member_list, privacy, json_metadata } = group + const { name, member_list, privacy, json_metadata, is_encrypted } = group const meta = getGroupMeta(json_metadata) let admin for (const mem of member_list) { @@ -38,10 +42,12 @@ class GroupSettings extends React.Component { break } const initialValues = { + name, title: meta.title, logo: meta.logo, admin, - privacy + privacy, + is_encrypted, } this.setState({ initialValues @@ -66,12 +72,81 @@ class GroupSettings extends React.Component { applyFieldValue('admin', value) } - validate = async () => { + 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) } - onSubmit = async () => { + 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) + await validateAdminStep(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) => { @@ -79,6 +154,18 @@ class GroupSettings extends React.Component { 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() @@ -87,7 +174,7 @@ class GroupSettings extends React.Component { const meta = getGroupMeta(json_metadata) const title = getGroupTitle(meta, name) - const { initialValues } = this.state + const { initialValues, submitError } = this.state let form if (!initialValues) { @@ -104,7 +191,7 @@ class GroupSettings extends React.Component { {({ handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, }) => { - const disabled = !isValid + const disabled = !isValid || this.state.uploading return (
@@ -118,32 +205,43 @@ class GroupSettings extends React.Component { autoFocus validateOnBlur={false} /> +
-
+
{tt('create_group_jsx.name2')}
{tt('create_group_jsx.name3')}{name}
+
- {tt('create_group_jsx.logo')} -
- this.onLogoChange(e, { applyFieldValue })} - validateOnBlur={false} - /> - {tt('group_settings_jsx.upload')} -
+ {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.admin')} this.onAdminChange(e, { applyFieldValue })} validateOnBlur={false} /> + +
+
+ +
+
+ {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}
}
-
@@ -190,5 +315,49 @@ export default connect( } }, dispatch => ({ + uploadImage: (file, progress) => { + dispatch({ + type: 'user/UPLOAD_IMAGE', + payload: {file, progress}, + }) + }, + privateGroup: ({ password, creator, name, title, logo, admin, 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, + admin: admin, + 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, + 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 index 927dd8513..e26d01862 100644 --- a/src/components/modules/groups/GroupSettings.scss +++ b/src/components/modules/groups/GroupSettings.scss @@ -2,4 +2,18 @@ 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/locales/ru-RU.json b/src/locales/ru-RU.json index a2a539b64..c2db4afc5 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -152,7 +152,14 @@ "title_GROUP": "Группа %(GROUP)s", "submit": "Сохранить", "owner": "Владелец", - "upload": "Загрузить" + "upload": "Загрузить", + "name_cannot_change": "Ссылку на группу изменить нельзя.", + "preview": "(просмотр)", + "login_hint_GROUP": "(изменения группы \"%(GROUP)s\")", + "encrypted": "Сообщения в группе ", + "encrypted2": " шифруются.", + "encrypted3": " не шифруются.", + "encrypted_hint": "Изменить это нельзя." }, "emoji_i18n": { "categoriesLabel": "Категории", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index caa8fb5fa..b1ebcbff2 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -126,6 +126,7 @@ export function* fetchMyGroups({ payload: { account } }) { accounts: [account] } }) + console.log('LOO', groups) yield put(g.actions.receiveMyGroups({ groups })) } catch (err) { diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index 127e65ccd..e178f096e 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -16,8 +16,56 @@ 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.update({ + key: ['my_groups'], + notSet: List(), + updater: groups => { + const idx = groups.findIndex(i => i.get('name') === json[1].name) + if (idx === -1) { + const now = new Date().toISOString().split('.')[0] + groups = groups.insert(0, fromJS({ + owner: json[1].creator, + name: json[1].name, + json_metadata: json[1].json_metadata, + is_encrypted: json[1].is_encrypted, + privacy: json[1].privacy, + created: now, + admins: 0, + moders: 0, + members: 0, + pendings: 0, + member_list: [{ + account: json[1].creator, + group: json[1].name, + invited: json[1].creator, + joined: now, + json_metadata: '{}', + member_type: 'admin', + updated: now + }] + })) + } else { + groups = groups.update(idx, g => { + g = g.set('json_metadata', json[1].json_metadata); + return g; + }); + } + return groups + } + })) + } + } + return operation +} + + function* preBroadcast_custom_json({operation}) { const json = JSON.parse(operation.json) if (operation.id === 'private_message') { @@ -148,6 +196,15 @@ function* broadcastOperation( try { const res = yield golos.broadcast.sendAsync( tx, [password]) + 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) { From 8dfa1f49f3f07de1ca4d805ce7bce25c3cf29db0 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 27 Jun 2024 08:56:09 +0300 Subject: [PATCH 13/50] HF 30 - Private message groups - members, etc. --- config-overrides.js | 1 + package.json | 1 + src/_themes.scss | 2 + src/assets/icons/ionicons/ban-outline.svg | 1 + src/assets/icons/ionicons/ban.svg | 1 + .../icons/ionicons/checkmark-circle.svg | 2 +- src/assets/icons/ionicons/checkmark-sharp.svg | 2 +- src/assets/icons/ionicons/person-add.svg | 1 + src/assets/icons/ionicons/person.svg | 1 + src/components/all.scss | 2 + src/components/elements/Icon.jsx | 4 + .../elements/common/AccountName/index.jsx | 66 +++++ .../elements/common/AccountName/index.scss | 14 + .../elements/groups/GroupMember.jsx | 121 ++++++++ src/components/modules/CreateGroup.jsx | 88 +++--- src/components/modules/Modals.jsx | 38 ++- src/components/modules/groups/GroupAdmin.jsx | 104 ------- src/components/modules/groups/GroupFinal.jsx | 24 +- .../modules/groups/GroupMembers.jsx | 265 ++++++++++++++++++ .../modules/groups/GroupMembers.scss | 58 ++++ src/components/modules/groups/GroupName.jsx | 6 +- .../modules/groups/GroupSettings.jsx | 33 +-- src/components/modules/groups/MyGroups.jsx | 12 +- src/components/pages/Messages.jsx | 3 + src/locales/en.json | 18 +- src/locales/ru-RU.json | 36 ++- src/redux/FetchDataSaga.js | 43 ++- src/redux/GlobalReducer.js | 72 +++++ src/redux/PollDataSaga.js | 4 + src/redux/TransactionSaga.js | 2 +- src/redux/UserReducer.js | 7 + src/utils/translateError.js | 62 +++- yarn.lock | 191 ++++++++++++- 33 files changed, 1085 insertions(+), 200 deletions(-) create mode 100644 src/assets/icons/ionicons/ban-outline.svg create mode 100644 src/assets/icons/ionicons/ban.svg create mode 100644 src/assets/icons/ionicons/person-add.svg create mode 100644 src/assets/icons/ionicons/person.svg create mode 100644 src/components/elements/common/AccountName/index.jsx create mode 100644 src/components/elements/common/AccountName/index.scss create mode 100644 src/components/elements/groups/GroupMember.jsx delete mode 100644 src/components/modules/groups/GroupAdmin.jsx create mode 100644 src/components/modules/groups/GroupMembers.jsx create mode 100644 src/components/modules/groups/GroupMembers.scss 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/package.json b/package.json index 92aff8533..1f3d4de16 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "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", 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/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/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/components/all.scss b/src/components/all.scss index 493d1fbe7..f4162c51d 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -8,6 +8,7 @@ @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'; @@ -23,6 +24,7 @@ @import './modules/LoginForm.scss'; @import './modules/CreateGroup.scss'; @import './modules/groups/MyGroups.scss'; +@import './modules/groups/GroupMembers.scss'; @import './modules/groups/GroupSettings.scss'; @import './modules/Modals.scss'; diff --git a/src/components/elements/Icon.jsx b/src/components/elements/Icon.jsx index aff269505..80fe69eef 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -128,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')], @@ -137,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/common/AccountName/index.jsx b/src/components/elements/common/AccountName/index.jsx new file mode 100644 index 000000000..11cf7c5ae --- /dev/null +++ b/src/components/elements/common/AccountName/index.jsx @@ -0,0 +1,66 @@ +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) + } + + lookupAccounts = async (value) => { + try { + 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) + 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) + } + } + + render() { + const { onChange, className, ...rest } = this.props + return tt('account_name_jsx.loading')} + noOptionsMessage={() => tt('account_name_jsx.no_options')} + + loadOptions={this.lookupAccounts} + defaultOptions={true} + cacheOptions={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/groups/GroupMember.jsx b/src/components/elements/groups/GroupMember.jsx new file mode 100644 index 000000000..944d77311 --- /dev/null +++ b/src/components/elements/groups/GroupMember.jsx @@ -0,0 +1,121 @@ +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' + +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 + + 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) { + banBtn = this.groupMember(e, member, 'banned')} /> + } + } else { + deleteBtn = this.groupMember(e, member, 'retired')} /> + } + + return + + + + + {account} + + + + + {!creatingNew && } + + + {isOwner && } + {(!isMe || isMember) && this.groupMember(e, member, 'member')} />} + {(!isMe || isModer) && this.groupMember(e, member, 'moder')} />} + {banBtn} + {deleteBtn} + + + } +} + +export default GroupMember diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index 889e50474..b238b77c3 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -2,7 +2,7 @@ import React from 'react' import {connect} from 'react-redux' import { Formik, Form, Field, ErrorMessage, } from 'formik' import { Map } from 'immutable' -import { api, formatter } from 'golos-lib-js' +import { api } from 'golos-lib-js' import { Asset, Price, AssetEditor } from 'golos-lib-js/lib/utils' import tt from 'counterpart' @@ -16,7 +16,7 @@ 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 GroupAdmin, { validateAdminStep } from 'app/components/modules/groups/GroupAdmin' +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' @@ -24,10 +24,27 @@ import { showLoginDialog } from 'app/components/dialogs/LoginDialog' const STEPS = () => { return { name: tt('create_group_jsx.step_name'), logo: tt('create_group_jsx.step_logo'), - admin: tt('create_group_jsx.step_admin'), + members: tt('create_group_jsx.step_members'), final: 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) @@ -35,14 +52,14 @@ class CreateGroup extends React.Component { step: 'name', validators: 0, initialValues: { + creatingNew: true, + title: '', name: '', is_encrypted: true, privacy: 'public_group', logo: '', - - admin: '', } } this.stepperRef = React.createRef() @@ -50,29 +67,27 @@ class CreateGroup extends React.Component { componentDidMount = async () => { try { - const dgp = await api.getDynamicGlobalProperties() - const { min_golos_power_to_emission } = dgp - const minVS = await Asset(min_golos_power_to_emission[0]) + const dgp = await api.getChainPropertiesAsync() + const { private_group_cost } = dgp + const cost = await Asset(private_group_cost) - const acc = this.props.currentAccount.toJS() - let { vesting_shares } = acc - vesting_shares = Asset(vesting_shares) - - if (vesting_shares.gte(minVS)) { + 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({ loaded: true }) return } - const minGolos = await Asset(min_golos_power_to_emission[1]) - const vsGolos = formatter.vestToGolos(vesting_shares, dgp.total_vesting_shares, dgp.total_vesting_fund_steem) - const delta = minGolos.minus(vsGolos) + const delta = cost.minus(gbgBalance) this.setState({ loaded: true, createError: { - minGolos, - vsGolos, + cost, + gbgBalance, delta, accName: acc.name } @@ -106,8 +121,8 @@ class CreateGroup extends React.Component { await validateNameStep(values, errors) } else if (step === 'logo') { await validateLogoStep(values, errors) - } else if (step === 'admin') { - await validateAdminStep(values, errors) + } else if (step === 'members') { + await validateMembersStep(values, errors) } await this.setValidating(false) return errors @@ -172,18 +187,18 @@ class CreateGroup extends React.Component { } else if (createError) { - const { message, minGolos, delta, vsGolos, accName } = createError + const { message, cost, gbgBalance, delta, accName } = createError if (message) { form =
{message}
} else { - form =
- {tt('create_group_jsx.golos_power_too_low') + minGolos.floatString + '. '}
- {tt('create_group_jsx.golos_power_too_low2')} + form =
+ {tt('create_group_jsx.gbg_too_low') + cost.floatString + '. '}
+ {tt('create_group_jsx.gbg_too_low2')} {delta.floatString}.
- +
} @@ -199,13 +214,13 @@ class CreateGroup extends React.Component { {({ handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, }) => { - const disabled = !isValid || !!validators + const disabled = !isValid || !!validators || !values.name return ( {!isSubmitting ? (step === 'name' ? : step === 'logo' ? : - step === 'admin' ? : + step === 'members' ? : step === 'final' ? : ) : null} @@ -215,18 +230,20 @@ class CreateGroup extends React.Component { {isSubmitting ?
: } + )}}) return
-
-

{tt('msgs_start_panel.create_group')}

-
- {form} +
+

{tt('msgs_start_panel.create_group')}

+
+ {form}
} } @@ -243,7 +260,11 @@ export default connect( } }, dispatch => ({ - privateGroup: ({ password, creator, name, title, logo, admin, is_encrypted, privacy, + stripGroupMembers: (group) => { + dispatch(g.actions.receiveGroupMembers({ + group, members: [], append: false })) + }, + privateGroup: ({ password, creator, name, title, logo, moders, is_encrypted, privacy, onSuccess, onError }) => { let json_metadata = { app: 'golos-messenger', @@ -257,7 +278,6 @@ export default connect( creator, name, json_metadata, - admin: admin, is_encrypted, privacy, extensions: [], diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index 8ff3ffd22..b29bfd03d 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -7,6 +7,7 @@ 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 Donate from 'app/components/modules/Donate' import LoginForm from 'app/components/modules/LoginForm'; @@ -23,6 +24,7 @@ class Modals extends React.Component { show_create_group_modal: PropTypes.bool, show_my_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, @@ -47,12 +49,14 @@ class Modals extends React.Component { show_create_group_modal, show_my_groups_modal, show_group_settings_modal, + show_group_members_modal, show_app_download_modal, hideLogin, hideDonate, hideCreateGroup, hideMyGroups, hideGroupSettings, + hideGroupMembers, hideAppDownload, notifications, removeNotification, @@ -67,28 +71,45 @@ class Modals extends React.Component { return n; }) : []; + const modalStyle = { + borderRadius: '8px', + boxShadow: '0 0 19px 3px rgba(0,0,0, 0.2)', + overflow: 'hidden', + } + return (
- {show_login_modal && + {show_login_modal && } - {show_donate_modal && + {show_donate_modal && } - {show_create_group_modal && + {show_create_group_modal && } - {show_my_groups_modal && + {show_my_groups_modal && } - {show_group_settings_modal && + {show_group_settings_modal && } - {show_app_download_modal && + {show_group_members_modal && + + + } + {show_app_download_modal && } @@ -112,6 +133,7 @@ export default connect( show_create_group_modal: state.user.get('show_create_group_modal'), show_my_groups_modal: state.user.get('show_my_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'), @@ -138,6 +160,10 @@ export default connect( 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()) diff --git a/src/components/modules/groups/GroupAdmin.jsx b/src/components/modules/groups/GroupAdmin.jsx deleted file mode 100644 index 56f8b3a6f..000000000 --- a/src/components/modules/groups/GroupAdmin.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react' -import { connect } from 'react-redux' -import { Field, ErrorMessage, } from 'formik' -import tt from 'counterpart' -import { api } from 'golos-lib-js' -import { validateAccountName } from 'golos-lib-js/lib/utils' - -import Input from 'app/components/elements/common/Input'; - -export async function validateAdminStep(values, errors) { - if (!values.admin) { - errors.admin = tt('g.required') - } else { - const nameError = validateAccountName(values.admin) - if (nameError.error) { - errors.admin = tt('account_name.' + nameError.error) - } else { - try { - let accs = await api.getAccountsAsync([values.admin]) - accs = accs[0] - if (!accs) { - errors.admin = tt('g.username_does_not_exist') - } - } catch (err) { - console.error(err) - errors.admin = 'Blockchain unavailable :(' - } - } - } -} - -class GroupAdmin extends React.Component { - state = {} - - constructor(props) { - super(props) - } - - componentDidMount() { - this.load() - } - - componentDidUpdate() { - this.load() - } - - load = () => { - const { loaded } = this.state - if (!loaded) { - const { username, applyFieldValue } = this.props - if (username) { - applyFieldValue('admin', username) - this.setState({ - loaded: true - }) - } - } - } - - onChange = async (e) => { - e.preventDefault() - const { applyFieldValue } = this.props - applyFieldValue('admin', e.target.value) - } - - render() { - const { uploading } = this.state - - return -
-
- {tt('create_group_jsx.admin_desc')} -
-
-
-
- this.onChange(e)} - > - - -
-
-
- } -} - -export default connect( - // mapStateToProps - (state, ownProps) => { - const currentUser = state.user.get('current') - const username = currentUser && currentUser.get('username') - return { - ...ownProps, - username, - } - }, - dispatch => ({ - }) -)(GroupAdmin) diff --git a/src/components/modules/groups/GroupFinal.jsx b/src/components/modules/groups/GroupFinal.jsx index a73215dab..1fa2cdeca 100644 --- a/src/components/modules/groups/GroupFinal.jsx +++ b/src/components/modules/groups/GroupFinal.jsx @@ -3,6 +3,8 @@ import { connect } from 'react-redux' import { Field, ErrorMessage, } from 'formik' import tt from 'counterpart' +import ExtLink from 'app/components/elements/ExtLink' + class GroupFinal extends React.Component { state = {} @@ -10,6 +12,20 @@ class GroupFinal extends React.Component { super(props) } + decorateSubmitError = (error) => { + if (error && error.startsWith && error.startsWith(tt('donate_jsx.insufficient_funds'))) { + const { username } = this.props + return + {error} + + + + + } + return error + } + render() { const { submitError } = this.props return @@ -19,7 +35,7 @@ class GroupFinal extends React.Component { {tt('create_group_jsx.final_desc')} {submitError ?
- {submitError} + {this.decorateSubmitError(submitError)}
: null}
@@ -30,8 +46,12 @@ class GroupFinal extends React.Component { export default connect( // mapStateToProps (state, ownProps) => { + const currentUser = state.user.getIn(['current']) + const username = currentUser && currentUser.get('username') + return { - ...ownProps + ...ownProps, + username, } }, dispatch => ({ diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx new file mode 100644 index 000000000..228232dda --- /dev/null +++ b/src/components/modules/groups/GroupMembers.jsx @@ -0,0 +1,265 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Field, ErrorMessage, } from 'formik' +import tt from 'counterpart' +import { api } from 'golos-lib-js' +import { validateAccountName } from 'golos-lib-js/lib/utils' + +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 { getGroupMeta, getGroupTitle } from 'app/utils/groups' + +export async function validateMembersStep(values, errors) { + /*if (!values.admin) { + errors.admin = tt('g.required') + } else { + const nameError = validateAccountName(values.admin) + if (nameError.error) { + errors.admin = tt('account_name.' + nameError.error) + } else { + try { + let accs = await api.getAccountsAsync([values.admin]) + accs = accs[0] + if (!accs) { + errors.admin = tt('g.username_does_not_exist') + } + } catch (err) { + console.error(err) + errors.admin = 'Blockchain unavailable :(' + } + } + }*/ +} + +class GroupMembers extends React.Component { + state = {} + + constructor(props) { + super(props) + } + + 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 = () => { + const { initialized } = this.state + if (!initialized) { + const { currentGroup } = this.props + if (currentGroup) { + const group = currentGroup + this.props.fetchGroupMembers(group) + this.setState({ + initialized: 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: () => { + this.props.updateGroupMember(group, member, member_type) + }, + onError: (err, errStr) => { + alert(errStr) + } + }) + } + } catch (err) { // TODO: and it is not enough :) if error in groupMember + console.error(err) + } + } + + 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() + + 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 =
+
+
+ +
+
+
+
+ {mems} +
+
+
+ } + + let header + const { creatingNew } = currentGroup + if (creatingNew) { + header =
+
+ {tt('create_group_jsx.members_desc')} +
+
+ } else { + const { name, json_metadata } = currentGroup + + const meta = getGroupMeta(json_metadata) + let title = getGroupTitle(meta, name) + + title = tt('group_members_jsx.title') + title + tt('group_members_jsx.title2') + header =
+
+
+

{title}

+
+
+
+
+ + +
+
+
+ } + + 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 + if (newGroup) { + currentGroup = newGroup + } else { + currentGroup = state.user.get('current_group') + if (currentGroup) currentGroup = currentGroup.toJS() + } + const group = currentGroup && state.global.getIn(['groups', currentGroup.name]) + return { + ...ownProps, + username, + currentGroup, + group, + } + }, + dispatch => ({ + fetchGroupMembers: (group) => { + dispatch(g.actions.fetchGroupMembers({ + group: group.name, creatingNew: !!group.creatingNew })) + }, + 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..90a2b4e66 --- /dev/null +++ b/src/components/modules/groups/GroupMembers.scss @@ -0,0 +1,58 @@ +.GroupMembers { + .member-name { + margin-left: 0.5rem; + line-height: 40px; + vertical-align: top; + } + @include themify($themes) { + .member-btns { + float: right; + padding-right: 1rem; + + .member-btn { + cursor: pointer; + padding-top: 0.35rem; + transition: all .1s ease-in; + &.selected { + 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 index 961686e47..686865cd6 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -52,9 +52,6 @@ export default class GroupName extends React.Component { applyFieldValue('title', value) let link = getSlug(value) applyFieldValue('name', link) - this.setState({ - showName: true - }) } onNameChange = (e) => { @@ -83,7 +80,6 @@ export default class GroupName extends React.Component { render() { const { values } = this.props - const { showName } = this.state return
@@ -102,7 +98,7 @@ export default class GroupName extends React.Component {
- {showName ?
+ {(values.title || values.name) ?
{tt('create_group_jsx.name')}
diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx index 410b905ce..27caa38ef 100644 --- a/src/components/modules/groups/GroupSettings.jsx +++ b/src/components/modules/groups/GroupSettings.jsx @@ -17,7 +17,6 @@ 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 { validateAdminStep } from 'app/components/modules/groups/GroupAdmin' import { getGroupLogo, getGroupMeta, getGroupTitle } from 'app/utils/groups' import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' @@ -32,20 +31,12 @@ class GroupSettings extends React.Component { componentDidMount() { const { currentGroup } = this.props const group = currentGroup.toJS() - const { name, member_list, privacy, json_metadata, is_encrypted } = group + const { name, privacy, json_metadata, is_encrypted } = group const meta = getGroupMeta(json_metadata) - let admin - for (const mem of member_list) { - if (mem.member_type === 'admin') { - admin = mem.account - } - break - } const initialValues = { name, title: meta.title, logo: meta.logo, - admin, privacy, is_encrypted, } @@ -67,11 +58,6 @@ class GroupSettings extends React.Component { applyFieldValue('logo', value) } - onAdminChange = (e, { applyFieldValue }) => { - const { value } = e.target - applyFieldValue('admin', value) - } - uploadLogo = (file, name, { applyFieldValue }) => { const { uploadImage } = this.props this.setState({ uploading: true }) @@ -114,7 +100,6 @@ class GroupSettings extends React.Component { errors.title = tt('create_group_jsx.group_min_length') } await validateLogoStep(values, errors) - await validateAdminStep(values, errors) return errors } @@ -241,19 +226,6 @@ class GroupSettings extends React.Component {
-
-
- {tt('create_group_jsx.admin')} - this.onAdminChange(e, { applyFieldValue })} - validateOnBlur={false} - /> - -
-
@@ -321,7 +293,7 @@ export default connect( payload: {file, progress}, }) }, - privateGroup: ({ password, creator, name, title, logo, admin, is_encrypted, privacy, + privateGroup: ({ password, creator, name, title, logo, is_encrypted, privacy, onSuccess, onError }) => { let json_metadata = { app: 'golos-messenger', @@ -335,7 +307,6 @@ export default connect( creator, name, json_metadata, - admin: admin, is_encrypted, privacy, extensions: [], diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index b43f78f07..80fceccfd 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -75,6 +75,11 @@ class MyGroups extends React.Component { this.props.showGroupSettings({ group }) } + showGroupMembers = (e, group) => { + e.preventDefault() + this.props.showGroupMembers({ group }) + } + _renderGroup = (group) => { const { name, json_metadata } = group @@ -101,7 +106,9 @@ class MyGroups extends React.Component { { e.preventDefault() }}> - @@ -192,6 +199,9 @@ export default connect( showGroupSettings({ group }) { dispatch(user.actions.showGroupSettings({ group })) }, + showGroupMembers({ group }) { + dispatch(user.actions.showGroupMembers({ group })) + }, deleteGroup: ({ owner, name, password, onSuccess, onError }) => { const opData = { diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index b4bc6e77c..6e394dd1f 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -164,6 +164,9 @@ class Messages extends React.Component { } 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(() => { diff --git a/src/locales/en.json b/src/locales/en.json index c318f2e3e..23c7bdcc6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -9,8 +9,16 @@ "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" + "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 ", @@ -118,18 +126,21 @@ "submit": "Create", "step_name": "Name", "step_logo": "Logo", - "step_admin": "Administrator", + "step_members": "Members", "step_create": "Create!", "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 ", "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", - "admin_desc": "Admin of group will be...", + "members_desc": "Add people, set moderators of the group...", "final_desc": "Now we are ready to create the group!", "image_wrong": "Cannot load this image.", "image_timeout": "Cannot load this image, it is loading too long..." @@ -260,6 +271,7 @@ "login": "Login", "logout": "Logout", "mentions": "Mentions", + "name": "Name", "night_mode": "Night Mode", "ok": "OK", "refresh": "Refresh", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index c2db4afc5..07089a420 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -9,8 +9,16 @@ "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" + "exceeded_maximum_allowed_bandwidth": "Недостаточно пропускной способности аккаунта. Пополните Силу Голоса или напишите на info@golos.id", + "insufficient1": "Нужно ", + "insufficient2": ", есть ", + "insufficient3": ". ", + "insufficient_top_up": "Пополнить" }, "chainvalidation_js": { "account_name_should": "Имя аккаунта должно ", @@ -121,7 +129,7 @@ "submit": "Создать", "step_name": "Имя", "step_logo": "Логотип", - "step_admin": "Администратор", + "step_members": "Участники", "step_create": "Создать!", "group_already_exists": "Такая группа уже существует.", "validating": "Проверка существования группы...", @@ -129,14 +137,18 @@ "golos_power_too_low": "Для создания группы нужна Сила Голоса не менее ", "golos_power_too_low2": "Вам не хватает ", "golos_power_too_low3": "Ваша Сила Голоса - ", + "gbg_too_low": "Создание группы стоит ", + "gbg_too_low2": "Вам не хватает ", + "gbg_too_low3": "На вашем балансе - ", "deposit_gp": "Пополнить Силу Голоса", "logo_desc": "Логотип группы - это как аватарка у пользователя... Необязательно, но \"must have\"...", "logo_upload": "Загрузить логотип", "logo_link": "Добавить логотип ссылкой", - "admin_desc": "Администратором группы будeт...", + "members_desc": "Вы можете добавить в группу людей, назначить модераторов...", "final_desc": "Теперь все готово к созданию группы!", "image_wrong": "Не удается загрузить картинку.", - "image_timeout": "Не удается загрузить картинку, она загружается слишком долго..." + "image_timeout": "Не удается загрузить картинку, она загружается слишком долго...", + "add_member": "+ Добавить участника..." }, "my_groups_jsx": { "title": "Мои группы", @@ -148,6 +160,21 @@ "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")", "members": "Участники" }, + "group_members_jsx": { + "title": "Участники группы ", + "title2": "", + "check_pending": "Заявки", + "check_pending_hint": "Заявки на вступление в группу", + "check_banned": "Забаненные", + "member": "Обычный участник", + "moder": "Модератор", + "owner": "Владелец группы", + "make_member": "Сделать обычным участником", + "make_moder": "Сделать модератором", + "ban": "Заблокировать", + "unban": "Разблокировать", + "banned": "Заблокирован(-а)" + }, "group_settings_jsx": { "title_GROUP": "Группа %(GROUP)s", "submit": "Сохранить", @@ -271,6 +298,7 @@ "login": "Войти", "logout": "Выйти", "mentions": "Упоминания", + "name": "Имя", "night_mode": "Ночной режим", "ok": "OK", "refresh": "Обновить", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index b1ebcbff2..e19cbd27b 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -9,6 +9,7 @@ export function* fetchDataWatches () { yield fork(watchFetchState) yield fork(watchFetchUiaBalances) yield fork(watchFetchMyGroups) + yield fork(watchFetchGroupMembers) } export function* watchLocationChange() { @@ -33,6 +34,7 @@ export function* fetchState(location_change_action) { state.messages_update = '0'; state.accounts = {} state.assets = {} + state.groups = {} let hasErr = false @@ -117,19 +119,54 @@ export function* watchFetchMyGroups() { export function* fetchMyGroups({ payload: { account } }) { try { - const groups = yield call([api, api.getGroupsAsync], { + const groupsOwn = yield call([api, api.getGroupsAsync], { member: account, - member_types: ['pending', 'member', 'moder', 'admin'], + member_types: [], start_group: '', limit: 100, with_members: { accounts: [account] } }) - console.log('LOO', groups) + 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] yield put(g.actions.receiveMyGroups({ groups })) } catch (err) { console.error('fetchMyGroups', err) } } + +export function* watchFetchGroupMembers() { + yield takeLatest('global/FETCH_GROUP_MEMBERS', fetchGroupMembers) +} + +export function* fetchGroupMembers({ payload: { group, creatingNew } }) { + 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: ['pending', 'member', 'moder'/*, 'banned'*/], + start_member: '', + limit: 100, + }) + + 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 9ab16b7d9..82c2ab783 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -274,5 +274,77 @@ export default createModule({ return state.set('my_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 + }) + 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 + 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) { + 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 + }) + 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 e178f096e..8ce75f1c7 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -209,7 +209,7 @@ function* broadcastOperation( console.error('Broadcast error', err) if (errorCallback) { let errStr = err.toString() - errStr = translateError(errStr) + errStr = translateError(errStr, err.payload) errStr = errStr.substring(0, 160) errorCallback(err, errStr) } diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index 50e75e0d2..8f50ddf62 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -8,6 +8,7 @@ const defaultState = fromJS({ show_create_group_modal: false, show_my_groups_modal: false, show_group_settings_modal: false, + show_group_members_modal: false, show_app_download_modal: false, loginLoading: false, pub_keys_used: null, @@ -145,6 +146,12 @@ export default createModule({ return state }}, { action: 'HIDE_GROUP_SETTINGS', reducer: state => state.set('show_group_settings_modal', false) }, + { action: 'SHOW_GROUP_MEMBERS', reducer: (state, { payload: { group }}) => { + state = state.set('show_group_members_modal', true) + state = state.set('current_group', fromJS(group)) + 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/utils/translateError.js b/src/utils/translateError.js index fa70ee050..ddd0c428b 100644 --- a/src/utils/translateError.js +++ b/src/utils/translateError.js @@ -1,6 +1,40 @@ 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 === null) { + return null + } + 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': @@ -37,5 +71,31 @@ export function translateError(string) { string = tt('chain_errors.exceeded_maximum_allowed_bandwidth') } + 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 += '.' + } + } + } catch (err) { + console.error('getErrorData', err) + } + } + return string } diff --git a/yarn.lock b/yarn.lock index 840f5b117..f64113e3b 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" @@ -3313,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" @@ -4038,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" @@ -4801,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" @@ -5274,7 +5414,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== @@ -6809,6 +6949,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" @@ -8105,7 +8250,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== @@ -8464,6 +8609,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" @@ -8473,6 +8633,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" @@ -8608,6 +8778,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" @@ -9161,7 +9336,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= @@ -9396,6 +9571,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" @@ -9880,6 +10060,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" From 5d150a82fec78862954742e81561535639146877 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 6 Jul 2024 20:10:26 +0300 Subject: [PATCH 14/50] HF 30 - Private group, some fixes --- .../elements/common/AccountName/index.jsx | 45 ++++++++++++++++++- src/components/modules/CreateGroup.jsx | 5 ++- src/components/modules/groups/GroupName.jsx | 14 +++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/components/elements/common/AccountName/index.jsx b/src/components/elements/common/AccountName/index.jsx index 11cf7c5ae..8f0e6c701 100644 --- a/src/components/elements/common/AccountName/index.jsx +++ b/src/components/elements/common/AccountName/index.jsx @@ -8,10 +8,16 @@ 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() } 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, @@ -33,16 +39,53 @@ class AccountName extends React.Component { } } + 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} - defaultOptions={true} + isLoading={isLoading} + defaultOptions={defaultOptions} cacheOptions={false} + onMenuOpen={this.onMenuOpen} + ref={this.ref} + isOptionSelected={() => false} className={'AccountName ' + (className || ' ')} getOptionLabel={(option) => { diff --git a/src/components/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index b238b77c3..6c755e209 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -77,6 +77,7 @@ class CreateGroup extends React.Component { const gbgBalance = Asset(sbd_balance) if (gbgBalance.gte(cost)) { this.setState({ + cost, loaded: true }) return @@ -179,7 +180,7 @@ class CreateGroup extends React.Component { } render() { - const { step, loaded, createError, validators, submitError } = this.state + const { step, loaded, createError, validators, submitError, cost } = this.state let form if (!loaded) { @@ -218,7 +219,7 @@ class CreateGroup extends React.Component { return (
- {!isSubmitting ? (step === 'name' ? : + {!isSubmitting ? (step === 'name' ? : step === 'logo' ? : step === 'members' ? : step === 'final' ? : diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx index 686865cd6..35029ccd0 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -79,7 +79,7 @@ export default class GroupName extends React.Component { } render() { - const { values } = this.props + const { values, cost } = this.props return
@@ -147,6 +147,18 @@ export default class GroupName extends React.Component {
+ + {cost ?
+
+ + + + {tt('create_group_jsx.gbg_too_low')} + {cost.floatString}. + + +
+
: null}
} } From e3ac7e6e6d5e7e4593efc707e9d0ff341ef3d134 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 8 Jul 2024 21:51:10 +0300 Subject: [PATCH 15/50] HF 30 - Private group, finish members --- src/components/elements/groups/GroupCost.jsx | 34 +++++ src/components/modules/CreateGroup.jsx | 133 +++++++++++++++--- src/components/modules/groups/GroupFinal.jsx | 61 +++++++- .../modules/groups/GroupMembers.jsx | 21 +-- src/components/modules/groups/GroupName.jsx | 13 +- .../modules/groups/GroupSettings.jsx | 2 +- src/components/modules/groups/MyGroups.jsx | 2 +- src/locales/en.json | 22 ++- src/locales/ru-RU.json | 8 +- src/redux/TransactionSaga.js | 12 +- 10 files changed, 244 insertions(+), 64 deletions(-) create mode 100644 src/components/elements/groups/GroupCost.jsx 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/modules/CreateGroup.jsx b/src/components/modules/CreateGroup.jsx index 6c755e209..f54791aa0 100644 --- a/src/components/modules/CreateGroup.jsx +++ b/src/components/modules/CreateGroup.jsx @@ -138,16 +138,28 @@ class CreateGroup extends React.Component { 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 } - this.props.privateGroup({ - password, - ...data, - onSuccess: () => { + try { + const finalSuccess = () => { actions.setSubmitting(false) const { closeMe } = this.props if (closeMe) closeMe() @@ -155,12 +167,45 @@ class CreateGroup extends React.Component { window.location.href = '/' + data.name return } - }, - onError: (err, errStr) => { - this.setState({ submitError: errStr }) - actions.setSubmitting(false) } - }) + 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') } @@ -215,14 +260,17 @@ class CreateGroup extends React.Component { {({ handleSubmit, isSubmitting, isValid, values, errors, setFieldValue, applyFieldValue, setFieldTouched, handleChange, }) => { - const disabled = !isValid || !!validators || !values.name + let disabled = !isValid || !!validators || !values.name + if (submitError && submitError.type === 'members') { + disabled = true + } return ( {!isSubmitting ? (step === 'name' ? : step === 'logo' ? : step === 'members' ? : - step === 'final' ? : + step === 'final' ? : ) : null} {!isSubmitting && { - let json_metadata = { + const trx = [] + let json_metadata, opData, json + + json_metadata = { app: 'golos-messenger', version: 1, title, logo } json_metadata = JSON.stringify(json_metadata) - - const opData = { + opData = { creator, name, json_metadata, @@ -283,24 +336,58 @@ export default connect( privacy, extensions: [], } - - const json = JSON.stringify(['private_group', opData]) + json = JSON.stringify(['private_group', opData]) + trx.push(['custom_json', { + id: 'private_message', + required_auths: [creator], + json, + }]) dispatch(transaction.actions.broadcastOperation({ type: 'custom_json', - operation: { + 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_auths: [creator], + required_posting_auths: [requester], json, - }, - username: creator, - password, + }]) + } + + 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/groups/GroupFinal.jsx b/src/components/modules/groups/GroupFinal.jsx index 1fa2cdeca..8954093c9 100644 --- a/src/components/modules/groups/GroupFinal.jsx +++ b/src/components/modules/groups/GroupFinal.jsx @@ -1,8 +1,10 @@ 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 { @@ -13,6 +15,14 @@ class GroupFinal extends React.Component { } 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 @@ -27,18 +37,57 @@ class GroupFinal extends React.Component { } render() { - const { submitError } = this.props + 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}
+ + } } @@ -49,9 +98,17 @@ export default connect( 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 => ({ diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx index 228232dda..500394862 100644 --- a/src/components/modules/groups/GroupMembers.jsx +++ b/src/components/modules/groups/GroupMembers.jsx @@ -2,7 +2,6 @@ import React from 'react' import { connect } from 'react-redux' import { Field, ErrorMessage, } from 'formik' import tt from 'counterpart' -import { api } from 'golos-lib-js' import { validateAccountName } from 'golos-lib-js/lib/utils' import g from 'app/redux/GlobalReducer' @@ -14,25 +13,7 @@ import LoadingIndicator from 'app/components/elements/LoadingIndicator' import { getGroupMeta, getGroupTitle } from 'app/utils/groups' export async function validateMembersStep(values, errors) { - /*if (!values.admin) { - errors.admin = tt('g.required') - } else { - const nameError = validateAccountName(values.admin) - if (nameError.error) { - errors.admin = tt('account_name.' + nameError.error) - } else { - try { - let accs = await api.getAccountsAsync([values.admin]) - accs = accs[0] - if (!accs) { - errors.admin = tt('g.username_does_not_exist') - } - } catch (err) { - console.error(err) - errors.admin = 'Blockchain unavailable :(' - } - } - }*/ + // nothing yet... } class GroupMembers extends React.Component { diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx index 35029ccd0..170ace4e7 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -4,6 +4,7 @@ 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) { @@ -148,17 +149,7 @@ export default class GroupName extends React.Component {
- {cost ?
-
- - - - {tt('create_group_jsx.gbg_too_low')} - {cost.floatString}. - - -
-
: null} + } } diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx index 27caa38ef..a9d8e72ef 100644 --- a/src/components/modules/groups/GroupSettings.jsx +++ b/src/components/modules/groups/GroupSettings.jsx @@ -322,7 +322,7 @@ export default connect( json, }, username: creator, - password, + keys: [password], successCallback: onSuccess, errorCallback: (err, errStr) => { console.error(err) diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index 80fceccfd..255d0f36f 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -220,7 +220,7 @@ export default connect( json, }, username: owner, - password, + keys: [password], successCallback: onSuccess, errorCallback: (err, errStr) => { console.error(err) diff --git a/src/locales/en.json b/src/locales/en.json index 23c7bdcc6..adc9d20ae 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -136,14 +136,19 @@ "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..." + "image_timeout": "Cannot load this image, it is loading too long...", + "add_member": "+ Add Member..." }, "my_groups_jsx": { "title": "My Groups", @@ -155,6 +160,21 @@ "login_hint_GROUP": "(delete \"%(GROUP)s\" group)", "members": "Members" }, + "group_members_jsx": { + "title": "Members Of ", + "title2": " Group", + "check_pending": "Join Requests", + "check_pending_hint": "Pending Group Join Requests", + "check_banned": "Blocked", + "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", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 07089a420..ca295b03d 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -140,15 +140,21 @@ "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": "+ Добавить участника..." + "add_member": "+ Добавить участника...", + "cannot_set_members": "Группа создана успешно. Но, к сожалению, не получилось задать участников из-за ошибки.", + "cannot_set_members2": "Вы можете попытаться сделать это заново в настройках группы." }, "my_groups_jsx": { "title": "Мои группы", diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index 8ce75f1c7..d543f2acc 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -163,11 +163,15 @@ function* broadcastOperation( return; } - if (!password) { - password = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])); - if (!password) { + 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 || [ @@ -195,7 +199,7 @@ function* broadcastOperation( } try { const res = yield golos.broadcast.sendAsync( - tx, [password]) + tx, keys) for (const [type, operation] of operations) { if (hook['accepted_' + type]) { try { From 44992e0ecda9ca7c8dde41d43b89e9a3fbdee758 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 17 Jul 2024 04:44:28 +0300 Subject: [PATCH 16/50] HF 30 - Private groups - ability to view group, refactor code --- server/index.js | 15 +- src/assets/icons/golos.svg | 1 + src/components/all.scss | 1 + src/components/elements/Icon.jsx | 2 +- .../messages/ChatError/ChatError.scss | 6 + .../elements/messages/ChatError/index.jsx | 18 +++ src/components/modules/MessagesTopCenter.jsx | 152 ++++++++++++++++++ src/components/modules/MessagesTopCenter.scss | 26 +++ src/components/modules/Modals.jsx | 2 +- src/components/modules/groups/MyGroups.jsx | 11 +- .../modules/messages/MessageList/index.js | 10 +- .../modules/messages/Messenger/index.js | 6 +- src/components/pages/Messages.jsx | 100 ++++++------ src/locales/ru-RU.json | 12 ++ src/redux/FetchDataSaga.js | 36 +++-- src/utils/Normalizators.js | 2 + 16 files changed, 331 insertions(+), 69 deletions(-) create mode 100644 src/assets/icons/golos.svg create mode 100644 src/components/elements/messages/ChatError/ChatError.scss create mode 100644 src/components/elements/messages/ChatError/index.jsx create mode 100644 src/components/modules/MessagesTopCenter.jsx create mode 100644 src/components/modules/MessagesTopCenter.scss 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/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/components/all.scss b/src/components/all.scss index f4162c51d..e6bdcb924 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -26,6 +26,7 @@ @import './modules/groups/MyGroups.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/elements/Icon.jsx b/src/components/elements/Icon.jsx index 80fe69eef..dcf3f562e 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -24,7 +24,7 @@ const icons = new Map([ // ['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')], 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..3a22ff33a --- /dev/null +++ b/src/components/elements/messages/ChatError/index.jsx @@ -0,0 +1,18 @@ +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 } = this.props + return
+
+
{isGroup ? tt('msgs_chat_error.404_group') : tt('msgs_chat_error.404_acc')}
+
{tt('msgs_chat_error.404_but')}
+
+ } +} + +export default ChatError diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx new file mode 100644 index 000000000..23eb6695b --- /dev/null +++ b/src/components/modules/MessagesTopCenter.jsx @@ -0,0 +1,152 @@ +import React from 'react' +import {connect} from 'react-redux' +import { Link } from 'react-router-dom' +import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdown' +import tt from 'counterpart' + +import ExtLink from 'app/components/elements/ExtLink' +import Icon from 'app/components/elements/Icon' +import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import user from 'app/redux/UserReducer' +import { getGroupLogo, } 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) => { + e.preventDefault() + this.dropdown.current.click() + } + + showGroupMembers = (e) => { + e.preventDefault() + const { the_group } = this.props + if (!the_group) return + this.props.showGroupMembers({ group: the_group }) + } + + _renderGroupDropdown = () => { + return
+ Test +
+ } + + 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) + avatar.push(
+ +
) + } + items.push(
+ + {to} + + {checkmark} +
) + } else { + items.push(
+ {to}{checkmark} +
) + } + + if (notifyErrors >= 30) { + items.push(
+ {isSmall ? + + {tt('messages.sync_error_short')} + { e.preventDefault(); this.props.fetchState(this.props.to) }}> + {tt('g.refresh').toLowerCase()}. + + : + {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 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 => ({ + showGroupMembers({ group }) { + dispatch(user.actions.showGroupMembers({ group })) + }, + }), +)(MessagesTopCenter) diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss new file mode 100644 index 000000000..445c7e74a --- /dev/null +++ b/src/components/modules/MessagesTopCenter.scss @@ -0,0 +1,26 @@ +.MessagesTopCenter { + .avatar-items { + display: inline-block; + vertical-align: top; + margin-top: 5px; + margin-right: 7px; + + .group-logo { + cursor: pointer; + img { + width: 32px; + height: 32px; + } + } + } + .main-items { + display: inline-block; + .to-group { + cursor: pointer; + color: #0078C4; + } + .group-stats { + cursor: pointer; + } + } +} diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index b29bfd03d..e5c328edd 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -96,7 +96,7 @@ class Modals extends React.Component { {show_my_groups_modal && - + } {show_group_settings_modal && diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index 255d0f36f..ade463f4b 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -1,5 +1,6 @@ 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' @@ -9,7 +10,6 @@ import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' import { session } from 'app/redux/UserSaga' 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' @@ -80,6 +80,11 @@ class MyGroups extends React.Component { this.props.showGroupMembers({ group }) } + onGoGroup = (e) => { + const { closeMe } = this.props + if (closeMe) closeMe() + } + _renderGroup = (group) => { const { name, json_metadata } = group @@ -98,7 +103,7 @@ class MyGroups extends React.Component { }, value: tt('g.delete') }) return - + {this._renderGroupLogo(group, meta)} {titleShr} @@ -122,7 +127,7 @@ class MyGroups extends React.Component { : null} - + } diff --git a/src/components/modules/messages/MessageList/index.js b/src/components/modules/messages/MessageList/index.js index 3af4c9f4d..ffeb5c170 100644 --- a/src/components/modules/messages/MessageList/index.js +++ b/src/components/modules/messages/MessageList/index.js @@ -79,7 +79,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() diff --git a/src/components/modules/messages/Messenger/index.js b/src/components/modules/messages/Messenger/index.js index 2f04d2255..93b2a3d44 100644 --- a/src/components/modules/messages/Messenger/index.js +++ b/src/components/modules/messages/Messenger/index.js @@ -38,10 +38,11 @@ export default class Messages extends React.Component { } render() { - const { account, to, + const { account, to, toNew, contacts, conversationTopLeft, conversationTopRight, conversationLinkPattern, 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 @@ -86,6 +87,7 @@ export default class Messages extends React.Component { if ((localStorage.getItem('msgr_auth') && !account) || process.env.MOBILE_APP) return null return }} + renderMessages={renderMessages} messages={messages} replyingMessage={replyingMessage} onCancelReply={onCancelReply} diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 6e394dd1f..9bf3c58b1 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -10,22 +10,23 @@ 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 PageFocus from 'app/components/elements/messages/PageFocus' import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' 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 { getProfileImage, } from 'app/utils/NormalizeProfile'; import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; import { notificationSubscribe, notificationShallowUnsubscribe, notificationTake, sendOffchainMessage } from 'app/utils/NotifyApiClient'; @@ -56,11 +57,18 @@ class Messages extends React.Component { this.composeRef = React.createRef() } + getToAcc = () => { + let { to } = this.props + if (to) to = to.replace('@', '') + return to + } + markMessages() { const { messages } = this.state; if (!messages.length) return; - const { account, accounts, to } = this.props; + const { account, accounts, } = this.props; + const to = this.getToAcc() let OPERATIONS = golos.messages.makeDatedGroups(messages, (message_object, idx) => { return message_object.toMark && !message_object._offchain; @@ -390,7 +398,8 @@ 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 } = this.props; + const to = this.getToAcc() const private_key = currentUser.getIn(['private_keys', 'memo_private']); let editInfo; @@ -480,7 +489,8 @@ class Messages extends React.Component { onPanelDeleteClick = (event) => { const { messages } = this.state; - const { account, accounts, to } = this.props; + const { account, accounts, } = 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) => { @@ -597,7 +607,8 @@ class Messages extends React.Component { if (!url) return; - const { to, account, accounts, currentUser, messages } = this.props; + const { account, accounts, 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], @@ -745,46 +756,16 @@ 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 { to } = this.props + const toAcc = this.getToAcc() + const { notifyErrors } = this.state + + return }; _renderMenuItems = (isSmall) => { @@ -862,6 +843,21 @@ class Messages extends React.Component { ); }; + _renderMessages = ({ }) => { + const { to, the_group, accounts } = this.props + + if (to) { + const isGroup = !to.startsWith('@') + if (isGroup && the_group === null) { + return + } else if (!isGroup && !accounts[this.getToAcc()]) { + return + } + } + + return false + } + handleFocusChange = isFocused => { this.windowFocused = isFocused; if (!isFocused) { @@ -934,6 +930,8 @@ class Messages extends React.Component { conversationTopLeft={this._renderConversationTopLeft} />
); + const toAcc = this.getToAcc() + return (
{bbc} @@ -946,7 +944,7 @@ class Messages extends React.Component { {Messenger ? ( dispatch(user.actions.showMyGroups()), fetchState: (to) => { - const pathname = '/' + (to ? ('@' + to) : ''); + const pathname = '/' + (to || '') dispatch({type: 'FETCH_STATE', payload: { location: { pathname diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index ca295b03d..1c5ae0270 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -109,6 +109,11 @@ "start_chat": "Начать чат", "create_group": "Создать группу" }, + "msgs_chat_error": { + "404_group": "Такой группы у нас нет", + "404_acc": "Такого пользователя нет", + "404_but": "Но у нас есть много интересного..." + }, "create_group_jsx": { "title": "Название", "name": "Ссылка chat.golos.app/", @@ -290,6 +295,13 @@ "confirm": "Подтверждение", "prompt": "Введите данные" }, + "plurals": { + "member_count": { + "zero": "0 участников", + "one": "1 участник", + "other": "%(count)s участник(-ов)" + } + }, "g": { "and": "и", "blog": "Блог", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index e19cbd27b..232521e39 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -61,15 +61,33 @@ export function* fetchState(location_change_action) { state.contacts = yield callSafe(state, [], 'getContactsAsync', [api, api.getContactsAsync], account, 'unknown', 100, 0) if (hasErr) return - if (parts[1]) { - const to = parts[1].replace('@', ''); - accounts.add(to); - - state.messages = yield callSafe(state, [], 'getThreadAsync', [api, api.getThreadAsync], account, to, {}); - if (hasErr) return - - if (state.messages.length) { - state.messages_update = state.messages[state.messages.length - 1].nonce; + const path = parts[1] + if (path) { + if (path.startsWith('@')) { + const to = path.replace('@', ''); + accounts.add(to); + + state.messages = yield callSafe(state, [], 'getThreadAsync', [api, api.getThreadAsync], account, to, {}); + if (hasErr) return + + if (state.messages.length) { + state.messages_update = state.messages[state.messages.length - 1].nonce; + } + } else { + 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 } } for (let contact of state.contacts) { diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 9200d4da5..844a63486 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -68,6 +68,8 @@ export function normalizeContacts(contacts, accounts, currentUser, preDecoded, c } export function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { + if (to) to = to.replace('@', '') + if (!to || !accounts[to]) { return []; } From 3a704e9271a4fb003236789335c67cd3182dfeac Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 22 Jul 2024 03:10:58 +0300 Subject: [PATCH 17/50] HF 30 - Private groups - managing groups --- .../icons/ionicons/lock-closed-outline.svg | 2 +- .../icons/ionicons/lock-open-outline.svg | 1 - src/components/modules/MessagesTopCenter.jsx | 187 +++++++++++++++++- src/components/modules/MessagesTopCenter.scss | 39 ++++ src/components/modules/groups/GroupName.jsx | 6 +- src/components/modules/groups/MyGroups.jsx | 1 - src/locales/ru-RU.json | 14 ++ 7 files changed, 237 insertions(+), 13 deletions(-) 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..bc7aa4300 100644 --- a/src/assets/icons/ionicons/lock-open-outline.svg +++ b/src/assets/icons/ionicons/lock-open-outline.svg @@ -1,5 +1,4 @@ - Lock Open Layer 1 diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index 23eb6695b..aa374d335 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -1,14 +1,19 @@ import React from 'react' import {connect} from 'react-redux' +import { withRouter } from 'react-router' import { Link } from 'react-router-dom' import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdown' import tt from 'counterpart' +import cn from 'classnames' +import { showLoginDialog } from 'app/components/dialogs/LoginDialog' +import DropdownMenu from 'app/components/elements/DropdownMenu' import ExtLink from 'app/components/elements/ExtLink' import Icon from 'app/components/elements/Icon' import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' -import { getGroupLogo, } from 'app/utils/groups' +import { getGroupLogo, getGroupMeta, getGroupTitle, } from 'app/utils/groups' import { getLastSeen } from 'app/utils/NormalizeProfile' class MessagesTopCenter extends React.Component { @@ -31,15 +36,149 @@ class MessagesTopCenter extends React.Component { this.props.showGroupMembers({ group: the_group }) } + 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 = () => { - return
- Test + 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) + + 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') + } + const lock = + groupType =
{groupType} {lock}
+ + let myStatus = null + let btnType + let showKebab, isOwner, banned + if (owner === username) { + myStatus = tt('msgs_group_dropdown.owner') + showKebab = true + } else { + const mem = member_list.find(pgm => pgm.account === username) + const { member_type } = (mem || {}) + + 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) { + 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) }, + ] + + return
+ +
+ {title} +
+ {groupType} + {myStatus} +
+ {showKebab ? + + : null} + + {btn} +
} render() { let avatar = [] let items = [] + let clickable = false const { to, toAcc, isSmall, notifyErrors, the_group } = this.props @@ -56,7 +195,7 @@ class MessagesTopCenter extends React.Component { if (the_group) { const { json_metadata } = the_group const logo = getGroupLogo(json_metadata) - avatar.push(
+ avatar.push(
) } @@ -71,6 +210,7 @@ class MessagesTopCenter extends React.Component { {checkmark}
) + clickable = true } else { items.push(
{to}{checkmark} @@ -110,7 +250,7 @@ class MessagesTopCenter extends React.Component { if (the_group) { const totalMembers = the_group.members + the_group.moders items.push(
+ style={secondStyle}> {tt('plurals.member_count', { count: totalMembers })} @@ -119,14 +259,16 @@ class MessagesTopCenter extends React.Component { } } - return
+ return
{avatar}
{items}
} } -export default connect( +export default withRouter(connect( (state, ownProps) => { const currentUser = state.user.get('current') const accounts = state.global.get('accounts') @@ -148,5 +290,34 @@ export default connect( showGroupMembers({ group }) { dispatch(user.actions.showGroupMembers({ 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) +)(MessagesTopCenter)) diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss index 445c7e74a..0df323c4f 100644 --- a/src/components/modules/MessagesTopCenter.scss +++ b/src/components/modules/MessagesTopCenter.scss @@ -1,4 +1,8 @@ .MessagesTopCenter { + &.clickable { + cursor: pointer; + } + .avatar-items { display: inline-block; vertical-align: top; @@ -13,6 +17,7 @@ } } } + .main-items { display: inline-block; .to-group { @@ -24,3 +29,37 @@ } } } + +.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; + .button { + margin-right: 0px !important; + margin-bottom: 0.55rem; + } + .DropdownMenu { + margin-left: 0.25rem; + margin-top: 0.25rem; + } + } +} diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx index 170ace4e7..a0b9d29c9 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -75,8 +75,10 @@ export default class GroupName extends React.Component { onPrivacyChange = (e) => { const { applyFieldValue } = this.props - applyFieldValue('privacy', e.target.value) - applyFieldValue('is_encrypted', true) + const { value } = e.target + applyFieldValue('privacy', value) + if (value === 'private_group') + applyFieldValue('is_encrypted', true) } render() { diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index ade463f4b..53eb61749 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -12,7 +12,6 @@ import { session } from 'app/redux/UserSaga' import DropdownMenu from 'app/components/elements/DropdownMenu' 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 { getGroupLogo, getGroupMeta } from 'app/utils/groups' diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 1c5ae0270..a4a855d71 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -105,6 +105,20 @@ "blocked_BY": "Вы заблокированы пользователем @%(BY)s.", "do_not_bother_BY": "@%(BY)s просит пользователей с низкой репутацией не беспокоить." }, + "msgs_group_dropdown": { + "join": "Вступить", + "retire": "Покинуть", + "cancel": "Отменить", + "public": "Открытая группа", + "read_only": "Группа, открытая только для чтения", + "private": "Закрытая группа", + "encrypted": "Сообщения шифруются", + "not_encrypted": "Сообщения не шифруются", + "banned": "Вы забанены.", + "pending": "Вы подали заявку.", + "moder": "Вы модератор.", + "owner": "Вы владелец." + }, "msgs_start_panel": { "start_chat": "Начать чат", "create_group": "Создать группу" From 9fdf1495d8745c234b184eeae744edbd6290db67 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 24 Jul 2024 00:57:53 +0300 Subject: [PATCH 18/50] Test deps --- package.json | 2 +- yarn.lock | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 1f3d4de16..1d291fa44 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "react-dom": "^17.0.2", "react-dom-confetti": "^0.2.0", "react-dropzone": "^14.2.3", - "react-foundation-components": "git+https://github.com/golos-blockchain/react-foundation-components.git#6606fd5529f1ccbc77cd8d33a8ce139fdf8f9a11", + "react-foundation-components": "/root/react-foundation-components", "react-intl": "^5.24.6", "react-notification": "^6.8.5", "react-redux": "^7.2.6", diff --git a/yarn.lock b/yarn.lock index f64113e3b..b984d0498 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8449,9 +8449,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@/root/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" From edc6620de09be6b32553bef52d6f9f8971f8ad1a Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 24 Jul 2024 02:14:55 +0300 Subject: [PATCH 19/50] HF 30 - Private groups dropdown + Update react-foundation-components --- package.json | 2 +- .../elements/groups/GroupMember.jsx | 12 +++-- src/components/modules/MessagesTopCenter.jsx | 50 ++++++++++++++++--- src/components/modules/MessagesTopCenter.scss | 5 +- .../modules/groups/GroupMembers.jsx | 7 ++- src/foundation-overrides.scss | 8 ++- src/utils/groups.js | 7 +++ yarn.lock | 3 +- 8 files changed, 78 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 1d291fa44..a9a3e7c7b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "react-dom": "^17.0.2", "react-dom-confetti": "^0.2.0", "react-dropzone": "^14.2.3", - "react-foundation-components": "/root/react-foundation-components", + "react-foundation-components": "git+https://github.com/golos-blockchain/react-foundation-components.git#45bcb22dd3e078a515a9f7dfb33f834b31f83a43", "react-intl": "^5.24.6", "react-notification": "^6.8.5", "react-redux": "^7.2.6", diff --git a/src/components/elements/groups/GroupMember.jsx b/src/components/elements/groups/GroupMember.jsx index 944d77311..869015d00 100644 --- a/src/components/elements/groups/GroupMember.jsx +++ b/src/components/elements/groups/GroupMember.jsx @@ -5,6 +5,7 @@ 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 { getMemberType } from 'app/utils/groups' class GroupMember extends React.Component { // shouldComponentUpdate(nextProps) { @@ -53,7 +54,10 @@ class GroupMember extends React.Component { render() { const { member, username, currentGroup } = this.props const { account, member_type, joined } = member - const { creatingNew } = currentGroup + const { creatingNew, member_list } = currentGroup + + const amOwner = currentGroup.owner === username + const amModer = amOwner || (member_list && getMemberType(member_list, username) === 'moder') const isMe = username === account const isOwner = currentGroup.owner === account @@ -79,7 +83,7 @@ class GroupMember extends React.Component { } if (!creatingNew) { - if (!isMe || isBanned) { + if ((!isMe || isBanned) && amModer) { banBtn = this.groupMember(e, member, 'banned')} /> @@ -105,10 +109,10 @@ class GroupMember extends React.Component { {isOwner && } - {(!isMe || isMember) && this.groupMember(e, member, 'member')} />} - {(!isMe || isModer) && this.groupMember(e, member, 'moder')} />} {banBtn} diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index aa374d335..9168ec837 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -2,6 +2,7 @@ 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' @@ -13,7 +14,7 @@ import Icon from 'app/components/elements/Icon' import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' -import { getGroupLogo, getGroupMeta, getGroupTitle, } from 'app/utils/groups' +import { getMemberType, getGroupLogo, getGroupMeta, getGroupTitle, } from 'app/utils/groups' import { getLastSeen } from 'app/utils/NormalizeProfile' class MessagesTopCenter extends React.Component { @@ -107,8 +108,7 @@ class MessagesTopCenter extends React.Component { myStatus = tt('msgs_group_dropdown.owner') showKebab = true } else { - const mem = member_list.find(pgm => pgm.account === username) - const { member_type } = (mem || {}) + const member_type = getMemberType(member_list, username) const isMember = member_type === 'member' const isModer = member_type === 'moder' @@ -135,8 +135,12 @@ class MessagesTopCenter extends React.Component { let btn if (btnType) { + const onBtnClick = (e) => { + e.preventDefault() + } + if (btnType === 'join') { - btn = } else { @@ -144,7 +148,7 @@ class MessagesTopCenter extends React.Component { if (btnType === 'cancel') { btnTitle = tt('msgs_group_dropdown.cancel') } - btn = } @@ -155,7 +159,9 @@ class MessagesTopCenter extends React.Component { { link: '#', value: tt('g.delete'), onClick: e => this.deleteGroup(e, title) }, ] - return
+ return
{ + e.stopPropagation() + }}>
{title} @@ -203,8 +209,9 @@ class MessagesTopCenter extends React.Component { {to} @@ -287,6 +294,35 @@ export default withRouter(connect( } }, 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 })) }, diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss index 0df323c4f..c657929f0 100644 --- a/src/components/modules/MessagesTopCenter.scss +++ b/src/components/modules/MessagesTopCenter.scss @@ -55,7 +55,10 @@ padding-right: 0.75rem; .button { margin-right: 0px !important; - margin-bottom: 0.55rem; + &.margin { + margin-right: 7px !important; + } + margin-bottom: 0.75rem; } .DropdownMenu { margin-left: 0.25rem; diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx index 500394862..7480c3335 100644 --- a/src/components/modules/groups/GroupMembers.jsx +++ b/src/components/modules/groups/GroupMembers.jsx @@ -10,7 +10,7 @@ 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 { getGroupMeta, getGroupTitle } from 'app/utils/groups' +import { getMemberType, getGroupMeta, getGroupTitle } from 'app/utils/groups' export async function validateMembersStep(values, errors) { // nothing yet... @@ -121,6 +121,11 @@ class GroupMembers extends React.Component { filterAccs.add(m.account) } + // TODO: but we should check it in diff cases + const { owner, member_list } = currentGroup + const amOwner = currentGroup.owner === username + const amModer = amOwner || (member_list && getMemberType(member_list, username) === 'moder') + mems =
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/utils/groups.js b/src/utils/groups.js index 91e36d48b..3106c1fb9 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -1,5 +1,11 @@ import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' +const getMemberType = (member_list, username) => { + const mem = member_list.find(pgm => pgm.account === username) + const { member_type } = (mem || {}) + return member_type +} + const getGroupMeta = (json_metadata) => { let meta if (json_metadata) { @@ -32,6 +38,7 @@ const getGroupLogo = (json_metadata) => { } export { + getMemberType, getGroupMeta, getGroupTitle, getGroupLogo, diff --git a/yarn.lock b/yarn.lock index b984d0498..0819c2aad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8449,8 +8449,9 @@ 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@/root/react-foundation-components: +"react-foundation-components@git+https://github.com/golos-blockchain/react-foundation-components.git#45bcb22dd3e078a515a9f7dfb33f834b31f83a43": version "0.14.0" + resolved "git+https://github.com/golos-blockchain/react-foundation-components.git#45bcb22dd3e078a515a9f7dfb33f834b31f83a43" dependencies: babel-runtime "^6.25.0" classnames "^2.2.5" From 89f5baa3899f25b603e47678b9df2367651fc8ab Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 24 Jul 2024 05:59:32 +0300 Subject: [PATCH 20/50] git-install --- .gitignore | 3 ++ Dockerfile | 1 + git-deps/.gitkeep | 0 git-install.js | 125 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 9 +++- yarn.lock | 6 +-- 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 git-deps/.gitkeep create mode 100644 git-install.js 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/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 a9a3e7c7b..e97df4177 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "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?3b21166c33ade760d562091e1fa0b71d172a7aaf", + "formik": "./git-deps/formik/packages/formik", "git-rev-sync": "^3.0.2", "golos-lib-js": "^0.9.69", "history": "4.10.1", @@ -33,7 +33,7 @@ "react-dom": "^17.0.2", "react-dom-confetti": "^0.2.0", "react-dropzone": "^14.2.3", - "react-foundation-components": "git+https://github.com/golos-blockchain/react-foundation-components.git#45bcb22dd3e078a515a9f7dfb33f834b31f83a43", + "react-foundation-components": "./git-deps/react-foundation-components", "react-intl": "^5.24.6", "react-notification": "^6.8.5", "react-redux": "^7.2.6", @@ -66,7 +66,12 @@ "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:server": "nodemon server", diff --git a/yarn.lock b/yarn.lock index 0819c2aad..88a3bfeec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5031,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?3b21166c33ade760d562091e1fa0b71d172a7aaf": +formik@./git-deps/formik/packages/formik: version "2.2.9" - resolved "https://gitpkg.now.sh/golos-blockchain/formik/packages/formik?3b21166c33ade760d562091e1fa0b71d172a7aaf#0f72ba16b0610fc14a6d42dfe2556600df5a3132" dependencies: deepmerge "^2.1.1" hoist-non-react-statics "^3.3.0" @@ -8449,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#45bcb22dd3e078a515a9f7dfb33f834b31f83a43": +react-foundation-components@./git-deps/react-foundation-components: version "0.14.0" - resolved "git+https://github.com/golos-blockchain/react-foundation-components.git#45bcb22dd3e078a515a9f7dfb33f834b31f83a43" dependencies: babel-runtime "^6.25.0" classnames "^2.2.5" From e1b850cc5c86d444d121d1058953e173d634f36d Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 24 Jul 2024 21:59:09 +0300 Subject: [PATCH 21/50] HF 30 - Private groups dropdown --- .../icons/ionicons/lock-open-outline.svg | 1 - src/components/modules/MessagesTopCenter.jsx | 55 ++++++++++++++++--- src/locales/ru-RU.json | 5 +- src/redux/GlobalReducer.js | 44 +++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/assets/icons/ionicons/lock-open-outline.svg b/src/assets/icons/ionicons/lock-open-outline.svg index bc7aa4300..a9401575c 100644 --- a/src/assets/icons/ionicons/lock-open-outline.svg +++ b/src/assets/icons/ionicons/lock-open-outline.svg @@ -1,7 +1,6 @@ - Layer 1 diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index 9168ec837..2dca63022 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -7,11 +7,13 @@ import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdow 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 ExtLink from 'app/components/elements/ExtLink' import Icon from 'app/components/elements/Icon' import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' +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' @@ -96,10 +98,7 @@ class MessagesTopCenter extends React.Component { } else { groupType = tt('msgs_group_dropdown.private') } - const lock = - groupType =
{groupType} {lock}
+ groupType =
{groupType}
let myStatus = null let btnType @@ -135,12 +134,47 @@ class MessagesTopCenter extends React.Component { let btn if (btnType) { - const onBtnClick = (e) => { + const onBtnClick = async (e) => { e.preventDefault() + this.openDropdown(e) + + if (btnType === 'retire') { + const res = await DialogManager.dangerConfirm(
+ {tt('msgs_group_dropdown.are_you_sure_retire') + ' ' + title + '?'}
, + 'GOLOS Messenger') + if (!res) return + } + + const member_type = btnType === 'join' ? 'pending' : 'retired' + this.props.groupMember({ + requester: username, group: name, + member: username, + member_type, + onSuccess: () => { + let ml = [] + if (btnType === 'join') { + ml.push({ + account: username, + member_type: 'pending' + }) + } else { + ml = member_list.map(mem => { + if (mem.account === username) { + mem.member_type = member_type + } + return mem + }) + } + this.props.updateMemberList(ml) + }, + onError: (err, errStr) => { + alert(errStr) + } + }) } if (btnType === 'join') { - btn = } else { @@ -159,12 +193,16 @@ class MessagesTopCenter extends React.Component { { link: '#', value: tt('g.delete'), onClick: e => this.deleteGroup(e, title) }, ] + const lock = + return
{ e.stopPropagation() }}>
- {title} + {title} {lock}
{groupType} {myStatus} @@ -329,6 +367,9 @@ export default withRouter(connect( showGroupSettings({ group }) { dispatch(user.actions.showGroupSettings({ group })) }, + updateMemberList(member_list) { + dispatch(g.actions.updateMemberList({ member_list })) + }, deleteGroup: ({ owner, name, password, onSuccess, onError }) => { const opData = { diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index a4a855d71..cfa696864 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -115,9 +115,10 @@ "encrypted": "Сообщения шифруются", "not_encrypted": "Сообщения не шифруются", "banned": "Вы забанены.", - "pending": "Вы подали заявку.", + "pending": "Вы подали заявку на вступление.", "moder": "Вы модератор.", - "owner": "Вы владелец." + "owner": "Вы владелец.", + "are_you_sure_retire": "Вы уверены, что хотите покинуть группу" }, "msgs_start_panel": { "start_chat": "Начать чат", diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 82c2ab783..80b8ea029 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -346,5 +346,49 @@ export default createModule({ return new_state }, }, + { + action: 'UPDATE_MEMBER_LIST', + reducer: (state, { payload: { member_list } }) => { + let new_state = state + const updater = (gro) => { + const mMap = {} + for (const mem of member_list) { + const { account } = mem + mMap[account] = { ...mMap[account], ...mem } + } + if (!gro.has('member_list')) { + gro = gro.set('member_list', List()) + } + gro = gro.update('member_list', List(), data => { + let newList = List() + data.forEach((mem, i) => { + const acc = mem.get('account') + if (mMap[acc]) { + if (mMap[acc].member_type !== 'retired') { + const newMem = mem.mergeDeep(fromJS(mMap[acc])) + newList = newList.push(newMem) + } + delete mMap[acc] + } else { + newList = newList.push(mem) + } + }) + const addVals = Object.values(mMap) + for (const av of addVals) { + if (av.member_type !== 'retired') { + newList = newList.push(fromJS(av)) + } + } + return newList + }) + return gro + } + new_state = new_state.update('the_group', Map(), gro => { + gro = updater(gro) + return gro + }) + return new_state + }, + }, ], }) From d7f1d586f18972319397d672e015024fc19bfad7 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 2 Aug 2024 08:13:38 +0300 Subject: [PATCH 22/50] HF 30 - Private groups --- src/components/all.scss | 1 + src/components/dialogs/LoginDialog/index.jsx | 9 -- src/components/elements/Stub.jsx | 125 +++++++++++++++ src/components/elements/Stub.scss | 14 ++ .../elements/common/DialogManager/index.scss | 2 +- .../elements/groups/GroupMember.jsx | 15 +- .../elements/messages/Compose/index.jsx | 29 ++-- .../messages/ToolbarButton/ToolbarButton.css | 4 +- src/components/modules/MessagesTopCenter.jsx | 61 ++++---- src/components/modules/MessagesTopCenter.scss | 2 + .../modules/groups/GroupMembers.jsx | 142 ++++++++++++++---- .../modules/groups/GroupMembers.scss | 31 +++- src/components/modules/groups/MyGroups.jsx | 118 +++++++++++++-- src/components/modules/groups/MyGroups.scss | 1 + .../modules/messages/MessageList/index.js | 12 +- .../modules/messages/Messenger/index.js | 3 +- src/components/pages/Messages.jsx | 14 +- src/locales/en.json | 34 ++++- src/locales/ru-RU.json | 16 +- src/redux/FetchDataSaga.js | 5 +- src/redux/GlobalReducer.js | 116 ++++++++++---- src/redux/TransactionSaga.js | 41 +---- src/redux/UserReducer.js | 7 +- src/utils/groups.js | 29 +++- 24 files changed, 647 insertions(+), 184 deletions(-) create mode 100644 src/components/elements/Stub.jsx create mode 100644 src/components/elements/Stub.scss diff --git a/src/components/all.scss b/src/components/all.scss index e6bdcb924..ba1f81f37 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -4,6 +4,7 @@ @import './elements/LoadingIndicator'; @import './elements/Logo'; @import "./elements/NotifiCounter"; +@import "./elements/Stub"; @import "./elements/Userpic"; @import "./elements/VerticalMenu"; @import "./elements/app/AppReminder"; diff --git a/src/components/dialogs/LoginDialog/index.jsx b/src/components/dialogs/LoginDialog/index.jsx index 9a37ddd51..78338dc78 100644 --- a/src/components/dialogs/LoginDialog/index.jsx +++ b/src/components/dialogs/LoginDialog/index.jsx @@ -10,8 +10,6 @@ import keyCodes from 'app/utils/keyCodes'; import { pageSession } from 'app/redux/UserSaga' export function showLoginDialog(username, onClose, authType = 'active', saveLogin = false, hint = '') { - let dm, oldZ = '' - DialogManager.showDialog({ component: LoginDialog, adaptive: true, @@ -21,16 +19,9 @@ export function showLoginDialog(username, onClose, authType = 'active', saveLogi hint, }, onClose: (data) => { - if (dm) dm.style.zIndex = oldZ if (onClose) onClose(data) }, }); - - setTimeout(() => { - dm = document.getElementsByClassName('DialogManager')[0] - oldZ = dm ? dm.style.zIndex : '' - if (dm) dm.style.zIndex = 1000 - }, 1) } export default class LoginDialog extends React.PureComponent { diff --git a/src/components/elements/Stub.jsx b/src/components/elements/Stub.jsx new file mode 100644 index 000000000..b10117911 --- /dev/null +++ b/src/components/elements/Stub.jsx @@ -0,0 +1,125 @@ +import React from 'react' +import { connect } from 'react-redux' +import tt from 'counterpart' + +import transaction from 'app/redux/TransactionReducer' +import { getRoleInGroup } from 'app/utils/groups' + +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 } = 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')} + } + + if (isCompose) { + return
+ {text}{btn} +
+ } else { + 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) => { + let composeStub, msgsStub + if (!the_group || the_group.name !== to) { + return { composeStub, msgsStub} + } + + const { privacy } = the_group + if (privacy !== 'public_group') { + const { amBanned, amMember, amModer, amPending } = getRoleInGroup(the_group, username) + const notMember = !amModer && !amMember + if (amBanned || 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..552c0b8cf --- /dev/null +++ b/src/components/elements/Stub.scss @@ -0,0 +1,14 @@ +.compose-stub { + height: 60px !important; + padding-top: 0.75rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.5rem; + + .stub-btn { + white-space: nowrap; + &.alert { + color: red; + } + } +} diff --git a/src/components/elements/common/DialogManager/index.scss b/src/components/elements/common/DialogManager/index.scss index 42b706099..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; diff --git a/src/components/elements/groups/GroupMember.jsx b/src/components/elements/groups/GroupMember.jsx index 869015d00..f43591870 100644 --- a/src/components/elements/groups/GroupMember.jsx +++ b/src/components/elements/groups/GroupMember.jsx @@ -5,7 +5,7 @@ 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 { getMemberType } from 'app/utils/groups' +import { getRoleInGroup } from 'app/utils/groups' class GroupMember extends React.Component { // shouldComponentUpdate(nextProps) { @@ -54,10 +54,13 @@ class GroupMember extends React.Component { render() { const { member, username, currentGroup } = this.props const { account, member_type, joined } = member - const { creatingNew, member_list } = currentGroup + const { creatingNew, } = currentGroup - const amOwner = currentGroup.owner === username - const amModer = amOwner || (member_list && getMemberType(member_list, username) === 'moder') + let { amOwner, amModer } = getRoleInGroup(currentGroup, username) + if (creatingNew) { + amOwner = true + amModer = true + } const isMe = username === account const isOwner = currentGroup.owner === account @@ -86,7 +89,7 @@ class GroupMember extends React.Component { if ((!isMe || isBanned) && amModer) { banBtn = this.groupMember(e, member, 'banned')} /> + onClick={e => this.groupMember(e, member, isBanned ? 'member' : 'banned')} /> } } else { deleteBtn = this.groupMember(e, member, 'member')} />} - {(amOwner || isModer) && this.groupMember(e, member, 'moder')} />} {banBtn} diff --git a/src/components/elements/messages/Compose/index.jsx b/src/components/elements/messages/Compose/index.jsx index 9cbec8062..127ee32a1 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'; @@ -64,6 +65,10 @@ export default class Compose extends React.Component { 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 +105,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,7 +206,7 @@ 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); @@ -224,9 +230,11 @@ export default class Compose extends React.Component {
); } - const sendButton = selectedMessagesCount ? null : - (); @@ -234,13 +242,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) ? (
, 'GOLOS Messenger') if (!res) return + } else { + setTimeout(() => { + this.closeDropdown(e) + }, 500) } - const member_type = btnType === 'join' ? 'pending' : 'retired' + 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: () => { - let ml = [] - if (btnType === 'join') { - ml.push({ - account: username, - member_type: 'pending' - }) - } else { - ml = member_list.map(mem => { - if (mem.account === username) { - mem.member_type = member_type - } - return mem - }) - } - this.props.updateMemberList(ml) }, onError: (err, errStr) => { alert(errStr) @@ -197,9 +209,7 @@ class MessagesTopCenter extends React.Component { title={is_encrypted ? tt('msgs_group_dropdown.encrypted') : tt('msgs_group_dropdown.not_encrypted')} name={is_encrypted ? 'ionicons/lock-closed-outline' : 'ionicons/lock-open-outline'} /> - return
{ - e.stopPropagation() - }}> + return
{title} {lock} @@ -211,7 +221,7 @@ class MessagesTopCenter extends React.Component { : null} {btn} @@ -362,14 +372,11 @@ export default withRouter(connect( })); }, showGroupMembers({ group }) { - dispatch(user.actions.showGroupMembers({ group })) + dispatch(user.actions.showGroupMembers({ group: ['the_group', group] })) }, showGroupSettings({ group }) { dispatch(user.actions.showGroupSettings({ group })) }, - updateMemberList(member_list) { - dispatch(g.actions.updateMemberList({ member_list })) - }, deleteGroup: ({ owner, name, password, onSuccess, onError }) => { const opData = { diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss index c657929f0..f7ac07d60 100644 --- a/src/components/modules/MessagesTopCenter.scss +++ b/src/components/modules/MessagesTopCenter.scss @@ -53,6 +53,8 @@ padding-left: 0.75rem; padding-bottom: 0.75rem; padding-right: 0.75rem; + min-width: 200px; + .button { margin-right: 0px !important; &.margin { diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx index 7480c3335..f2cd45e1e 100644 --- a/src/components/modules/groups/GroupMembers.jsx +++ b/src/components/modules/groups/GroupMembers.jsx @@ -3,6 +3,7 @@ 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' @@ -10,17 +11,19 @@ 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 { getMemberType, getGroupMeta, getGroupTitle } from 'app/utils/groups' +import { getRoleInGroup, getGroupMeta, getGroupTitle } from 'app/utils/groups' export async function validateMembersStep(values, errors) { // nothing yet... } class GroupMembers extends React.Component { - state = {} - constructor(props) { super(props) + this.state = { + showModers: false, + showPendings: !!props.showPendings, + } } componentDidMount() { @@ -39,13 +42,33 @@ class GroupMembers extends React.Component { return members.get('loading') } - init = () => { + init = (force = false) => { const { initialized } = this.state - if (!initialized) { + if (!initialized || force) { const { currentGroup } = this.props if (currentGroup) { const group = currentGroup - this.props.fetchGroupMembers(group) + + 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 }) @@ -53,6 +76,32 @@ class GroupMembers extends React.Component { } } + 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 @@ -71,7 +120,6 @@ class GroupMembers extends React.Component { member, member_type, onSuccess: () => { - this.props.updateGroupMember(group, member, member_type) }, onError: (err, errStr) => { alert(errStr) @@ -83,6 +131,22 @@ class GroupMembers extends React.Component { } } + _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() @@ -90,6 +154,13 @@ class GroupMembers extends React.Component { 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 =
@@ -121,13 +192,8 @@ class GroupMembers extends React.Component { filterAccs.add(m.account) } - // TODO: but we should check it in diff cases - const { owner, member_list } = currentGroup - const amOwner = currentGroup.owner === username - const amModer = amOwner || (member_list && getMemberType(member_list, username) === 'moder') - mems =
-
+ {amModer ?
-
+
: null}
{mems} @@ -145,7 +211,6 @@ class GroupMembers extends React.Component { } let header - const { creatingNew } = currentGroup if (creatingNew) { header =
@@ -153,30 +218,38 @@ class GroupMembers extends React.Component {
} else { - const { name, json_metadata } = currentGroup + 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()} +
+
}
} @@ -194,12 +267,24 @@ export default connect( const username = currentUser && currentUser.get('username') const { newGroup } = ownProps - let currentGroup + let currentGroup, showPendings if (newGroup) { currentGroup = newGroup } else { - currentGroup = state.user.get('current_group') - if (currentGroup) currentGroup = currentGroup.toJS() + 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 { @@ -207,12 +292,13 @@ export default connect( username, currentGroup, group, + showPendings, } }, dispatch => ({ - fetchGroupMembers: (group) => { + fetchGroupMembers: (group, memberTypes, sortConditions) => { dispatch(g.actions.fetchGroupMembers({ - group: group.name, creatingNew: !!group.creatingNew })) + group: group.name, creatingNew: !!group.creatingNew, memberTypes, sortConditions, })) }, updateGroupMember: (group, member, member_type) => { dispatch(g.actions.updateGroupMember({ diff --git a/src/components/modules/groups/GroupMembers.scss b/src/components/modules/groups/GroupMembers.scss index 90a2b4e66..4f81660e9 100644 --- a/src/components/modules/groups/GroupMembers.scss +++ b/src/components/modules/groups/GroupMembers.scss @@ -5,6 +5,35 @@ 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; @@ -13,7 +42,7 @@ cursor: pointer; padding-top: 0.35rem; transition: all .1s ease-in; - &.selected { + &.selected:not(.ban) { cursor: auto; } } diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index 53eb61749..427617399 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -5,6 +5,7 @@ import { Map } from 'immutable' import { api, formatter } from 'golos-lib-js' import tt from 'counterpart' +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' @@ -13,7 +14,7 @@ import DropdownMenu from 'app/components/elements/DropdownMenu' import Icon from 'app/components/elements/Icon' import LoadingIndicator from 'app/components/elements/LoadingIndicator' import { showLoginDialog } from 'app/components/dialogs/LoginDialog' -import { getGroupLogo, getGroupMeta } from 'app/utils/groups' +import { getGroupLogo, getGroupMeta, getRoleInGroup } from 'app/utils/groups' class MyGroups extends React.Component { constructor(props) { @@ -69,14 +70,44 @@ class MyGroups extends React.Component { })) } + 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) => { + showGroupMembers = (e, group, show_pendings) => { e.preventDefault() - this.props.showGroupMembers({ group }) + const { name } = group + this.props.showGroupMembers({ group: name, show_pendings }) } onGoGroup = (e) => { @@ -85,7 +116,7 @@ class MyGroups extends React.Component { } _renderGroup = (group) => { - const { name, json_metadata } = group + const { name, json_metadata, pendings } = group const meta = getGroupMeta(json_metadata) @@ -97,9 +128,22 @@ class MyGroups extends React.Component { const kebabItems = [] - kebabItems.push({ link: '#', onClick: e => { - this.deleteGroup(e, group, titleShr) - }, value: tt('g.delete') }) + 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 @@ -109,19 +153,33 @@ class MyGroups extends React.Component { { e.preventDefault() + e.stopPropagation() }}> + {amPending ? : null} + {(amModer && pendings) ? : null} - + : null*/} {kebabItems.length ? : null} @@ -145,7 +203,7 @@ class MyGroups extends React.Component { {tt('my_groups_jsx.empty')} {tt('my_groups_jsx.empty2')} - {tt('my_groups_jsx.create')} + {tt('my_groups_jsx.create')}.
} else { @@ -175,6 +233,7 @@ class MyGroups extends React.Component {
{button} {groups} + {hasGroups ?
: null}
} } @@ -182,12 +241,12 @@ class MyGroups extends React.Component { export default connect( (state, ownProps) => { const currentUser = state.user.getIn(['current']) - const currentAccount = currentUser && state.global.getIn(['accounts', currentUser.get('username')]) + const username = currentUser && currentUser.get('username') const my_groups = state.global.get('my_groups') return { ...ownProps, currentUser, - currentAccount, + username, my_groups, } }, @@ -203,8 +262,8 @@ export default connect( showGroupSettings({ group }) { dispatch(user.actions.showGroupSettings({ group })) }, - showGroupMembers({ group }) { - dispatch(user.actions.showGroupMembers({ group })) + showGroupMembers({ group, show_pendings }) { + dispatch(user.actions.showGroupMembers({ group: ['my_groups', group], show_pendings })) }, deleteGroup: ({ owner, name, password, onSuccess, onError }) => { @@ -231,6 +290,35 @@ export default connect( 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 index bee27a8d5..83ac2bb76 100644 --- a/src/components/modules/groups/MyGroups.scss +++ b/src/components/modules/groups/MyGroups.scss @@ -16,6 +16,7 @@ .group-buttons { float: right; padding-top: 0.8rem; + cursor: default; .button { vertical-align: middle; margin-bottom: 0px; diff --git a/src/components/modules/messages/MessageList/index.js b/src/components/modules/messages/MessageList/index.js index ffeb5c170..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'; @@ -162,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 (
@@ -179,9 +180,13 @@ export default class MessageList extends React.Component { onCancelReply={onCancelReply} onSendMessage={onSendMessage} rightItems={[ - (showImageBtn ? : undefined), + (showImageBtn ? : undefined), (
- +
), ]} @@ -193,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/index.js b/src/components/modules/messages/Messenger/index.js index 93b2a3d44..7e4a80454 100644 --- a/src/components/modules/messages/Messenger/index.js +++ b/src/components/modules/messages/Messenger/index.js @@ -45,7 +45,7 @@ export default class Messages extends React.Component { replyingMessage, onCancelReply, onSendMessage, onButtonImageClicked, onImagePasted, selectedMessages, onMessageSelect, onPanelDeleteClick, onPanelReplyClick, onPanelEditClick, onPanelCloseClick, - composeRef + composeRef, composeStub } = this.props; const { isSmall } = this.state @@ -101,6 +101,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 9bf3c58b1..ef3f462c4 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -18,7 +18,7 @@ import DialogManager from 'app/components/elements/common/DialogManager' import AddImageDialog from 'app/components/dialogs/AddImageDialog' import ChatError from 'app/components/elements/messages/ChatError' 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' @@ -843,7 +843,7 @@ class Messages extends React.Component { ); }; - _renderMessages = ({ }) => { + _renderMessages = (messagesStub, { }) => { const { to, the_group, accounts } = this.props if (to) { @@ -855,6 +855,10 @@ class Messages extends React.Component { } } + if (messagesStub && messagesStub.ui) { + return messagesStub.ui + } + return false } @@ -932,6 +936,9 @@ class Messages extends React.Component {
); const toAcc = this.getToAcc() + const { username, the_group } = this.props + const { composeStub, msgsStub } = renderStubs(the_group, to, username) + return (
{bbc} @@ -954,7 +961,7 @@ class Messages extends React.Component { messagesTopLeft={this._renderMessagesTopLeft()} messagesTopCenter={this._renderMessagesTopCenter} messagesTopRight={this._renderMessagesTopRight} - renderMessages={this._renderMessages} + renderMessages={(...args) => this._renderMessages(msgsStub, ...args)} replyingMessage={this.state.replyingMessage} onCancelReply={this.onCancelReply} onSendMessage={this.onSendMessage} @@ -968,6 +975,7 @@ class Messages extends React.Component { onImagePasted={this.onImagePasted} onImageDropped={this.onImageDropped} composeRef={this.composeRef} + composeStub={composeStub} />) : null}
) diff --git a/src/locales/en.json b/src/locales/en.json index adc9d20ae..3f3375149 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -104,6 +104,22 @@ "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" @@ -163,9 +179,11 @@ "group_members_jsx": { "title": "Members Of ", "title2": " Group", - "check_pending": "Join Requests", + "check_pending": "Requests", "check_pending_hint": "Pending Group Join Requests", "check_banned": "Blocked", + "all": "All", + "moders": "Moderators", "member": "Member", "moder": "Moderator", "owner": "Group Owner", @@ -215,6 +233,13 @@ "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" + }, "user_saga_js": { "image_upload": { "uploading": "Uploading", @@ -277,6 +302,13 @@ "confirm": "Confirmation", "prompt": "Enter the data" }, + "plurals": { + "member_count": { + "zero": "0 members", + "one": "1 member", + "other": "%(count)s members" + } + }, "g": { "and": "and", "blog": "Blog", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index cfa696864..738f08cde 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -118,7 +118,8 @@ "pending": "Вы подали заявку на вступление.", "moder": "Вы модератор.", "owner": "Вы владелец.", - "are_you_sure_retire": "Вы уверены, что хотите покинуть группу" + "are_you_sure_retire": "Вы уверены, что хотите покинуть группу", + "joining_back_will_require_approval": "Вступить обратно вы сможете только после одобрения заявки." }, "msgs_start_panel": { "start_chat": "Начать чат", @@ -184,7 +185,9 @@ "create_more": "+ Создать еще группу", "edit": "Изменить", "login_hint_GROUP": "(удаления группы \"%(GROUP)s\")", - "members": "Участники" + "members": "Участники", + "cancel_pending": "Отменить заявку", + "are_you_sure_cancel": "Вы уверены, что хотите отказаться от вступления в группу" }, "group_members_jsx": { "title": "Участники группы ", @@ -192,6 +195,8 @@ "check_pending": "Заявки", "check_pending_hint": "Заявки на вступление в группу", "check_banned": "Забаненные", + "all": "Все", + "moders": "Модераторы", "member": "Обычный участник", "moder": "Модератор", "owner": "Владелец группы", @@ -248,6 +253,13 @@ "flags": "Флаги" } }, + "stub_jsx": { + "read_only": "Писать сообщения в этой группе могут лишь ее члены.", + "private_group": "Это закрытая группа. Чтобы видеть сообщения и общаться в ней, надо стать ее членом.", + "pending": "Вы подали заявку на вступление в группу.", + "banned": "Вы забанены в этой группе.", + "join": "Подать заявку" + }, "user_saga_js": { "image_upload": { "uploading": "Загрузка", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 232521e39..0a68b2321 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -167,7 +167,7 @@ export function* watchFetchGroupMembers() { yield takeLatest('global/FETCH_GROUP_MEMBERS', fetchGroupMembers) } -export function* fetchGroupMembers({ payload: { group, creatingNew } }) { +export function* fetchGroupMembers({ payload: { group, creatingNew, memberTypes, sortConditions } }) { try { if (creatingNew) { yield put(g.actions.receiveGroupMembers({ group, members: [], append: true })) @@ -178,7 +178,8 @@ export function* fetchGroupMembers({ payload: { group, creatingNew } }) { const members = yield call([api, api.getGroupMembersAsync], { group, - member_types: ['pending', 'member', 'moder'/*, 'banned'*/], + member_types: memberTypes, + sort_conditions: sortConditions, start_member: '', limit: 100, }) diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 80b8ea029..1942a1435 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -4,6 +4,31 @@ import { Asset } from 'golos-lib-js/lib/utils' import { processDatedGroup } 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', initialState: new Map({ @@ -308,11 +333,45 @@ export default createModule({ 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 => { @@ -320,6 +379,7 @@ export default createModule({ 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 { @@ -343,50 +403,52 @@ export default createModule({ }) return gro }) - return new_state - }, - }, - { - action: 'UPDATE_MEMBER_LIST', - reducer: (state, { payload: { member_list } }) => { - let new_state = state - const updater = (gro) => { - const mMap = {} - for (const mem of member_list) { - const { account } = mem - mMap[account] = { ...mMap[account], ...mem } - } + 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) => { - const acc = mem.get('account') - if (mMap[acc]) { - if (mMap[acc].member_type !== 'retired') { - const newMem = mem.mergeDeep(fromJS(mMap[acc])) + 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) } - delete mMap[acc] } else { newList = newList.push(mem) } }) - const addVals = Object.values(mMap) - for (const av of addVals) { - if (av.member_type !== 'retired') { - newList = newList.push(fromJS(av)) - } + 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 = new_state.update('the_group', Map(), gro => { - gro = updater(gro) - return gro - }) + new_state = updateInMyGroups(new_state, group, groupUpdater) + new_state = updateTheGroup(new_state, group, groupUpdater) return new_state }, }, diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index d543f2acc..534e3850d 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -23,43 +23,10 @@ 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.update({ - key: ['my_groups'], - notSet: List(), - updater: groups => { - const idx = groups.findIndex(i => i.get('name') === json[1].name) - if (idx === -1) { - const now = new Date().toISOString().split('.')[0] - groups = groups.insert(0, fromJS({ - owner: json[1].creator, - name: json[1].name, - json_metadata: json[1].json_metadata, - is_encrypted: json[1].is_encrypted, - privacy: json[1].privacy, - created: now, - admins: 0, - moders: 0, - members: 0, - pendings: 0, - member_list: [{ - account: json[1].creator, - group: json[1].name, - invited: json[1].creator, - joined: now, - json_metadata: '{}', - member_type: 'admin', - updated: now - }] - })) - } else { - groups = groups.update(idx, g => { - g = g.set('json_metadata', json[1].json_metadata); - return g; - }); - } - return groups - } - })) + 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 diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index 8f50ddf62..1ab53e4f6 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -146,9 +146,12 @@ export default createModule({ return state }}, { action: 'HIDE_GROUP_SETTINGS', reducer: state => state.set('show_group_settings_modal', false) }, - { action: 'SHOW_GROUP_MEMBERS', reducer: (state, { payload: { group }}) => { + { action: 'SHOW_GROUP_MEMBERS', reducer: (state, { payload: { group, show_pendings }}) => { state = state.set('show_group_members_modal', true) - state = state.set('current_group', fromJS(group)) + 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) }, diff --git a/src/utils/groups.js b/src/utils/groups.js index 3106c1fb9..a785da43d 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -1,11 +1,5 @@ import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' -const getMemberType = (member_list, username) => { - const mem = member_list.find(pgm => pgm.account === username) - const { member_type } = (mem || {}) - return member_type -} - const getGroupMeta = (json_metadata) => { let meta if (json_metadata) { @@ -37,9 +31,30 @@ const getGroupLogo = (json_metadata) => { return logo } +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 } +} + export { - getMemberType, getGroupMeta, getGroupTitle, getGroupLogo, + getMemberType, + getRoleInGroup, } From 18e641d07a694da39eb955cad58c326675e25647 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 6 Aug 2024 03:13:34 +0300 Subject: [PATCH 23/50] HF 30 - Private groups - Small fixes in stubs --- src/components/elements/Stub.jsx | 11 +++++++++-- src/components/elements/Stub.scss | 19 ++++++++++++++----- .../modules/messages/Messenger/index.js | 1 + 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/elements/Stub.jsx b/src/components/elements/Stub.jsx index b10117911..6bcc22b73 100644 --- a/src/components/elements/Stub.jsx +++ b/src/components/elements/Stub.jsx @@ -45,7 +45,9 @@ class StubInner extends React.Component {
} else { return
- {text}{btn} +
+ {text}{btn} +
} } @@ -102,7 +104,12 @@ export default Stub export const renderStubs = (the_group, to, username) => { let composeStub, msgsStub - if (!the_group || the_group.name !== to) { + if (!the_group) { + const isGroup = to && !to.startsWith('@') + if (isGroup) composeStub = { disabled: true } + return { composeStub, msgsStub} + } + if (the_group.name !== to) { return { composeStub, msgsStub} } diff --git a/src/components/elements/Stub.scss b/src/components/elements/Stub.scss index 552c0b8cf..5e29c0dea 100644 --- a/src/components/elements/Stub.scss +++ b/src/components/elements/Stub.scss @@ -4,11 +4,20 @@ 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; + } +} - .stub-btn { - white-space: nowrap; - &.alert { - color: red; - } +.stub-btn { + white-space: nowrap; + &.alert { + color: red; } } diff --git a/src/components/modules/messages/Messenger/index.js b/src/components/modules/messages/Messenger/index.js index 7e4a80454..07214d678 100644 --- a/src/components/modules/messages/Messenger/index.js +++ b/src/components/modules/messages/Messenger/index.js @@ -54,6 +54,7 @@ export default class Messages extends React.Component { Date: Sat, 17 Aug 2024 14:45:26 +0300 Subject: [PATCH 24/50] HF 30 - Private groups --- src/components/elements/Stub.jsx | 19 +++++- src/components/elements/Stub.scss | 1 + .../elements/messages/ChatError/index.jsx | 17 +++-- .../messages/ConversationListItem/index.jsx | 6 +- .../elements/messages/Donating/index.jsx | 8 +-- src/components/modules/Donate.jsx | 10 +-- src/components/pages/Messages.jsx | 64 ++++++++++++++----- src/locales/ru-RU.json | 4 +- src/redux/FetchDataSaga.js | 42 +++++++++++- src/utils/Normalizators.js | 38 +++++++++-- 10 files changed, 167 insertions(+), 42 deletions(-) diff --git a/src/components/elements/Stub.jsx b/src/components/elements/Stub.jsx index 6bcc22b73..d885d1830 100644 --- a/src/components/elements/Stub.jsx +++ b/src/components/elements/Stub.jsx @@ -2,6 +2,7 @@ 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' @@ -22,7 +23,7 @@ class StubInner extends React.Component { } render() { - const { type, banned, notMember, pending } = this.props + const { type, banned, notMember, pending, loading } = this.props const isCompose = type === 'compose' @@ -44,6 +45,13 @@ class StubInner extends React.Component { {text}{btn}
} else { + if (loading) { + return
+
+ +
+
+ } return
{text}{btn} @@ -104,9 +112,14 @@ export default Stub export const renderStubs = (the_group, to, username) => { let composeStub, msgsStub - if (!the_group) { + if (!the_group || the_group.error) { const isGroup = to && !to.startsWith('@') - if (isGroup) composeStub = { disabled: true } + if (isGroup) { + composeStub = { disabled: true } + if (the_group !== null) { // if not 404 + msgsStub = { ui: } + } + } return { composeStub, msgsStub} } if (the_group.name !== to) { diff --git a/src/components/elements/Stub.scss b/src/components/elements/Stub.scss index 5e29c0dea..2b676e94d 100644 --- a/src/components/elements/Stub.scss +++ b/src/components/elements/Stub.scss @@ -12,6 +12,7 @@ top: 50%; transform: translateY(-50%); padding: 1rem; + width: 98%; } } diff --git a/src/components/elements/messages/ChatError/index.jsx b/src/components/elements/messages/ChatError/index.jsx index 3a22ff33a..3db51ff3d 100644 --- a/src/components/elements/messages/ChatError/index.jsx +++ b/src/components/elements/messages/ChatError/index.jsx @@ -6,11 +6,20 @@ import './ChatError.scss' class ChatError extends React.Component { render() { - const { isGroup } = this.props + 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
-
-
{isGroup ? tt('msgs_chat_error.404_group') : tt('msgs_chat_error.404_acc')}
-
{tt('msgs_chat_error.404_but')}
+
{tt('msgs_chat_error.500_group')}
+
{tt('msgs_chat_error.500_load_msgs')}
+
{error}
} } diff --git a/src/components/elements/messages/ConversationListItem/index.jsx b/src/components/elements/messages/ConversationListItem/index.jsx index 6985e6858..218a48912 100644 --- a/src/components/elements/messages/ConversationListItem/index.jsx +++ b/src/components/elements/messages/ConversationListItem/index.jsx @@ -34,8 +34,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; }; 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/modules/Donate.jsx b/src/components/modules/Donate.jsx index 496ed2fe5..b9a325d1f 100644 --- a/src/components/modules/Donate.jsx +++ b/src/components/modules/Donate.jsx @@ -88,13 +88,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) => { @@ -211,7 +211,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 +220,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/pages/Messages.jsx b/src/components/pages/Messages.jsx index ef3f462c4..74363ef13 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -5,6 +5,7 @@ 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'; @@ -63,6 +64,11 @@ class Messages extends React.Component { return to } + getGroupName = () => { + const { the_group } = this.props + return the_group ? the_group.name : '' + } + markMessages() { const { messages } = this.state; if (!messages.length) return; @@ -272,7 +278,7 @@ class Messages extends React.Component { } } - componentDidUpdate(prevProps) { + async componentDidUpdate(prevProps) { if (this.props.username !== prevProps.username && this.props.username) { this.props.fetchState(this.props.to); this.setCallback(this.props.username); @@ -301,10 +307,11 @@ class Messages extends React.Component { const newContacts = contacts.size ? normalizeContacts(contacts, accounts, currentUser, this.preDecoded, this.cachedProfileImages) : this.state.contacts + const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded) this.setState({ to: this.props.to, contacts: newContacts, - messages: normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded), + messages: decoded, messagesCount: messages.size, }, () => { hideSplash() @@ -398,7 +405,7 @@ class Messages extends React.Component { onSendMessage = (message, event) => { if (!message.length) return; - const { 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']); @@ -409,6 +416,7 @@ class Messages extends React.Component { this.props.sendMessage({ senderAcc: account, memoKey: private_key, toAcc: accounts[to], + group: the_group, body: message, editInfo, type: 'text', replyingMessage: this.state.replyingMessage, notifyAbort: this.notifyAbort }) @@ -607,11 +615,12 @@ class Messages extends React.Component { if (!url) return; - const { 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], + senderAcc: account, memoKey: private_key, toAcc: (!group) && accounts[to], + group: the_group, body: url, type: 'image', meta: {width, height}, replyingMessage: this.state.replyingMessage, notifyAbort: this.notifyAbort }); @@ -848,10 +857,15 @@ class Messages extends React.Component { if (to) { const isGroup = !to.startsWith('@') - if (isGroup && the_group === null) { - return + 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 + return } } @@ -955,7 +969,10 @@ class Messages extends React.Component { contacts={this.state.searchContacts || this.state.contacts} conversationTopLeft={this._renderConversationTopLeft} conversationTopRight={this._renderConversationTopRight} - conversationLinkPattern='/@*' + conversationLinkPattern={contact => { + if (contact.kind === 'group') return '/*' + return '/@*' + }} onConversationSearch={this.onConversationSearch} messages={this.state.messages} messagesTopLeft={this._renderMessagesTopLeft()} @@ -1066,7 +1083,7 @@ export default withRouter(connect( }) ); }, - sendMessage ({ senderAcc, memoKey, toAcc, body, editInfo = undefined, type = 'text', meta = {}, replyingMessage = null, notifyAbort }) { + sendMessage: async ({ senderAcc, memoKey, toAcc, group, body, editInfo = undefined, type = 'text', meta = {}, replyingMessage = null, notifyAbort }) => { let message = { app: 'golos-messenger', version: 1, @@ -1084,20 +1101,37 @@ export default withRouter(connect( message = {...message, ...replyingMessage}; } - const data = golos.messages.encode(memoKey, toAcc.memo_key, message, editInfo ? editInfo.nonce : undefined); + let data + if (group) { + if (group.is_encrypted) { + alert('enc') + } + data = await golos.messages.encodeMsg({ group, message }) + alert(JSON.stringify(data)) + } else { + data = golos.messages.encode(memoKey, toAcc.memo_key, message, editInfo ? editInfo.nonce : undefined); + } + + const emptyPubKey = 'GLS1111111111111111111111111111111114T1Anm' const opData = { from: senderAcc.name, - to: toAcc.name, + to: toAcc ? toAcc.name : '', nonce: editInfo ? editInfo.nonce : data.nonce, - from_memo_key: senderAcc.memo_key, - to_memo_key: toAcc.memo_key, + from_memo_key: group ? emptyPubKey : senderAcc.memo_key, + to_memo_key: group ? emptyPubKey : toAcc.memo_key, checksum: data.checksum, update: editInfo ? true : false, encrypted_message: data.encrypted_message, }; - if (!editInfo) { + if (group) { + opData.extensions = [[0, { + group: group.name + }]] + } + + if (!editInfo && !group) { sendOffchainMessage(opData).catch(err => { console.error('sendOffchainMessage', err) if (notifyAbort) { diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 738f08cde..6835880f5 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -128,7 +128,9 @@ "msgs_chat_error": { "404_group": "Такой группы у нас нет", "404_acc": "Такого пользователя нет", - "404_but": "Но у нас есть много интересного..." + "404_but": "Но у нас есть много интересного...", + "500_group": "Ошибка", + "500_load_msgs": "Не удалось загрузить сообщения." }, "create_group_jsx": { "title": "Название", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 0a68b2321..1c9df610c 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -1,5 +1,5 @@ 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' @@ -30,6 +30,7 @@ export function* fetchState(location_change_action) { const state = {} state.nodeError = null state.contacts = []; + state.the_group = undefined state.messages = []; state.messages_update = '0'; state.accounts = {} @@ -58,7 +59,18 @@ export function* fetchState(location_change_action) { if (account) { accounts.add(account); - state.contacts = yield callSafe(state, [], 'getContactsAsync', [api, api.getContactsAsync], account, 'unknown', 100, 0) + const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])) + + const con = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, + call: async (loginData) => { + return await api.getContactsAsync({ + ...loginData, + owner: account, limit: 100, + }) + } + }) + alert(JSON.stringify(con)) + state.contacts = con.contacts if (hasErr) return const path = parts[1] @@ -88,6 +100,32 @@ export function* fetchState(location_change_action) { the_group = null } state.the_group = the_group + + let query = { + group: path, + } + 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) + } + if (the_group && thRes.error) { + the_group.error = thRes.error + } + if (thRes.messages) { + state.messages = thRes.messages + if (state.messages.length) { + state.messages_update = state.messages[state.messages.length - 1].nonce; + } + } } } for (let contact of state.contacts) { diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 844a63486..118cd7e10 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -3,6 +3,8 @@ import tt from 'counterpart' import { getProfileImage } from 'app/utils/NormalizeProfile'; +const { decodeMsgs } = golos.messages + function getProfileImageLazy(account, cachedProfileImages) { if (!account) return getProfileImage(null); @@ -67,10 +69,14 @@ export function normalizeContacts(contacts, accounts, currentUser, preDecoded, c return contactsCopy } -export function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { - if (to) to = to.replace('@', '') +export async function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { + let isGroup = true + if (to) { + if (to[0] === '@') isGroup = false + to = to.replace('@', '') + } - if (!to || !accounts[to]) { + if (!to || (!isGroup && !accounts[to])) { return []; } @@ -78,13 +84,33 @@ 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, + if (isGroup) { + const decoded = await decodeMsgs({ messages: messagesCopy, + for_each: (msg, i) => { + msg.id = ++id; + msg.author = msg.from; + msg.date = new Date(msg.create_date + 'Z'); + }, + on_error: (msg, i, err) => { + console.log(err) + msg.message = {body: tt_invalid_message, invalid: true} + msg.id = ++id; + msg.author = msg.from; + msg.date = new Date(msg.create_date + 'Z'); + }, + begin_idx: messagesCopy.length - 1, + end_idx: -1, + }) + return decoded + } + + const privateMemo = currentUser.getIn(['private_keys', 'memo_private']); + + let messagesCopy2 = golos.messages.decode(privateMemo, accounts[to].memo_key, messagesCopy, (msg, i, results) => { msg.id = ++id; msg.author = msg.from; From 55eb524c637f64641ba7f64afbed2ba343c23a0c Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 23 Aug 2024 12:23:32 +0300 Subject: [PATCH 25/50] HF 30 - Private messages - Block/Unblock --- src/assets/icons/clock.svg | 1 + src/assets/icons/ionicons/gift.svg | 2 +- src/assets/icons/ionicons/happy-outline.svg | 2 +- src/assets/icons/ionicons/image-outline.svg | 2 +- .../icons/ionicons/language-outline.svg | 2 +- src/assets/icons/ionicons/trash-outline.svg | 2 +- src/components/all.scss | 1 + src/components/elements/Icon.jsx | 2 +- src/components/elements/Stub.jsx | 24 +++- src/components/modules/AccountDropdown.jsx | 134 ++++++++++++++++++ src/components/modules/AccountDropdown.scss | 26 ++++ src/components/modules/MessagesTopCenter.jsx | 27 ++-- src/components/modules/MessagesTopCenter.scss | 15 ++ .../modules/groups/GroupSettings.jsx | 1 - src/components/modules/groups/MyGroups.jsx | 1 - src/components/pages/Messages.jsx | 4 +- src/locales/en.json | 14 +- src/locales/ru-RU.json | 15 +- src/redux/FetchDataSaga.js | 5 +- src/redux/GlobalReducer.js | 54 +++++-- src/redux/UserSaga.js | 3 +- src/utils/misc.js | 22 ++- 22 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 src/assets/icons/clock.svg create mode 100644 src/components/modules/AccountDropdown.jsx create mode 100644 src/components/modules/AccountDropdown.scss 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/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/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/components/all.scss b/src/components/all.scss index ba1f81f37..86a54770e 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -22,6 +22,7 @@ @import "./dialogs/LoginDialog/index"; // modules +@import './modules/AccountDropdown.scss'; @import './modules/LoginForm.scss'; @import './modules/CreateGroup.scss'; @import './modules/groups/MyGroups.scss'; diff --git a/src/components/elements/Icon.jsx b/src/components/elements/Icon.jsx index dcf3f562e..d08896d57 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -21,7 +21,7 @@ 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')], diff --git a/src/components/elements/Stub.jsx b/src/components/elements/Stub.jsx index d885d1830..7fb036eed 100644 --- a/src/components/elements/Stub.jsx +++ b/src/components/elements/Stub.jsx @@ -5,6 +5,7 @@ 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) => { @@ -23,7 +24,7 @@ class StubInner extends React.Component { } render() { - const { type, banned, notMember, pending, loading } = this.props + const { type, banned, notMember, pending, loading, blocked, blocking } = this.props const isCompose = type === 'compose' @@ -38,6 +39,10 @@ class StubInner extends React.Component { 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) { @@ -110,10 +115,23 @@ const Stub = connect( export default Stub -export const renderStubs = (the_group, to, username) => { +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) { - const isGroup = to && !to.startsWith('@') if (isGroup) { composeStub = { disabled: true } if (the_group !== null) { // if not 404 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/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index 3fb4f978d..52016cd20 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -10,9 +10,9 @@ 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 ExtLink from 'app/components/elements/ExtLink' 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' @@ -28,17 +28,17 @@ class MessagesTopCenter extends React.Component { } openDropdown = (e) => { - e.preventDefault() let isInside = false let node = e.target while (node.parentNode) { - if (node.className.includes('msgs-group-dropdown')) { + 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() } @@ -232,7 +232,6 @@ class MessagesTopCenter extends React.Component { render() { let avatar = [] let items = [] - let clickable = false const { to, toAcc, isSmall, notifyErrors, the_group } = this.props @@ -256,6 +255,7 @@ class MessagesTopCenter extends React.Component { items.push(
{checkmark}
) - clickable = true } else { items.push(
- {to}{checkmark} + } + transition={Fade} + > + {to} + + {checkmark}
) } @@ -288,7 +297,7 @@ class MessagesTopCenter extends React.Component { const secondStyle = {fontSize: '13px', fontWeight: 'normal'} if (!isGroup) { const { accounts } = this.props - const acc =accounts[toAcc] + const acc = accounts[toAcc] let lastSeen = acc && getLastSeen(acc) if (lastSeen) { items.push(
@@ -314,9 +323,7 @@ class MessagesTopCenter extends React.Component { } } - return
+ return
{avatar}
{items}
diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss index f7ac07d60..4ef63f5ad 100644 --- a/src/components/modules/MessagesTopCenter.scss +++ b/src/components/modules/MessagesTopCenter.scss @@ -30,6 +30,21 @@ } } +.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; diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx index a9d8e72ef..c60224d68 100644 --- a/src/components/modules/groups/GroupSettings.jsx +++ b/src/components/modules/groups/GroupSettings.jsx @@ -9,7 +9,6 @@ import tt from 'counterpart' import g from 'app/redux/GlobalReducer' import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' -import { session } from 'app/redux/UserSaga' import DropdownMenu from 'app/components/elements/DropdownMenu' import ExtLink from 'app/components/elements/ExtLink' import Icon from 'app/components/elements/Icon' diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index 427617399..332539866 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -9,7 +9,6 @@ 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 { session } from 'app/redux/UserSaga' import DropdownMenu from 'app/components/elements/DropdownMenu' import Icon from 'app/components/elements/Icon' import LoadingIndicator from 'app/components/elements/LoadingIndicator' diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 74363ef13..c9ce3d441 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -929,7 +929,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 = @@ -951,7 +951,7 @@ class Messages extends React.Component { const toAcc = this.getToAcc() const { username, the_group } = this.props - const { composeStub, msgsStub } = renderStubs(the_group, to, username) + const { composeStub, msgsStub } = renderStubs(the_group, to, username, accounts) return (
diff --git a/src/locales/en.json b/src/locales/en.json index 3f3375149..338748dfd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,4 +1,11 @@ { + "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.", @@ -238,7 +245,9 @@ "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" + "join": "Make Request", + "blocked": "User blocked you.", + "blocking": "You are blocked by user." }, "user_saga_js": { "image_upload": { @@ -311,6 +320,8 @@ }, "g": { "and": "and", + "block": "Block", + "blocked": "Blocked", "blog": "Blog", "cancel": "Cancel", "delete": "Delete", @@ -334,6 +345,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 6835880f5..c7d49c101 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -1,4 +1,11 @@ { + "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": "Имя аккаунта должно быть длиннее.", @@ -260,7 +267,9 @@ "private_group": "Это закрытая группа. Чтобы видеть сообщения и общаться в ней, надо стать ее членом.", "pending": "Вы подали заявку на вступление в группу.", "banned": "Вы забанены в этой группе.", - "join": "Подать заявку" + "join": "Подать заявку", + "blocked": "Пользователь заблокировал вас.", + "blocking": "Вы заблокировали пользователя." }, "user_saga_js": { "image_upload": { @@ -333,6 +342,8 @@ }, "g": { "and": "и", + "block": "Заблокировать", + "blocked": "Заблокирован(-а)", "blog": "Блог", "cancel": "Отмена", "delete": "Удалить", @@ -355,6 +366,8 @@ "rewards": "Награды", "settings": "Настройки", "sign_up": "Регистрация", + "submit": "Отправить", + "unblock": "Разблокировать", "username_does_not_exist": "Такого имени не существует", "wallet": "Кошелек", "wait": "Ждите..." diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 1c9df610c..775f1629e 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -69,7 +69,7 @@ export function* fetchState(location_change_action) { }) } }) - alert(JSON.stringify(con)) + //alert(JSON.stringify(con)) state.contacts = con.contacts if (hasErr) return @@ -134,7 +134,8 @@ export function* fetchState(location_change_action) { } 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) { diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 1942a1435..9917298c6 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -2,6 +2,7 @@ import { Map, List, fromJS, fromJSGreedy } from 'immutable'; import createModule from 'redux-modules' import { Asset } from 'golos-lib-js/lib/utils' +import { session } from 'app/redux/UserSaga' import { processDatedGroup } from 'app/utils/MessageUtils' const updateInMyGroups = (state, group, groupUpdater, groupsUpserter = mg => mg) => { @@ -50,17 +51,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 }, }, { @@ -452,5 +462,31 @@ export default createModule({ 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/UserSaga.js b/src/redux/UserSaga.js index 1bbf26b02..5f2110178 100644 --- a/src/redux/UserSaga.js +++ b/src/redux/UserSaga.js @@ -244,8 +244,9 @@ function* getAccountHandler({ payload: { usernames, resolve, reject }}) { usernames = [current.get('username')] } +alert('ac') const accounts = yield call([api, api.getAccountsAsync], usernames) - +alert('ac2') for (let account of accounts) { yield put(g.actions.receiveAccount({ account })) } diff --git a/src/utils/misc.js b/src/utils/misc.js index 3d39bc97c..b777af5d0 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -7,7 +7,27 @@ const renderPart = (part, params) => { 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, - delay + delay, + maxDate, + maxDateStr, + isBlockedByMe, + isBlockingMe, } From dd4cad4fc19f4b0b353641d06fa2af7df96eeb5f Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 23 Aug 2024 14:09:41 +0300 Subject: [PATCH 26/50] HF 30 - Private messages - fix sendMessage --- src/components/pages/Messages.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index c9ce3d441..6fea9ee97 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -1083,7 +1083,7 @@ export default withRouter(connect( }) ); }, - sendMessage: async ({ senderAcc, memoKey, toAcc, group, 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, @@ -1107,7 +1107,6 @@ export default withRouter(connect( alert('enc') } data = await golos.messages.encodeMsg({ group, message }) - alert(JSON.stringify(data)) } else { data = golos.messages.encode(memoKey, toAcc.memo_key, message, editInfo ? editInfo.nonce : undefined); } From 46c24eb9f14c11346ea50c5b3da1a220a87d5acd Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 23 Aug 2024 19:54:06 +0300 Subject: [PATCH 27/50] HF 30 - Private groups - TOP --- src/assets/icons/search.svg | 1 + src/components/all.scss | 1 + src/components/elements/Icon.jsx | 2 +- src/components/modules/Modals.jsx | 14 ++ src/components/modules/groups/GroupName.jsx | 2 +- src/components/modules/groups/MyGroups.jsx | 29 +++- src/components/modules/groups/MyGroups.scss | 17 +- src/components/modules/groups/TopGroups.jsx | 170 +++++++++++++++++++ src/components/modules/groups/TopGroups.scss | 30 ++++ src/locales/en.json | 31 +++- src/locales/ru-RU.json | 23 ++- src/redux/FetchDataSaga.js | 44 +++++ src/redux/GlobalReducer.js | 10 ++ src/redux/UserReducer.js | 3 + 14 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 src/assets/icons/search.svg create mode 100644 src/components/modules/groups/TopGroups.jsx create mode 100644 src/components/modules/groups/TopGroups.scss 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/components/all.scss b/src/components/all.scss index 86a54770e..f45c91c86 100644 --- a/src/components/all.scss +++ b/src/components/all.scss @@ -26,6 +26,7 @@ @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'; diff --git a/src/components/elements/Icon.jsx b/src/components/elements/Icon.jsx index d08896d57..4825eff24 100644 --- a/src/components/elements/Icon.jsx +++ b/src/components/elements/Icon.jsx @@ -27,7 +27,7 @@ const icons = new Map([ ['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')], diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index e5c328edd..9a3709903 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -9,6 +9,7 @@ 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' @@ -23,6 +24,7 @@ class Modals extends React.Component { 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, @@ -48,6 +50,7 @@ class Modals extends React.Component { 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, @@ -55,6 +58,7 @@ class Modals extends React.Component { hideDonate, hideCreateGroup, hideMyGroups, + hideTopGroups, hideGroupSettings, hideGroupMembers, hideAppDownload, @@ -98,6 +102,11 @@ class Modals extends React.Component { } + {show_top_groups_modal && + + + } {show_group_settings_modal && @@ -132,6 +141,7 @@ export default connect( 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'), @@ -156,6 +166,10 @@ export default connect( 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()) diff --git a/src/components/modules/groups/GroupName.jsx b/src/components/modules/groups/GroupName.jsx index a0b9d29c9..46c467025 100644 --- a/src/components/modules/groups/GroupName.jsx +++ b/src/components/modules/groups/GroupName.jsx @@ -51,7 +51,7 @@ export default class GroupName extends React.Component { } const { applyFieldValue } = this.props applyFieldValue('title', value) - let link = getSlug(value) + let link = getSlug(value).substring(0, 32) applyFieldValue('name', link) } diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index 332539866..fb4ed4174 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -32,6 +32,12 @@ class MyGroups extends React.Component { this.refetch() } + showTopGroups = (e) => { + e.preventDefault() + const { username } = this.props + this.props.showTopGroups(username) + } + createGroup = (e) => { e.preventDefault() this.props.showCreateGroup() @@ -201,9 +207,15 @@ class MyGroups extends React.Component { 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.create')} + {' ' + tt('my_groups_jsx.create2')}.
} else { hasGroups = true @@ -221,9 +233,15 @@ class MyGroups extends React.Component { let button if (hasGroups) { - button = + button =
+ + +
} return
@@ -258,6 +276,9 @@ export default connect( showCreateGroup() { dispatch(user.actions.showCreateGroup({ redirectAfter: false })) }, + showTopGroups(account) { + dispatch(user.actions.showTopGroups({ account })) + }, showGroupSettings({ group }) { dispatch(user.actions.showGroupSettings({ group })) }, diff --git a/src/components/modules/groups/MyGroups.scss b/src/components/modules/groups/MyGroups.scss index 83ac2bb76..36afe236c 100644 --- a/src/components/modules/groups/MyGroups.scss +++ b/src/components/modules/groups/MyGroups.scss @@ -13,6 +13,15 @@ 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; @@ -24,9 +33,9 @@ .DropdownMenu.show > .VerticalMenu { transform: translateX(-100%); } - .btn-title { - margin-left: 5px; - vertical-align: middle; - } + } + .btn-title { + margin-left: 5px; + vertical-align: middle; } } diff --git a/src/components/modules/groups/TopGroups.jsx b/src/components/modules/groups/TopGroups.jsx new file mode 100644 index 000000000..6fc7560aa --- /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) + 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..d2830dd32 --- /dev/null +++ b/src/components/modules/groups/TopGroups.scss @@ -0,0 +1,30 @@ +.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; + } + .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/locales/en.json b/src/locales/en.json index 338748dfd..b8faead74 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -131,6 +131,13 @@ "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/", @@ -177,11 +184,26 @@ "title": "My Groups", "empty": "You have not any groups yet. ", "empty2": "You can ", - "create": "create your own group", + "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" + "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 ", @@ -316,6 +338,11 @@ "zero": "0 members", "one": "1 member", "other": "%(count)s members" + }, + "message_count": { + "zero": "0 messages", + "one": "1 message", + "other": "%(count)s messages" } }, "g": { diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index c7d49c101..5db3838ad 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -190,14 +190,27 @@ "title": "Мои группы", "empty": "У вас пока нет групп. ", "empty2": "Вы можете ", - "create": "создать свою группу", - "create_more": "+ Создать еще группу", + "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": "", @@ -338,6 +351,11 @@ "zero": "0 участников", "one": "1 участник", "other": "%(count)s участник(-ов)" + }, + "message_count": { + "zero": "0 сообщений", + "one": "1 сообщение", + "other": "%(count)s сообщения(-й)" } }, "g": { @@ -359,6 +377,7 @@ "name": "Имя", "night_mode": "Ночной режим", "ok": "OK", + "or": "или", "refresh": "Обновить", "required": "Обязательно", "replies": "Ответы", diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 775f1629e..fa2a1f981 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -9,6 +9,7 @@ export function* fetchDataWatches () { yield fork(watchFetchState) yield fork(watchFetchUiaBalances) yield fork(watchFetchMyGroups) + yield fork(watchFetchTopGroups) yield fork(watchFetchGroupMembers) } @@ -202,6 +203,49 @@ export function* fetchMyGroups({ payload: { account } }) { } } +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) { + console.log('FTG') + 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) } diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 9917298c6..8f9cca607 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -309,6 +309,16 @@ export default createModule({ 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 diff --git a/src/redux/UserReducer.js b/src/redux/UserReducer.js index 1ab53e4f6..691307dd3 100644 --- a/src/redux/UserReducer.js +++ b/src/redux/UserReducer.js @@ -7,6 +7,7 @@ const defaultState = fromJS({ 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, @@ -140,6 +141,8 @@ export default createModule({ { 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)) From 0a1e39d56515d0963ba103d7d2e666924df90250 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 31 Aug 2024 01:15:40 +0300 Subject: [PATCH 28/50] HF 30 - Private messages --- package.json | 2 +- src/components/pages/Messages.jsx | 98 ++++++++++-------- src/utils/Normalizators.js | 166 +++++++++++++++++------------- yarn.lock | 8 +- 4 files changed, 156 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index e97df4177..dd8ed1060 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "emoji-picker-element": "^1.10.1", "formik": "./git-deps/formik/packages/formik", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.69", + "golos-lib-js": "^0.9.74", "history": "4.10.1", "immutable": "^4.0.0", "koa": "^2.13.4", diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 6fea9ee97..cee1aac71 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -278,7 +278,7 @@ class Messages extends React.Component { } } - async componentDidUpdate(prevProps) { + componentDidUpdate(prevProps) { if (this.props.username !== prevProps.username && this.props.username) { this.props.fetchState(this.props.to); this.setCallback(this.props.username); @@ -294,35 +294,38 @@ 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; + const anotherChat = this.props.to !== this.state.to; + this.setState({ + to: this.props.to, // protects from infinity loop + }); if (!this.props.checkMemo(currentUser)) { - this.setState({ - to: this.props.to, // protects from infinity loop - }); return; } - const anotherChat = this.props.to !== this.state.to; 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 - const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded) - this.setState({ - to: this.props.to, - contacts: newContacts, - messages: decoded, - messagesCount: messages.size, - }, () => { - hideSplash() - if (added) - this.markMessages2(); - setTimeout(() => { - if (anotherChat || anotherKey) { - this.focusInput(); - } - }, focusTimeout); - }) + + const updateData = async () => { + const newContacts = contacts.size ? + await normalizeContacts(contacts, accounts, currentUser, this.preDecoded, this.cachedProfileImages) : + this.state.contacts + const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded) + this.setState({ + contacts: newContacts, + messages: decoded, + messagesCount: messages.size, + }, () => { + hideSplash() + if (added) + this.markMessages2(); + setTimeout(() => { + if (anotherChat || anotherKey) { + this.focusInput(); + } + }, focusTimeout); + }) + } + updateData() } } @@ -416,7 +419,7 @@ class Messages extends React.Component { this.props.sendMessage({ senderAcc: account, memoKey: private_key, toAcc: accounts[to], - group: the_group, + group: this.isGroup() && the_group, body: message, editInfo, type: 'text', replyingMessage: this.state.replyingMessage, notifyAbort: this.notifyAbort }) @@ -619,8 +622,8 @@ class Messages extends React.Component { const to = this.getToAcc() const private_key = currentUser.getIn(['private_keys', 'memo_private']); this.props.sendMessage({ - senderAcc: account, memoKey: private_key, toAcc: (!group) && accounts[to], - group: the_group, + 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 }); @@ -852,11 +855,16 @@ class Messages extends React.Component { ); }; + isGroup = () => { + const { to } = this.props + return to && !to.startsWith('@') + } + _renderMessages = (messagesStub, { }) => { const { to, the_group, accounts } = this.props if (to) { - const isGroup = !to.startsWith('@') + const isGroup = this.isGroup() if (isGroup) { const noGroup = the_group === null const groupError = noGroup || (the_group && the_group.error) @@ -1101,28 +1109,30 @@ export default withRouter(connect( message = {...message, ...replyingMessage}; } - let data - if (group) { - if (group.is_encrypted) { - alert('enc') - } - data = await golos.messages.encodeMsg({ group, message }) - } else { - data = golos.messages.encode(memoKey, toAcc.memo_key, message, editInfo ? editInfo.nonce : undefined); + 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 emptyPubKey = 'GLS1111111111111111111111111111111114T1Anm' - const opData = { from: senderAcc.name, to: toAcc ? toAcc.name : '', - nonce: editInfo ? editInfo.nonce : data.nonce, - from_memo_key: group ? emptyPubKey : senderAcc.memo_key, - to_memo_key: group ? emptyPubKey : toAcc.memo_key, + nonce: /*editInfo ? editInfo.nonce : */data.nonce, + 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, - }; + } + alert(JSON.stringify(data.encrypted_message)) if (group) { opData.extensions = [[0, { @@ -1153,7 +1163,7 @@ export default withRouter(connect( if (err.message.includes('blocked by')) { this.showError(tt( 'messages.blocked_BY', { - BY: toAcc.name + BY: toAcc ? toAcc.name : '' } ), 10000) return @@ -1161,7 +1171,7 @@ 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 diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 118cd7e10..560fb0881 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -16,7 +16,39 @@ function getProfileImageLazy(account, cachedProfileImages) { return image; } -export function normalizeContacts(contacts, accounts, currentUser, preDecoded, cachedProfileImages) { +const cacheKey = (msg) => { + let key = [msg.nonce] + if (msg.group) { + key.push(msg.group) + key.push(msg.receive_date) + key.push(msg.from) + key.push(msg.to) + } else { + key.push(msg.receive_date) + } + key = key.join('|') + return key +} + +const saveToCache = (preDecoded, msg) => { + if (!msg.message) return false + if (msg.group && msg.decrypt_date !== msg.receive_date) return false + let key = cacheKey(msg) + preDecoded[key] = { message: msg.message } + return true +} + +const loadFromCache = (preDecoded, msg) => { + let key = cacheKey(msg) + let pd = preDecoded[key]; + if (pd) { + msg.message = pd.message + return true + } + return false +} + +export async function normalizeContacts(contacts, accounts, currentUser, preDecoded, cachedProfileImages) { if (!currentUser || !accounts) return []; @@ -24,11 +56,13 @@ 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); @@ -38,41 +72,46 @@ export function normalizeContacts(contacts, accounts, currentUser, preDecoded, c 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 (loadFromCache(preDecoded, msg)) { + return true + } + return false; + }, + for_each: (msg) => { + saveToCache(preDecoded, msg) + }, + on_error: (msg, idx, exception) => { + msg.message = { body: tt_invalid_message, invalid: true, }; + }, + begin_idx: 0, + end_idx: messages.length, + }) + } catch (ex) { + console.log(ex); } + return contactsCopy } export async function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { - let isGroup = true + let isGroup = false if (to) { - if (to[0] === '@') isGroup = false + if (to[0] !== '@') isGroup = true to = to.replace('@', '') } @@ -88,62 +127,51 @@ export async function normalizeMessages(messages, accounts, currentUser, to, pre const tt_invalid_message = tt('messages.invalid_message'); - if (isGroup) { - const decoded = await decodeMsgs({ messages: messagesCopy, - for_each: (msg, i) => { - msg.id = ++id; - msg.author = msg.from; - msg.date = new Date(msg.create_date + 'Z'); - }, - on_error: (msg, i, err) => { - console.log(err) - msg.message = {body: tt_invalid_message, invalid: true} - msg.id = ++id; - msg.author = msg.from; - msg.date = new Date(msg.create_date + 'Z'); - }, - begin_idx: messagesCopy.length - 1, - end_idx: -1, - }) - return decoded - } - + const posting = currentUser.getIn(['private_keys', 'posting_private']) const privateMemo = currentUser.getIn(['private_keys', 'memo_private']); - let messagesCopy2 = golos.messages.decode(privateMemo, accounts[to].memo_key, messagesCopy, - (msg, i, results) => { + console.time('dddm') + 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 (!isGroup) { + if (msg.to === currentAcc.name) { + if (msg.read_date.startsWith('19')) { + msg.toMark = true; + } + } else { + if (msg.read_date.startsWith('19')) { + msg.unread = true; + } } } + msg.decrypt_date = null - let pd = preDecoded[msg.nonce + '' + msg.receive_date]; - if (pd) { - msg.message = pd; - results.push(msg); - return true; + if (loadFromCache(preDecoded, msg)) { + results.push(msg) + return true } return false; }, - (msg) => { - preDecoded[msg.nonce + '' + msg.receive_date] = msg.message; + for_each: (msg, i) => { + saveToCache(preDecoded, msg) }, - (msg, i, err) => { - console.log(err); - msg.message = {body: tt_invalid_message, invalid: true}; + on_error: (msg, i, err) => { + console.error(err) + msg.message = {body: tt_invalid_message, invalid: true} }, - messagesCopy.length - 1, -1); - - return messagesCopy2; + begin_idx: messagesCopy.length - 1, + end_idx: -1, + }) + console.timeEnd('dddm') + return decoded } catch (ex) { console.log(ex); return []; diff --git a/yarn.lock b/yarn.lock index 88a3bfeec..981c20f3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5256,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.69: - version "0.9.69" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.69.tgz#d7b9d17fab1d0967b2e99923ef7c85740da7a157" - integrity sha512-6kxDJUiSj8itwMAEP8klnJSijxyZ1Xz2RVmGGh7BAMTb1WT+YDUoZJFHFv4Cldt7usHs7OAIFKv4bQQzJCpM1w== +golos-lib-js@^0.9.74: + version "0.9.74" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.74.tgz#2be3851f9168bf846453486455e30e882d1f11dd" + integrity sha512-+Wa0FULKmOE+OIiQO9bQis4q9ytgl//8F2GzSqzGfxon0IVDGA1Ij7QIkc1nrL36nN0yFTu5tAnXpue7cafECg== dependencies: abort-controller "^3.0.0" assert "^2.0.0" From be4e832484fb4266680e484dcc954365360ad739 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 2 Sep 2024 23:54:00 +0300 Subject: [PATCH 29/50] HF 30 - Private groups --- src/components/pages/Messages.jsx | 29 +++++++++++++++++++++-------- src/locales/ru-RU.json | 6 +++++- src/redux/TransactionSaga.js | 11 +++++++++++ src/redux/UserSaga.js | 2 -- src/utils/Normalizators.js | 2 +- src/utils/translateError.js | 27 ++++++++++++++++++++++++++- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index cee1aac71..046f50798 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -500,7 +500,7 @@ class Messages extends React.Component { onPanelDeleteClick = (event) => { const { messages } = this.state; - const { account, accounts, } = this.props; + const { account, accounts, the_group } = this.props; const to = this.getToAcc() // TODO: works wrong if few messages have same create_time @@ -533,6 +533,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, @@ -540,6 +548,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', { @@ -550,7 +559,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: {}, @@ -1078,15 +1089,16 @@ 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) } }) ); @@ -1125,14 +1137,14 @@ export default withRouter(connect( const opData = { from: senderAcc.name, to: toAcc ? toAcc.name : '', - nonce: /*editInfo ? editInfo.nonce : */data.nonce, + nonce: editInfo ? editInfo.nonce : data.nonce, 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, } - alert(JSON.stringify(data.encrypted_message)) + //alert(JSON.stringify(data.encrypted_message)) if (group) { opData.extensions = [[0, { @@ -1158,7 +1170,7 @@ export default withRouter(connect( json, }, successCallback: null, - errorCallback: (err) => { + errorCallback: (err, errStr) => { if (err && err.message) { if (err.message.includes('blocked by')) { this.showError(tt( @@ -1178,6 +1190,7 @@ export default withRouter(connect( } } console.error(err) + this.showError(errStr, 10000) }, })); }, diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 5db3838ad..384b12694 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -110,7 +110,10 @@ "sync_error": "Ошибка синхронизации. Для получения новых сообщений обновляйте страницу.", "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": "Вступить", @@ -374,6 +377,7 @@ "login": "Войти", "logout": "Выйти", "mentions": "Упоминания", + "modified": " (изменено)", "name": "Имя", "night_mode": "Ночной режим", "ok": "OK", diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index 534e3850d..e6e85f3dd 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -44,10 +44,21 @@ function* preBroadcast_custom_json({operation}) { updater: msgs => { const idx = msgs.findIndex(i => i.get('nonce') === json[1].nonce); if (idx === -1) { + let group = '' + const exts = json[1].extensions || [] + for (const [key, val ] of exts) { + if (key === 0) { + group = val.group + break + } + } msgs = msgs.insert(0, fromJS({ nonce: json[1].nonce, checksum: json[1].checksum, from: json[1].from, + from_memo_key: json[1].from_memo_key, + to_memo_key: json[1].to_memo_key, + group, read_date: '1970-01-01T00:00:00', create_date: new Date().toISOString().split('.')[0], receive_date: '1970-01-01T00:00:00', diff --git a/src/redux/UserSaga.js b/src/redux/UserSaga.js index 5f2110178..3b57ef347 100644 --- a/src/redux/UserSaga.js +++ b/src/redux/UserSaga.js @@ -244,9 +244,7 @@ function* getAccountHandler({ payload: { usernames, resolve, reject }}) { usernames = [current.get('username')] } -alert('ac') const accounts = yield call([api, api.getAccountsAsync], usernames) -alert('ac2') for (let account of accounts) { yield put(g.actions.receiveAccount({ account })) } diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 560fb0881..8eeef7aaf 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -164,7 +164,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to, pre saveToCache(preDecoded, msg) }, on_error: (msg, i, err) => { - console.error(err) + console.error(err, msg) msg.message = {body: tt_invalid_message, invalid: true} }, begin_idx: messagesCopy.length - 1, diff --git a/src/utils/translateError.js b/src/utils/translateError.js index ddd0c428b..92c78d421 100644 --- a/src/utils/translateError.js +++ b/src/utils/translateError.js @@ -6,9 +6,10 @@ const getErrorData = (errPayload, errName, depth = 0) => { if (depth > 50) { throw new Error('getErrorData - infinity loop detected...') } - if (errPayload === null) { + if (!errPayload) { return null } + console.error(errPayload.name) if (errPayload.name === errName) { let { stack } = errPayload stack = stack && stack[0] @@ -69,6 +70,7 @@ export function translateError(string, errPayload) { 'Account exceeded maximum allowed bandwidth per vesting share' )) { string = tt('chain_errors.exceeded_maximum_allowed_bandwidth') + return string } if (string.includes( @@ -91,11 +93,34 @@ export function translateError(string, errPayload) { } 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 } From e47e553fc9f38fc6843c9a65d3010bd9344ae9e6 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 4 Sep 2024 09:52:25 +0300 Subject: [PATCH 30/50] HF 30 - Profiling --- src/redux/FetchDataSaga.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index fa2a1f981..9fa6af7cf 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -62,6 +62,7 @@ export function* fetchState(location_change_action) { const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])) + console.time('prof: getContactsAsync') const con = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, call: async (loginData) => { return await api.getContactsAsync({ @@ -73,6 +74,7 @@ export function* fetchState(location_change_action) { //alert(JSON.stringify(con)) state.contacts = con.contacts if (hasErr) return + console.timeEnd('prof: getContactsAsync') const path = parts[1] if (path) { @@ -87,6 +89,7 @@ export function* fetchState(location_change_action) { state.messages_update = state.messages[state.messages.length - 1].nonce; } } else { + console.time('prof: getGroupsAsync') let the_group = yield callSafe(state, [], 'getGroupsAsync', [api, api.getGroupsAsync], { start_group: path, limit: 1, @@ -101,7 +104,9 @@ export function* fetchState(location_change_action) { the_group = null } state.the_group = the_group + console.timeEnd('prof: getGroupsAsync') + console.time('prof: getThreadAsync') let query = { group: path, } @@ -127,6 +132,7 @@ export function* fetchState(location_change_action) { state.messages_update = state.messages[state.messages.length - 1].nonce; } } + console.timeEnd('prof: getThreadAsync') } } for (let contact of state.contacts) { From 67201a49a7291d15d8d818eb6ef84f374a02fdf0 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 5 Sep 2024 23:17:12 +0300 Subject: [PATCH 31/50] HF 30 - Improve private groups performance --- src/components/pages/Messages.jsx | 5 ++- src/redux/FetchDataSaga.js | 7 ++++ src/utils/Normalizators.js | 60 +++++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 046f50798..fba08ba0f 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -48,7 +48,6 @@ class Messages extends React.Component { searchContacts: null, notifyErrors: 0, }; - this.preDecoded = {}; this.cachedProfileImages = {}; this.windowFocused = true; this.newMessages = 0; @@ -307,9 +306,9 @@ class Messages extends React.Component { const updateData = async () => { const newContacts = contacts.size ? - await normalizeContacts(contacts, accounts, currentUser, this.preDecoded, this.cachedProfileImages) : + await normalizeContacts(contacts, accounts, currentUser, this.cachedProfileImages) : this.state.contacts - const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to, this.preDecoded) + const decoded = await normalizeMessages(messages, accounts, currentUser, prevProps.to) this.setState({ contacts: newContacts, messages: decoded, diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 9fa6af7cf..bf1a1166a 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -3,6 +3,7 @@ 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) @@ -63,15 +64,18 @@ export function* fetchState(location_change_action) { const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])) console.time('prof: getContactsAsync') + const conCache = getSpaceInCache(null, 'contacts') 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), }) } }) //alert(JSON.stringify(con)) + console.log('procc:' + con._dec_processed) state.contacts = con.contacts if (hasErr) return console.timeEnd('prof: getContactsAsync') @@ -106,9 +110,11 @@ export function* fetchState(location_change_action) { state.the_group = the_group console.timeEnd('prof: getGroupsAsync') + const space = getSpaceInCache({ group: the_group.name }) console.time('prof: getThreadAsync') let query = { group: path, + cache: Object.keys(space), } const getThread = async (loginData) => { query = {...query, ...loginData} @@ -123,6 +129,7 @@ export function* fetchState(location_change_action) { } else { thRes = yield call(getThread) } + console.log('proc:' + thRes._dec_processed) if (the_group && thRes.error) { the_group.error = thRes.error } diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 8eeef7aaf..7b3694de5 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -16,10 +16,26 @@ function getProfileImageLazy(account, cachedProfileImages) { return image; } +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 cacheKey = (msg) => { let key = [msg.nonce] if (msg.group) { - key.push(msg.group) key.push(msg.receive_date) key.push(msg.from) key.push(msg.to) @@ -30,25 +46,39 @@ const cacheKey = (msg) => { return key } -const saveToCache = (preDecoded, msg) => { +export const saveToCache = (msg, contact = false) => { if (!msg.message) return false if (msg.group && msg.decrypt_date !== msg.receive_date) return false - let key = cacheKey(msg) - preDecoded[key] = { message: msg.message } + const space = getSpaceInCache(msg) + const key = cacheKey(msg) + space[key] = { message: msg.message } + if (contact) { + const cont = getContactsSpace(msg) + cont[key] = { message: msg.message } + } return true } -const loadFromCache = (preDecoded, msg) => { - let key = cacheKey(msg) - let pd = preDecoded[key]; +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 async function normalizeContacts(contacts, accounts, currentUser, preDecoded, cachedProfileImages) { +export async function normalizeContacts(contacts, accounts, currentUser, cachedProfileImages) { if (!currentUser || !accounts) return []; @@ -87,13 +117,13 @@ export async function normalizeContacts(contacts, accounts, currentUser, preDeco } } - if (loadFromCache(preDecoded, msg)) { + if (loadFromCache(msg, true)) { return true } return false; }, for_each: (msg) => { - saveToCache(preDecoded, msg) + saveToCache(msg, true) }, on_error: (msg, idx, exception) => { msg.message = { body: tt_invalid_message, invalid: true, }; @@ -108,7 +138,7 @@ export async function normalizeContacts(contacts, accounts, currentUser, preDeco return contactsCopy } -export async function normalizeMessages(messages, accounts, currentUser, to, preDecoded) { +export async function normalizeMessages(messages, accounts, currentUser, to) { let isGroup = false if (to) { if (to[0] !== '@') isGroup = true @@ -130,7 +160,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to, pre const posting = currentUser.getIn(['private_keys', 'posting_private']) const privateMemo = currentUser.getIn(['private_keys', 'memo_private']); - console.time('dddm') + console.log('ttt', Date.now()) const decoded = await decodeMsgs({ msgs: messagesCopy, private_memo: !isGroup && privateMemo, login: { @@ -154,14 +184,14 @@ export async function normalizeMessages(messages, accounts, currentUser, to, pre } msg.decrypt_date = null - if (loadFromCache(preDecoded, msg)) { + if (loadFromCache(msg)) { results.push(msg) return true } return false; }, for_each: (msg, i) => { - saveToCache(preDecoded, msg) + saveToCache(msg) }, on_error: (msg, i, err) => { console.error(err, msg) @@ -170,7 +200,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to, pre begin_idx: messagesCopy.length - 1, end_idx: -1, }) - console.timeEnd('dddm') + console.log('ttte', Date.now()) return decoded } catch (ex) { console.log(ex); From 51a90de01eea5ba3004732f66daff651075e49ee Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 6 Sep 2024 01:16:57 +0300 Subject: [PATCH 32/50] HF 30 - Private groups - Avatars --- .../elements/messages/Compose/index.jsx | 14 ++++++++----- .../elements/messages/Message/Message.css | 5 +++++ .../elements/messages/Message/index.jsx | 14 ++++++++++++- src/components/pages/Messages.jsx | 20 +++++++++++++++++-- src/utils/Normalizators.js | 2 +- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/elements/messages/Compose/index.jsx b/src/components/elements/messages/Compose/index.jsx index 127ee32a1..d7b20c909 100644 --- a/src/components/elements/messages/Compose/index.jsx +++ b/src/components/elements/messages/Compose/index.jsx @@ -211,11 +211,15 @@ export default class Compose extends React.Component { 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++ } } @@ -271,11 +275,11 @@ export default class Compose extends React.Component { {tt('g.cancel')} - - {(selectedMessagesCount === 1 && selectedEditablesCount === 1) ? ( : null} + {(selectedMessagesCount === 1 && selectedEditables === 1) ? () : null} diff --git a/src/components/elements/messages/Message/Message.css b/src/components/elements/messages/Message/Message.css index 1b59c724e..97828d2be 100644 --- a/src/components/elements/messages/Message/Message.css +++ b/src/components/elements/messages/Message/Message.css @@ -20,6 +20,11 @@ display: flex; } +.msgs-message .bubble-container .avatar { + width: 42px; + margin-top: 14px; +} + .msgs-message .bubble-container a { color: #007aff; text-decoration: underline; diff --git a/src/components/elements/messages/Message/index.jsx b/src/components/elements/messages/Message/index.jsx index 3f73964d9..b54dea9f2 100644 --- a/src/components/elements/messages/Message/index.jsx +++ b/src/components/elements/messages/Message/index.jsx @@ -3,6 +3,7 @@ import tt from 'counterpart'; import { Asset } from 'golos-lib-js/lib/utils' import Donating from 'app/components/elements/messages/Donating' +import Userpic from 'app/components/elements/Userpic' import { displayQuoteMsg } from 'app/utils/MessageUtils'; import { proxifyImageUrl } from 'app/utils/ProxifyUrl'; import './Message.css'; @@ -36,7 +37,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') { @@ -100,6 +101,16 @@ export default class Message extends React.Component { adds.unshift(unread) } + let avatar + if (!isMine && group) { + if (startsSequence) { + avatar = + } + avatar =
+ {avatar} +
+ } + return (
+ {avatar} {isMine ? adds : null}
this.onMessageSelect(idx, event)} title={friendlyDate + (modified ? tt('g.modified') : '')}> { quoteHeader } diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index fba08ba0f..feaf8ed47 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -27,6 +27,7 @@ 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 { getRoleInGroup } from 'app/utils/groups' import { getProfileImage, } from 'app/utils/NormalizeProfile'; import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; @@ -447,7 +448,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; @@ -456,7 +470,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) { diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 7b3694de5..9d6ff1402 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -182,7 +182,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to) { } } } - msg.decrypt_date = null + //msg.decrypt_date = null if (loadFromCache(msg)) { results.push(msg) From c5d6283951d5cb8b9ae906f42397bae5cfe287ed Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 6 Sep 2024 02:13:06 +0300 Subject: [PATCH 33/50] HF 30 - getThrentacts --- src/redux/FetchDataSaga.js | 41 +++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index bf1a1166a..4b244d188 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -63,24 +63,28 @@ export function* fetchState(location_change_action) { const posting = yield select(state => state.user.getIn(['current', 'private_keys', 'posting_private'])) - console.time('prof: getContactsAsync') + const path = parts[1] + const conCache = getSpaceInCache(null, 'contacts') - 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), - }) - } - }) - //alert(JSON.stringify(con)) - console.log('procc:' + con._dec_processed) - state.contacts = con.contacts - if (hasErr) return - console.timeEnd('prof: getContactsAsync') - const path = parts[1] + if (path.startsWith('@')) { + 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), + }) + } + }) + //alert(JSON.stringify(con)) + console.log('procc:' + con._dec_processed) + state.contacts = con.contacts + if (hasErr) return + console.timeEnd('prof: getContactsAsync') + } + if (path) { if (path.startsWith('@')) { const to = path.replace('@', ''); @@ -115,6 +119,10 @@ export function* fetchState(location_change_action) { let query = { group: path, cache: Object.keys(space), + contacts: { + owner: account, limit: 100, + cache: Object.keys(conCache), + }, } const getThread = async (loginData) => { query = {...query, ...loginData} @@ -133,6 +141,7 @@ export function* fetchState(location_change_action) { 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) { From 145c4725a1688d11365d0615bdadc7e1787734c1 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 6 Sep 2024 04:18:04 +0300 Subject: [PATCH 34/50] HF 30 - Acc cache --- src/components/elements/messages/Message/Message.css | 3 +++ src/redux/FetchDataSaga.js | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/elements/messages/Message/Message.css b/src/components/elements/messages/Message/Message.css index 97828d2be..0d4823401 100644 --- a/src/components/elements/messages/Message/Message.css +++ b/src/components/elements/messages/Message/Message.css @@ -24,6 +24,9 @@ width: 42px; margin-top: 14px; } +.msgs-message .bubble-container .avatar .Userpic { + position: static; +} .msgs-message .bubble-container a { color: #007aff; diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 4b244d188..f04fb0449 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -157,8 +157,17 @@ export function* fetchState(location_change_action) { } if (accounts.size > 0) { - let accs = yield callSafe(state, [], 'getAccountsAsync', [api, api.getAccountsAsync], Array.from(accounts), + let accs + if (window.accountsCache && window.uac) { + console.log('uac') + accs = window.accountsCache + } else { + console.time('prof: getAcc') + accs = yield callSafe(state, [], 'getAccountsAsync', [api, api.getAccountsAsync], Array.from(accounts), { current: account || '' }) + console.timeEnd('prof: getAcc') + window.accountsCache = accs + } if (hasErr) return for (let i in accs) { From 67d44389a7b81d79e65323597ab7de32632c5371 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 6 Sep 2024 05:32:16 +0300 Subject: [PATCH 35/50] HF 30 - lib --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dd8ed1060..2f7ec9aed 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "emoji-picker-element": "^1.10.1", "formik": "./git-deps/formik/packages/formik", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.74", + "golos-lib-js": "^0.9.75", "history": "4.10.1", "immutable": "^4.0.0", "koa": "^2.13.4", diff --git a/yarn.lock b/yarn.lock index 981c20f3a..1b7a63bd9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5256,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.74: - version "0.9.74" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.74.tgz#2be3851f9168bf846453486455e30e882d1f11dd" - integrity sha512-+Wa0FULKmOE+OIiQO9bQis4q9ytgl//8F2GzSqzGfxon0IVDGA1Ij7QIkc1nrL36nN0yFTu5tAnXpue7cafECg== +golos-lib-js@^0.9.75: + version "0.9.75" + resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.75.tgz#51b2f05f6c536776d5a9681f2871d4c9d3449e37" + integrity sha512-0upRVfRnCJ+MD9cMCtVCA85eWpXKTF/zg8mjhQEpfuUELFRmNQ1maDuIY/meM3VSIVgkXaoqw1ci0CHjjfP55w== dependencies: abort-controller "^3.0.0" assert "^2.0.0" From bd590feb621a60007c27803b36c98b18d9d36984 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 7 Sep 2024 01:23:06 +0300 Subject: [PATCH 36/50] HF 30 - Fix quoting, moderation editing --- src/components/pages/Messages.jsx | 22 ++++++++++++++++++---- src/redux/FetchDataSaga.js | 13 ++----------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index feaf8ed47..f09e62ef8 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -414,7 +414,7 @@ class Messages extends React.Component { let editInfo; if (this.editNonce) { - editInfo = { nonce: this.editNonce }; + editInfo = { from: this.editFrom, nonce: this.editNonce } } this.props.sendMessage({ @@ -616,6 +616,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}, @@ -1132,7 +1133,12 @@ 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}; } @@ -1151,7 +1157,7 @@ export default withRouter(connect( const opData = { from: senderAcc.name, - to: toAcc ? toAcc.name : '', + to, nonce: editInfo ? editInfo.nonce : data.nonce, from_memo_key: data.from_memo_key, to_memo_key: data.to_memo_key, @@ -1159,11 +1165,19 @@ export default withRouter(connect( update: editInfo ? true : false, encrypted_message: data.encrypted_message, } - //alert(JSON.stringify(data.encrypted_message)) + alert(JSON.stringify(opData)) if (group) { + let requester + + if (editInfo && editInfo.from !== senderAcc.name) { + opData.from = editInfo.from + requester = senderAcc.name + } + opData.extensions = [[0, { - group: group.name + group: group.name, + requester }]] } diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index f04fb0449..d931dbf7f 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -67,7 +67,7 @@ export function* fetchState(location_change_action) { const conCache = getSpaceInCache(null, 'contacts') - if (path.startsWith('@')) { + if (path.startsWith('@') || !path) { console.time('prof: getContactsAsync') const con = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, call: async (loginData) => { @@ -157,17 +157,8 @@ export function* fetchState(location_change_action) { } if (accounts.size > 0) { - let accs - if (window.accountsCache && window.uac) { - console.log('uac') - accs = window.accountsCache - } else { - console.time('prof: getAcc') - 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 || '' }) - console.timeEnd('prof: getAcc') - window.accountsCache = accs - } if (hasErr) return for (let i in accs) { From 515b8a016cd0a3d2beb48c3fe3caf6714cceb0a7 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 10 Sep 2024 03:49:07 +0300 Subject: [PATCH 37/50] HF 30 - Avatars in private groups --- .../messages/AuthorDropdown/index.jsx | 30 +++++++++++++++++++ .../elements/messages/Message/Message.css | 7 +++++ .../elements/messages/Message/index.jsx | 20 ++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/components/elements/messages/AuthorDropdown/index.jsx diff --git a/src/components/elements/messages/AuthorDropdown/index.jsx b/src/components/elements/messages/AuthorDropdown/index.jsx new file mode 100644 index 000000000..090a74227 --- /dev/null +++ b/src/components/elements/messages/AuthorDropdown/index.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import {connect} from 'react-redux' +import { withRouter } from 'react-router' +import { Link } from 'react-router-dom' +import tt from 'counterpart' + +class AuthorDropdown extends React.Component { + constructor(props) { + super(props) + this.state = { + } + } + + render() { + const { author } = this.props + return
{author}
+ } +} + +export default withRouter(connect( + (state, ownProps) => { + + return { + } + }, + dispatch => ({ + deleteGroup: ({ owner, name, password, }) => { + } + }), +)(AuthorDropdown)) diff --git a/src/components/elements/messages/Message/Message.css b/src/components/elements/messages/Message/Message.css index 0d4823401..abde29ef4 100644 --- a/src/components/elements/messages/Message/Message.css +++ b/src/components/elements/messages/Message/Message.css @@ -20,9 +20,16 @@ display: flex; } +.msgs-message .bubble-container .author { + font-size: 98%; + font-weight: bold; + color: #0078C4; + padding-bottom: 3px; +} .msgs-message .bubble-container .avatar { width: 42px; margin-top: 14px; + cursor: pointer; } .msgs-message .bubble-container .avatar .Userpic { position: static; diff --git a/src/components/elements/messages/Message/index.jsx b/src/components/elements/messages/Message/index.jsx index b54dea9f2..392f081f5 100644 --- a/src/components/elements/messages/Message/index.jsx +++ b/src/components/elements/messages/Message/index.jsx @@ -1,7 +1,10 @@ import React from 'react'; +import { Fade } from 'react-foundation-components/lib/global/fade' +import { LinkWithDropdown } from 'react-foundation-components/lib/global/dropdown' import tt from 'counterpart'; 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 { displayQuoteMsg } from 'app/utils/MessageUtils'; @@ -101,10 +104,24 @@ export default class Message extends React.Component { adds.unshift(unread) } + let author let avatar if (!isMine && group) { if (startsSequence) { - avatar = + author =
+ {from} +
+ + avatar = } + transition={Fade} + > + + } avatar =
{avatar} @@ -129,6 +146,7 @@ export default class Message extends React.Component { {avatar} {isMine ? adds : null}
this.onMessageSelect(idx, event)} title={friendlyDate + (modified ? tt('g.modified') : '')}> + {author} { quoteHeader } { content }
From 1e2ae41f5e9cdcc2fc447591bffe4547aae83d21 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 10 Sep 2024 20:34:57 +0300 Subject: [PATCH 38/50] HF 30 - Lettered avatars --- src/components/elements/Userpic.jsx | 24 +++- .../AuthorDropdown/AuthorDropdown.scss | 28 +++++ .../messages/AuthorDropdown/index.jsx | 118 +++++++++++++++++- .../LetteredAvatar/LetteredAvatar.css | 17 +++ .../messages/LetteredAvatar/colors.js | 28 +++++ .../messages/LetteredAvatar/index.jsx | 98 +++++++++++++++ .../elements/messages/Message/Message.css | 4 + .../elements/messages/Message/index.jsx | 36 +++++- src/redux/FetchDataSaga.js | 8 ++ src/redux/GlobalReducer.js | 6 + 10 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 src/components/elements/messages/AuthorDropdown/AuthorDropdown.scss create mode 100644 src/components/elements/messages/LetteredAvatar/LetteredAvatar.css create mode 100644 src/components/elements/messages/LetteredAvatar/colors.js create mode 100644 src/components/elements/messages/LetteredAvatar/index.jsx 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/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 index 090a74227..3456bd563 100644 --- a/src/components/elements/messages/AuthorDropdown/index.jsx +++ b/src/components/elements/messages/AuthorDropdown/index.jsx @@ -3,6 +3,15 @@ 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 ExtLink from 'app/components/elements/ExtLink' +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 './AuthorDropdown.scss' class AuthorDropdown extends React.Component { constructor(props) { @@ -11,20 +20,121 @@ class AuthorDropdown extends React.Component { } } + 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 } = this.props - return
{author}
+ const { author, authorAcc, the_group, account } = this.props + + let lastSeen + if (authorAcc && authorAcc.last_seen) { + lastSeen = authorAcc.last_seen + } + + 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' + 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 => ({ - deleteGroup: ({ owner, name, password, }) => { - } + 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/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..05f85bd1b --- /dev/null +++ b/src/components/elements/messages/LetteredAvatar/colors.js @@ -0,0 +1,28 @@ +export const defaultColors = [ + '#e25f51',// A + '#f26091',// B + '#bb65ca',// C + '#9572cf',// D + '#7884cd',// E + '#5b95f9',// F + '#48c2f9',// G + '#45d0e2',// H + '#48b6ac',// I + '#52bc89',// J + '#9bce5f',// K + '#d4e34a',// L + '#feda10',// M + '#f7c000',// N + '#ffa800',// O + '#ff8a60',// P + '#c2c2c2',// Q + '#8fa4af',// R + '#a2887e',// S + '#a3a3a3',// T + '#afb5e2',// U + '#b39bdd',// V + '#c2c2c2',// W + '#7cdeeb',// X + '#bcaaa4',// Y + '#add67d'// Z +] diff --git a/src/components/elements/messages/LetteredAvatar/index.jsx b/src/components/elements/messages/LetteredAvatar/index.jsx new file mode 100644 index 000000000..909cdda35 --- /dev/null +++ b/src/components/elements/messages/LetteredAvatar/index.jsx @@ -0,0 +1,98 @@ +// 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%`, + }; + 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 abde29ef4..a874a7d8b 100644 --- a/src/components/elements/messages/Message/Message.css +++ b/src/components/elements/messages/Message/Message.css @@ -26,6 +26,10 @@ 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; diff --git a/src/components/elements/messages/Message/index.jsx b/src/components/elements/messages/Message/index.jsx index 392f081f5..692b7bf39 100644 --- a/src/components/elements/messages/Message/index.jsx +++ b/src/components/elements/messages/Message/index.jsx @@ -1,7 +1,9 @@ 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 tt from 'counterpart'; +import cn from 'classnames' import { Asset } from 'golos-lib-js/lib/utils' import AuthorDropdown from 'app/components/elements/messages/AuthorDropdown' @@ -11,7 +13,7 @@ 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 { onMessageSelect = (idx, event) => { if (this.props.onMessageSelect) { const { data, selected } = this.props; @@ -107,23 +109,30 @@ export default class Message extends React.Component { let author let avatar if (!isMine && group) { + const { authorAcc } = this.props + const isBanned = authorAcc && authorAcc.member_type === 'banned' + if (startsSequence) { - author =
+ author =
{from}
avatar = } transition={Fade} > - + } - avatar =
+ + avatar =
{avatar}
} @@ -156,3 +165,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/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index d931dbf7f..13def8b32 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -119,6 +119,7 @@ export function* fetchState(location_change_action) { let query = { group: path, cache: Object.keys(space), + accounts: true, contacts: { owner: account, limit: 100, cache: Object.keys(conCache), @@ -137,6 +138,13 @@ export function* fetchState(location_change_action) { } else { thRes = yield call(getThread) } + + if (thRes.accounts) { + for (const [n, acc] of Object.entries(thRes.accounts)) { + state.accounts[n] = acc + } + } + console.log('proc:' + thRes._dec_processed) if (the_group && thRes.error) { the_group.error = thRes.error diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 8f9cca607..c9b2db3eb 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -469,6 +469,12 @@ export default createModule({ } 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 }, }, From 75f41caf8802f1807e34608dd7cf502982a047ed Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 10 Sep 2024 23:14:13 +0300 Subject: [PATCH 39/50] HF 30 - Private groups notifications --- .../messages/AuthorDropdown/index.jsx | 8 ++-- src/components/pages/Messages.jsx | 38 +++++++++++++++---- src/redux/GlobalReducer.js | 17 +++++++-- src/utils/Normalizators.js | 1 + src/utils/NormalizeProfile.js | 4 +- src/utils/NotifyApiClient.js | 25 ++++++++++++ src/utils/ServerApiClient.js | 2 +- src/utils/groups.js | 15 ++++++++ 8 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/components/elements/messages/AuthorDropdown/index.jsx b/src/components/elements/messages/AuthorDropdown/index.jsx index 3456bd563..b7253adf0 100644 --- a/src/components/elements/messages/AuthorDropdown/index.jsx +++ b/src/components/elements/messages/AuthorDropdown/index.jsx @@ -5,11 +5,11 @@ import { Link } from 'react-router-dom' import tt from 'counterpart' import cn from 'classnames' -import ExtLink from 'app/components/elements/ExtLink' 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' @@ -48,8 +48,8 @@ class AuthorDropdown extends React.Component { const { author, authorAcc, the_group, account } = this.props let lastSeen - if (authorAcc && authorAcc.last_seen) { - lastSeen = authorAcc.last_seen + if (authorAcc) { + lastSeen = getLastSeen(authorAcc) } let isModer @@ -73,7 +73,7 @@ class AuthorDropdown extends React.Component { return
- {'@' + author} + {'@' + author}
{lastSeen ?
{tt('messages.last_seen')} diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index f09e62ef8..b3f243c68 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -27,11 +27,11 @@ 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 { getRoleInGroup } from 'app/utils/groups' +import { getRoleInGroup, opGroup } from 'app/utils/groups' import { getProfileImage, } from 'app/utils/NormalizeProfile'; import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; -import { notificationSubscribe, notificationShallowUnsubscribe, notificationTake, sendOffchainMessage } from 'app/utils/NotifyApiClient'; +import { notificationSubscribe, notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage } 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' @@ -177,6 +177,27 @@ 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) + } + return false + } + async setCallback(username, removeTaskIds) { if (process.env.NO_NOTIFY) { // config-overrides.js, yarn run dev return @@ -203,13 +224,15 @@ class Messages extends React.Component { this.notifyErrorsClear(); } if (this.checkLoggedOut(username)) return + const watched = this.watchGroup(this.props.to) 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 toAcc = this.getToAcc() + let updateMessage = opGroup(op) === this.state.to || (op.from === toAcc || + op.to === toAcc) const isMine = username === op.from; if (type === 'private_message') { if (op.update) { @@ -248,7 +271,7 @@ class Messages extends React.Component { }, delay); return; } - this.notifyErrorsClear(); + if (watched) this.notifyErrorsClear(); } componentDidMount() { @@ -281,8 +304,9 @@ class Messages extends React.Component { componentDidUpdate(prevProps) { if (this.props.username !== prevProps.username && this.props.username) { 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() @@ -1165,7 +1189,7 @@ export default withRouter(connect( update: editInfo ? true : false, encrypted_message: data.encrypted_message, } - alert(JSON.stringify(opData)) + //alert(JSON.stringify(opData)) if (group) { let requester diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index c9b2db3eb..f8dc61385 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -3,6 +3,7 @@ import createModule from 'redux-modules' import { Asset } from 'golos-lib-js/lib/utils' import { session } from 'app/redux/UserSaga' +import { opGroup } from 'app/utils/groups' import { processDatedGroup } from 'app/utils/MessageUtils' const updateInMyGroups = (state, group, groupUpdater, groupsUpserter = mg => mg) => { @@ -92,6 +93,8 @@ export default createModule({ message.donates = '0.000 GOLOS' message.donates_uia = 0 } + const group = opGroup(message) + message.group = group let new_state = state; let messages_update = message.nonce; @@ -112,13 +115,19 @@ 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) + }) 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, diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 9d6ff1402..d05fe0098 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -126,6 +126,7 @@ export async function normalizeContacts(contacts, accounts, currentUser, cachedP saveToCache(msg, true) }, on_error: (msg, idx, exception) => { + console.error(exception) msg.message = { body: tt_invalid_message, invalid: true, }; }, begin_idx: 0, diff --git a/src/utils/NormalizeProfile.js b/src/utils/NormalizeProfile.js index a3c0f3ad4..6631eabee 100644 --- a/src/utils/NormalizeProfile.js +++ b/src/utils/NormalizeProfile.js @@ -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..87448d2b4 100644 --- a/src/utils/NotifyApiClient.js +++ b/src/utils/NotifyApiClient.js @@ -178,6 +178,30 @@ 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 sendOffchainMessage(op) { if (!notifyAvailable()) return; let url = notifyUrl(`/msgs/send_offchain`); @@ -211,4 +235,5 @@ if (process.env.BROWSER) { 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/groups.js b/src/utils/groups.js index a785da43d..b6f50e3ee 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -51,10 +51,25 @@ const getRoleInGroup = (group, username) => { return { amOwner, amModer, amPending, amMember, amBanned } } +const opGroup = (op) => { + let group = '' + if (!op) return group + const { extensions } = op + if (extensions) { + for (const ext of extensions) { + if (ext && ext[0] === 0) { + group = (ext[1] && ext[1].group) || group + } + } + } + return group +} + export { getGroupMeta, getGroupTitle, getGroupLogo, getMemberType, getRoleInGroup, + opGroup, } From abd8681ff2a70199d8c3dd3a93326cef6ae6fc24 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Fri, 13 Sep 2024 08:01:36 +0300 Subject: [PATCH 40/50] HF 30 - Private groups - Account avatars, mini-accounts --- src/assets/images/group.png | Bin 0 -> 3242 bytes src/components/elements/Stub.jsx | 18 +++--- .../elements/common/AccountName/index.jsx | 6 ++ .../messages/ConversationListItem/index.jsx | 16 ++++- src/components/modules/MessagesTopCenter.jsx | 4 +- src/components/modules/MessagesTopCenter.scss | 1 + .../modules/groups/GroupMembers.jsx | 8 +++ .../modules/groups/GroupSettings.jsx | 2 +- src/components/modules/groups/MyGroups.jsx | 2 +- src/components/modules/groups/MyGroups.scss | 1 + src/components/modules/groups/TopGroups.jsx | 2 +- src/components/modules/groups/TopGroups.scss | 3 + .../messages/ConversationList/index.jsx | 3 +- .../modules/messages/Messenger/index.js | 3 +- src/components/pages/Messages.jsx | 22 +++++-- src/redux/FetchDataSaga.js | 61 +++++++++++------- src/redux/GlobalReducer.js | 10 +++ src/utils/Normalizators.js | 38 +++++++---- src/utils/NormalizeProfile.js | 4 +- src/utils/groups.js | 6 +- 20 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 src/assets/images/group.png diff --git a/src/assets/images/group.png b/src/assets/images/group.png new file mode 100644 index 0000000000000000000000000000000000000000..cf6af912e140924a30020ed05539b4ae2b7e3d8d GIT binary patch literal 3242 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 { } const { privacy } = the_group - if (privacy !== 'public_group') { - const { amBanned, amMember, amModer, amPending } = getRoleInGroup(the_group, username) - const notMember = !amModer && !amMember - if (amBanned || notMember) { - composeStub = { ui: } + if (privacy === 'private_group') { + composeStub = { disabled: true } + msgsStub = { ui: } - if (privacy === 'private_group') { - composeStub = { disabled: true } - msgsStub = { ui: } - } } } diff --git a/src/components/elements/common/AccountName/index.jsx b/src/components/elements/common/AccountName/index.jsx index 8f0e6c701..fa9e578ec 100644 --- a/src/components/elements/common/AccountName/index.jsx +++ b/src/components/elements/common/AccountName/index.jsx @@ -15,6 +15,11 @@ class AccountName extends React.Component { this.ref = React.createRef() } + onAccountsLoad = (accs) => { + const { onAccountsLoad } = this.props + onAccountsLoad(accs) + } + lookupAccounts = async (value) => { try { //await new Promise(resolve => setTimeout(resolve, 2000)) @@ -24,6 +29,7 @@ class AccountName extends React.Component { filter_accounts: [...filterAccounts], }) const accs = await api.lookupAccountNamesAsync(accNames) + this.onAccountsLoad(accs) return accs } catch (err) { console.error(err) diff --git a/src/components/elements/messages/ConversationListItem/index.jsx b/src/components/elements/messages/ConversationListItem/index.jsx index 218a48912..fe2b33ecd 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, }; } @@ -50,6 +51,17 @@ 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; @@ -89,7 +101,7 @@ export default class ConversationListItem extends React.Component { return ( - {''} + {this._renderAvatar()}

{contact}{checkmark}

{last_body && truncate(last_body, {length: 30})} diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index 52016cd20..0726b4d02 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -99,7 +99,7 @@ class MessagesTopCenter extends React.Component { const { name, json_metadata, privacy, is_encrypted, owner, member_list, members, moders } = the_group - const logo = getGroupLogo(json_metadata) + const logo = getGroupLogo(json_metadata).url const meta = getGroupMeta(json_metadata) const title = getGroupTitle(meta, name) @@ -247,7 +247,7 @@ class MessagesTopCenter extends React.Component { if (isGroup) { if (the_group) { const { json_metadata } = the_group - const logo = getGroupLogo(json_metadata) + const logo = getGroupLogo(json_metadata).url avatar.push(
) diff --git a/src/components/modules/MessagesTopCenter.scss b/src/components/modules/MessagesTopCenter.scss index 4ef63f5ad..7cc3d9712 100644 --- a/src/components/modules/MessagesTopCenter.scss +++ b/src/components/modules/MessagesTopCenter.scss @@ -14,6 +14,7 @@ img { width: 32px; height: 32px; + border-radius: 50%; } } } diff --git a/src/components/modules/groups/GroupMembers.jsx b/src/components/modules/groups/GroupMembers.jsx index f2cd45e1e..633b88108 100644 --- a/src/components/modules/groups/GroupMembers.jsx +++ b/src/components/modules/groups/GroupMembers.jsx @@ -199,6 +199,9 @@ class GroupMembers extends React.Component { placeholder={tt('create_group_jsx.add_member')} onChange={this.onAddAccount} filterAccounts={filterAccs} + onAccountsLoad={(accs) => { + this.props.receiveAccounts(accs) + }} />
: null} @@ -300,6 +303,11 @@ export default connect( 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, })) diff --git a/src/components/modules/groups/GroupSettings.jsx b/src/components/modules/groups/GroupSettings.jsx index c60224d68..1509851c7 100644 --- a/src/components/modules/groups/GroupSettings.jsx +++ b/src/components/modules/groups/GroupSettings.jsx @@ -16,7 +16,7 @@ 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 { getGroupLogo, getGroupMeta, getGroupTitle } from 'app/utils/groups' +import { getGroupMeta, getGroupTitle } from 'app/utils/groups' import { proxifyImageUrlWithStrip } from 'app/utils/ProxifyUrl' class GroupSettings extends React.Component { diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index fb4ed4174..a9a588e65 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -46,7 +46,7 @@ class MyGroups extends React.Component { _renderGroupLogo = (group, meta) => { const { json_metadata } = group - const logo = getGroupLogo(json_metadata) + const logo = getGroupLogo(json_metadata).url return diff --git a/src/components/modules/groups/MyGroups.scss b/src/components/modules/groups/MyGroups.scss index 36afe236c..b7634153f 100644 --- a/src/components/modules/groups/MyGroups.scss +++ b/src/components/modules/groups/MyGroups.scss @@ -4,6 +4,7 @@ img { width: 48px; height: 48px; + border-radius: 50%; } } .group-title { diff --git a/src/components/modules/groups/TopGroups.jsx b/src/components/modules/groups/TopGroups.jsx index 6fc7560aa..bfab39653 100644 --- a/src/components/modules/groups/TopGroups.jsx +++ b/src/components/modules/groups/TopGroups.jsx @@ -34,7 +34,7 @@ class TopGroups extends React.Component { _renderGroupLogo = (group, meta) => { const { json_metadata } = group - const logo = getGroupLogo(json_metadata) + const logo = getGroupLogo(json_metadata).url return diff --git a/src/components/modules/groups/TopGroups.scss b/src/components/modules/groups/TopGroups.scss index d2830dd32..a0f5e79b7 100644 --- a/src/components/modules/groups/TopGroups.scss +++ b/src/components/modules/groups/TopGroups.scss @@ -14,6 +14,9 @@ .group-logo { width: 13%; vertical-align: middle; + img { + border-radius: 50%; + } } .group-stats { 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/Messenger/index.js b/src/components/modules/messages/Messenger/index.js index 07214d678..755666b20 100644 --- a/src/components/modules/messages/Messenger/index.js +++ b/src/components/modules/messages/Messenger/index.js @@ -39,7 +39,7 @@ export default class Messages extends React.Component { render() { const { account, to, toNew, - contacts, conversationTopLeft, conversationTopRight, conversationLinkPattern, + contacts, conversationTopLeft, conversationTopRight, conversationLinkPattern, renderConversationAvatar, onConversationSearch, onConversationSelect, messagesTopLeft, messagesTopCenter, messagesTopRight, messages, renderMessages, replyingMessage, onCancelReply, onSendMessage, @@ -71,6 +71,7 @@ export default class Messages extends React.Component { conversations={contacts} conversationSelected={to} conversationLinkPattern={conversationLinkPattern} + renderConversationAvatar={renderConversationAvatar} onConversationSearch={onConversationSearch} onConversationSelect={onConversationSelect} /> diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index b3f243c68..554a8720c 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -18,6 +18,7 @@ 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 { renderStubs } from 'app/components/elements/Stub' import Userpic from 'app/components/elements/Userpic' @@ -231,8 +232,9 @@ class Messages extends React.Component { const takeResult = await notificationTake(username, removeTaskIds, (type, op, timestamp, task_id) => { const isDonate = type === 'donate' const toAcc = this.getToAcc() - let updateMessage = opGroup(op) === this.state.to || (op.from === toAcc || - op.to === toAcc) + 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) { @@ -378,12 +380,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 }]; } @@ -1032,6 +1038,14 @@ class Messages extends React.Component { 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()} @@ -1205,7 +1219,7 @@ export default withRouter(connect( }]] } - if (!editInfo && !group) { + if (!editInfo) { sendOffchainMessage(opData).catch(err => { console.error('sendOffchainMessage', err) if (notifyAbort) { diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 13def8b32..9e4eae007 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -22,22 +22,36 @@ 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.the_group = undefined - state.messages = []; - state.messages_update = '0'; - state.accounts = {} - state.assets = {} - state.groups = {} + if (isFirstRendering || fake) { + state.contacts = []; + state.the_group = undefined + state.messages = []; + state.messages_update = '0'; + state.accounts = {} + state.assets = {} + state.groups = {} + } let hasErr = false @@ -59,8 +73,6 @@ 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'])) const path = parts[1] @@ -75,6 +87,8 @@ export function* fetchState(location_change_action) { ...loginData, owner: account, limit: 100, cache: Object.keys(conCache), + accounts: true, + relations: false, }) } }) @@ -83,19 +97,28 @@ export function* fetchState(location_change_action) { state.contacts = con.contacts if (hasErr) return console.timeEnd('prof: getContactsAsync') + + addMiniAccounts(state, con.accounts) } if (path) { if (path.startsWith('@')) { const to = path.replace('@', ''); - accounts.add(to); - state.messages = yield callSafe(state, [], 'getThreadAsync', [api, api.getThreadAsync], account, to, {}); + 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 { console.time('prof: getGroupsAsync') let the_group = yield callSafe(state, [], 'getGroupsAsync', [api, api.getGroupsAsync], { @@ -123,6 +146,7 @@ export function* fetchState(location_change_action) { contacts: { owner: account, limit: 100, cache: Object.keys(conCache), + relations: false, }, } const getThread = async (loginData) => { @@ -139,11 +163,7 @@ export function* fetchState(location_change_action) { thRes = yield call(getThread) } - if (thRes.accounts) { - for (const [n, acc] of Object.entries(thRes.accounts)) { - state.accounts[n] = acc - } - } + addMiniAccounts(state, thRes.accounts) console.log('proc:' + thRes._dec_processed) if (the_group && thRes.error) { @@ -159,9 +179,6 @@ export function* fetchState(location_change_action) { console.timeEnd('prof: getThreadAsync') } } - for (let contact of state.contacts) { - accounts.add(contact.contact); - } } if (accounts.size > 0) { @@ -243,7 +260,6 @@ export function* fetchTopGroups({ payload: { account } }) { let start_group = '' for (let page = 1; page <= 3; ++page) { - console.log('FTG') if (page > 1) { groupsWithoutMe.pop() } @@ -295,6 +311,7 @@ export function* fetchGroupMembers({ payload: { group, creatingNew, memberTypes, sort_conditions: sortConditions, start_member: '', limit: 100, + accounts: true, }) yield put(g.actions.receiveGroupMembers({ group, members })) diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index f8dc61385..0d415b832 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -359,6 +359,16 @@ export default createModule({ }) 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 }, }, diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index d05fe0098..a3509b014 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -1,18 +1,22 @@ import golos from 'golos-lib-js' import tt from 'counterpart' +import { getGroupLogo } from 'app/utils/groups' import { getProfileImage } from 'app/utils/NormalizeProfile'; const { decodeMsgs } = golos.messages -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; +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; } @@ -46,12 +50,14 @@ const cacheKey = (msg) => { return key } -export const saveToCache = (msg, contact = false) => { +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 space = getSpaceInCache(msg) const key = cacheKey(msg) - space[key] = { message: msg.message } + if (general) { + const space = getSpaceInCache(msg) + space[key] = { message: msg.message } + } if (contact) { const cont = getContactsSpace(msg) cont[key] = { message: msg.message } @@ -95,7 +101,14 @@ export async function normalizeContacts(contacts, accounts, currentUser, cachedP 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: '', }; @@ -128,6 +141,7 @@ export async function normalizeContacts(contacts, accounts, currentUser, cachedP 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, diff --git a/src/utils/NormalizeProfile.js b/src/utils/NormalizeProfile.js index 6631eabee..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 } } /** diff --git a/src/utils/groups.js b/src/utils/groups.js index b6f50e3ee..7a7fd3485 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -22,13 +22,15 @@ 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/user.png') + logo = require('app/assets/images/group.png') + isDefault = true } - return logo + return {url: logo, isDefault } } const getMemberType = (member_list, username) => { From 8e98fff5c13c9a2df3b0abb37659d93e17c429ac Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sun, 22 Sep 2024 04:21:48 +0300 Subject: [PATCH 41/50] HF 30 - Private groups - WebSocket, memo key not required, fixes --- package.json | 2 +- .../elements/messages/Compose/index.jsx | 18 ++ src/components/modules/Modals.jsx | 26 ++- src/components/pages/Messages.jsx | 161 ++++++++++-------- src/utils/NotifyApiClient.js | 156 ++++++++++++++++- 5 files changed, 278 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index 2f7ec9aed..a1d54ed33 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "scripts": { "preinstall": "node git-install.js", "cordova": "cordova", - "dev": "react-app-rewired start", + "dev": "cross-env HTTPS=true react-app-rewired start", "dev:server": "nodemon server", "build": "react-app-rewired build", "prod": "NODE_ENV=production node server/index.js", diff --git a/src/components/elements/messages/Compose/index.jsx b/src/components/elements/messages/Compose/index.jsx index d7b20c909..0be80a774 100644 --- a/src/components/elements/messages/Compose/index.jsx +++ b/src/components/elements/messages/Compose/index.jsx @@ -8,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) { @@ -57,12 +69,18 @@ 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(); diff --git a/src/components/modules/Modals.jsx b/src/components/modules/Modals.jsx index 9a3709903..f4bbcba82 100644 --- a/src/components/modules/Modals.jsx +++ b/src/components/modules/Modals.jsx @@ -2,6 +2,7 @@ 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'; @@ -81,11 +82,23 @@ class Modals extends React.Component { 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 && - + onHide={doHideLogin} show={show_login_modal}> + } {show_donate_modal && @@ -132,7 +145,7 @@ 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'); @@ -150,8 +163,11 @@ export default connect( } }, dispatch => ({ - hideLogin: e => { + hideLogin: (e, goBack) => { if (e) e.preventDefault(); + if (goBack) { + goBack() + } dispatch(user.actions.hideLogin()) }, hideDonate: e => { @@ -187,4 +203,4 @@ export default connect( removeNotification: (key) => dispatch({type: 'REMOVE_NOTIFICATION', payload: {key}}), }) -)(Modals) +)(Modals)) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 554a8720c..3abcf53c7 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -32,7 +32,8 @@ import { getRoleInGroup, opGroup } from 'app/utils/groups' import { getProfileImage, } from 'app/utils/NormalizeProfile'; import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; -import { notificationSubscribe, notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage } from 'app/utils/NotifyApiClient'; +import { notificationSubscribe, notificationSubscribeWs, notifyWsPing, + notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage } 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' @@ -210,70 +211,69 @@ 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 if (this.nonce !== op.nonce) { + this.props.messaged(op, timestamp, updateMessage, isMine); + this.nonce = op.nonce + if (!isMine && !this.windowFocused) { + this.flashMessage(); + } + } + } 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) 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 - const watched = this.watchGroup(this.props.to) - 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' - 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 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) } - }, 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) } - if (watched) this.notifyErrorsClear(); + ping(true) + this.watchGroup(this.props.to) } componentDidMount() { @@ -289,12 +289,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(() => { @@ -304,7 +306,11 @@ 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 (loggedNow) { this.props.fetchState(this.props.to); this.setCallback(this.props.username) } else if (this.props.to !== this.state.to) { @@ -324,9 +330,9 @@ class Messages extends React.Component { this.setState({ to: this.props.to, // protects from infinity loop }); - if (!this.props.checkMemo(currentUser)) { + /*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; @@ -345,9 +351,7 @@ class Messages extends React.Component { if (added) this.markMessages2(); setTimeout(() => { - if (anotherChat || anotherKey) { - this.focusInput(); - } + this.focusInput(); }, focusTimeout); }) } @@ -912,6 +916,11 @@ class Messages extends React.Component { ); }; + isChat = () => { + const { to } = this.props + return to && to.startsWith('@') + } + isGroup = () => { const { to } = this.props return to && !to.startsWith('@') @@ -1113,7 +1122,7 @@ export default withRouter(connect( dispatch => ({ loginUser: () => dispatch(user.actions.usernamePasswordLogin()), - checkMemo: (currentUser) => { + checkAuth: (currentUser, memoNeed) => { if (!currentUser) { hideSplash() dispatch(user.actions.showLogin({ @@ -1121,13 +1130,15 @@ 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; }, diff --git a/src/utils/NotifyApiClient.js b/src/utils/NotifyApiClient.js index 87448d2b4..4df4ebca5 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 notifyWsAvailable = () => { + return notifyAvailable() && $GLS_Config.notify_service.host_ws +} + const 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($GLS_Config.notify_service.host_ws) + 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 (!notifyWsAvailable()) return null + 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; @@ -202,6 +326,29 @@ export async function queueWatch(account, group, sidKey = '__subscriber_id') { } } +export async function queueWatchWs(account, group, sidKey = '__subscriber_id') { + if (!notifyWsAvailable()) 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`); @@ -218,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); } @@ -228,12 +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 -} +//} From 04c404105192cb066294fb67fe0cba7c0ee35e3a Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 2 Oct 2024 19:43:29 +0300 Subject: [PATCH 42/50] HF 30 - Private groups - Do not decrypt own messages --- src/components/pages/Messages.jsx | 4 +++- src/redux/FetchDataSaga.js | 4 ++-- src/redux/TransactionSaga.js | 17 +++----------- src/utils/Normalizators.js | 39 +++++++++++++++++++++++++++++-- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index 3abcf53c7..c503fbaef 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -30,7 +30,7 @@ import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' import { getRoleInGroup, opGroup } from 'app/utils/groups' import { getProfileImage, } from 'app/utils/NormalizeProfile'; -import { normalizeContacts, normalizeMessages } from 'app/utils/Normalizators'; +import { normalizeContacts, normalizeMessages, cacheMyOwnMsg } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; import { notificationSubscribe, notificationSubscribeWs, notifyWsPing, notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage } from 'app/utils/NotifyApiClient'; @@ -1230,6 +1230,8 @@ export default withRouter(connect( }]] } + cacheMyOwnMsg(opData, group, message) + if (!editInfo) { sendOffchainMessage(opData).catch(err => { console.error('sendOffchainMessage', err) diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index 9e4eae007..a5a556f52 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -137,11 +137,11 @@ export function* fetchState(location_change_action) { state.the_group = the_group console.timeEnd('prof: getGroupsAsync') - const space = getSpaceInCache({ group: the_group.name }) + const space = the_group && getSpaceInCache({ group: the_group.name }) console.time('prof: getThreadAsync') let query = { group: path, - cache: Object.keys(space), + cache: space ? Object.keys(space) : [], accounts: true, contacts: { owner: account, limit: 100, diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index e6e85f3dd..a97727c83 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -4,6 +4,7 @@ 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() { @@ -52,20 +53,8 @@ function* preBroadcast_custom_json({operation}) { break } } - msgs = msgs.insert(0, fromJS({ - nonce: json[1].nonce, - checksum: json[1].checksum, - from: json[1].from, - from_memo_key: json[1].from_memo_key, - to_memo_key: json[1].to_memo_key, - group, - 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 - })) + const newMsg = messageOpToObject(json[1], group) + msgs = msgs.insert(0, fromJS(newMsg)) } else { messages_update = json[1].nonce; msgs = msgs.update(idx, msg => { diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index a3509b014..40a92385c 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -37,14 +37,20 @@ 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(msg.receive_date) + key.push(recDate) key.push(msg.from) key.push(msg.to) } else { - key.push(msg.receive_date) + key.push(recDate) } key = key.join('|') return key @@ -84,6 +90,31 @@ const loadFromCache = (msg, contact = false) => { return false } +export function messageOpToObject(op, group) { + 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 + } + 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 []; @@ -130,9 +161,11 @@ export async function normalizeContacts(contacts, accounts, currentUser, cachedP } } + console.log('FCC1') if (loadFromCache(msg, true)) { return true } + console.log('FCC2') return false; }, for_each: (msg) => { @@ -199,10 +232,12 @@ export async function normalizeMessages(messages, accounts, currentUser, to) { } //msg.decrypt_date = null + console.log('FC1') if (loadFromCache(msg)) { results.push(msg) return true } + console.log('FC2') return false; }, for_each: (msg, i) => { From ca9e4ac77599c3079b3960cce573ba0c3263c275 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 8 Oct 2024 23:50:18 +0300 Subject: [PATCH 43/50] HF 30 - Private groups - Load avatars of new messages --- src/redux/GlobalReducer.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 0d415b832..4c7529666 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -151,6 +151,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; }, }, From 78d0b01aea80831751060e3cb7c9efd846706fc1 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Wed, 9 Oct 2024 02:37:40 +0300 Subject: [PATCH 44/50] HF 30 - Private groups - Clear/remove chat notification --- src/redux/GlobalReducer.js | 53 +++++++++++++++++++++++++++++--------- src/utils/MessageUtils.js | 25 +++++++++++++++++- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index 4c7529666..a17099b0b 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -4,7 +4,7 @@ import { Asset } from 'golos-lib-js/lib/utils' import { session } from 'app/redux/UserSaga' import { opGroup } from 'app/utils/groups' -import { processDatedGroup } from 'app/utils/MessageUtils' +import { processDatedGroup, opDeleteContact } from 'app/utils/MessageUtils' const updateInMyGroups = (state, group, groupUpdater, groupsUpserter = mg => mg) => { state = state.update('my_groups', null, mg => { @@ -203,8 +203,11 @@ export default createModule({ new_state = new_state.updateIn(['messages'], List(), messages => { - return processDatedGroup(message, messages, (msg, idx) => { - return msg.set('read_date', timestamp); + return processDatedGroup(message, messages, (messages, idx) => { + let msg = messages.get(idx) + msg = msg.set('read_date', timestamp) + const msgs = messages.set(idx, msg) + return { msgs } }); }); } @@ -243,15 +246,41 @@ 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, (messages, idx) => { + let msg = messages.get(idx) + const msgs = messages.delete(idx) + return { msgs, 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; }, diff --git a/src/utils/MessageUtils.js b/src/utils/MessageUtils.js index 144a89bf6..8089fac00 100644 --- a/src/utils/MessageUtils.js +++ b/src/utils/MessageUtils.js @@ -26,9 +26,32 @@ export function processDatedGroup(group, messages, for_each) { break; } if (inRange) { - messages = messages.set(idx, for_each(msg, idx)); + const updated = for_each(messages, idx) + if (updated) { + const { msgs, fixIdx } = updated + if (msgs) { + messages = msgs + } + if (fixIdx !== undefined) { + idx = fixIdx + } + } } } } 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 +} From 63cf192816f4672ea9948bcd9ac5c15b579319be Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 10 Oct 2024 03:37:11 +0300 Subject: [PATCH 45/50] HF 30 - Private groups - Lettered Avatar Color --- .../messages/LetteredAvatar/colors.js | 44 +++++++++---------- .../messages/LetteredAvatar/index.jsx | 3 +- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/components/elements/messages/LetteredAvatar/colors.js b/src/components/elements/messages/LetteredAvatar/colors.js index 05f85bd1b..411b0727f 100644 --- a/src/components/elements/messages/LetteredAvatar/colors.js +++ b/src/components/elements/messages/LetteredAvatar/colors.js @@ -1,28 +1,28 @@ export const defaultColors = [ - '#e25f51',// A - '#f26091',// B - '#bb65ca',// C - '#9572cf',// D - '#7884cd',// E - '#5b95f9',// F - '#48c2f9',// G - '#45d0e2',// H - '#48b6ac',// I - '#52bc89',// J - '#9bce5f',// K - '#d4e34a',// L - '#feda10',// M - '#f7c000',// N - '#ffa800',// O - '#ff8a60',// P - '#c2c2c2',// Q + '#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',// S - '#a3a3a3',// T + //'#a2887e',// brown + '#a3a3a3',// gray '#afb5e2',// U '#b39bdd',// V - '#c2c2c2',// W + //'#c2c2c2',// brown '#7cdeeb',// X - '#bcaaa4',// Y - '#add67d'// Z + '#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 index 909cdda35..9ad914a1d 100644 --- a/src/components/elements/messages/LetteredAvatar/index.jsx +++ b/src/components/elements/messages/LetteredAvatar/index.jsx @@ -68,6 +68,7 @@ class LetteredAvatar extends Component { lineHeight: `${size}px`, borderRadius: `${radius || radius === 0 ? radius : size}px`, fontSize: `100%`, + fontWeight: 550, }; return (
-
{initials}
+
{initials}
); } From 168ee08bdb0ff27647082e7d27b4ddfcb5d100ee Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 10 Oct 2024 22:33:16 +0300 Subject: [PATCH 46/50] HF 30 - Private groups - Fix donates --- src/components/modules/Donate.jsx | 15 ++++++++++----- src/utils/groups.js | 9 ++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/modules/Donate.jsx b/src/components/modules/Donate.jsx index b9a325d1f..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) } } @@ -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}
diff --git a/src/utils/groups.js b/src/utils/groups.js index 7a7fd3485..fd2878aed 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -56,7 +56,7 @@ const getRoleInGroup = (group, username) => { const opGroup = (op) => { let group = '' if (!op) return group - const { extensions } = op + const { extensions, memo } = op if (extensions) { for (const ext of extensions) { if (ext && ext[0] === 0) { @@ -64,6 +64,13 @@ const opGroup = (op) => { } } } + if (group) return group + if (memo) { // donate + const { target } = memo + if (target && target.group) { + return target.group + } + } return group } From 2682be6e3117414488e3faba7963d87cff1ae165 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Thu, 17 Oct 2024 10:32:41 +0300 Subject: [PATCH 47/50] HF 30 - Private groups - Mentions, replies, fix mark messages --- package.json | 4 +- .../messages/AuthorDropdown/index.jsx | 4 +- .../ConversationListItem.css | 5 + .../messages/ConversationListItem/index.jsx | 25 ++- .../elements/messages/Message/Message.css | 8 + .../elements/messages/Message/index.jsx | 26 ++- .../modules/groups/GroupMembers.jsx | 3 + src/components/modules/groups/MyGroups.jsx | 5 + src/components/pages/Messages.jsx | 151 +++++++++++++----- src/locales/en.json | 10 ++ src/locales/ru-RU.json | 10 ++ src/redux/FetchDataSaga.js | 23 +-- src/redux/GlobalReducer.js | 46 ++++-- src/redux/TransactionSaga.js | 4 +- src/utils/MessageUtils.js | 26 ++- src/utils/Normalizators.js | 38 +++-- src/utils/groups.js | 16 +- src/utils/mentions.js | 18 +++ yarn.lock | 8 +- 19 files changed, 327 insertions(+), 103 deletions(-) create mode 100644 src/utils/mentions.js diff --git a/package.json b/package.json index a1d54ed33..347170cb5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "emoji-picker-element": "^1.10.1", "formik": "./git-deps/formik/packages/formik", "git-rev-sync": "^3.0.2", - "golos-lib-js": "^0.9.75", + "golos-lib-js": "^0.9.76", "history": "4.10.1", "immutable": "^4.0.0", "koa": "^2.13.4", @@ -73,7 +73,7 @@ "scripts": { "preinstall": "node git-install.js", "cordova": "cordova", - "dev": "cross-env HTTPS=true 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/src/components/elements/messages/AuthorDropdown/index.jsx b/src/components/elements/messages/AuthorDropdown/index.jsx index b7253adf0..10d046744 100644 --- a/src/components/elements/messages/AuthorDropdown/index.jsx +++ b/src/components/elements/messages/AuthorDropdown/index.jsx @@ -61,10 +61,12 @@ class AuthorDropdown extends React.Component { let banBtn if (isModer) { const isBanned = authorAcc && authorAcc.member_type === 'banned' + const isOwner = the_group && the_group.owner === author banBtn =
} diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index a9a588e65..f818bf340 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -12,6 +12,7 @@ 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' @@ -244,6 +245,8 @@ class MyGroups extends React.Component {
} + const { username } = this.props + return

{tt('my_groups_jsx.title')}

@@ -251,6 +254,8 @@ class MyGroups extends React.Component { {button} {groups} {hasGroups ?
: null} + {username ? : null}
} } diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index c503fbaef..aa6608a67 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -29,6 +29,7 @@ import g from 'app/redux/GlobalReducer' import transaction from 'app/redux/TransactionReducer' import user from 'app/redux/UserReducer' 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'; @@ -53,7 +54,7 @@ class Messages extends React.Component { }; this.cachedProfileImages = {}; this.windowFocused = true; - this.newMessages = 0; + this.newMessages = {} if (process.env.MOBILE_APP) { this.stopService() } @@ -71,21 +72,80 @@ class Messages extends React.Component { return the_group ? the_group.name : '' } - markMessages() { - const { messages } = this.state; - if (!messages.length) return; + scrollToMention = () => { + const { username } = this.props + const { messages } = this.state + //alert('scrollToMention ' + messages.length) + let nonce + for (const msg of messages) { + console.log(msg.read_date) + 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() - let OPERATIONS = golos.messages.makeDatedGroups(messages, (message_object, idx) => { - return message_object.toMark && !message_object._offchain; + const isGroup = this.isGroup() + + 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', @@ -93,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'); @@ -220,18 +281,20 @@ class Messages extends React.Component { //alert(scope + ' ' + type + op +' ' + timestamp) const isDonate = type === 'donate' const toAcc = this.getToAcc() - const group = opGroup(op) + 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 if (this.nonce !== op.nonce) { - this.props.messaged(op, timestamp, updateMessage, isMine); - this.nonce = op.nonce - if (!isMine && !this.windowFocused) { - this.flashMessage(); + } 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') { @@ -348,6 +411,9 @@ class Messages extends React.Component { messagesCount: messages.size, }, () => { hideSplash() + if (this.props.fetched !== prevProps.fetched && this.isGroup()) { + this.scrollToMention() + } if (added) this.markMessages2(); setTimeout(() => { @@ -357,6 +423,7 @@ class Messages extends React.Component { } updateData() } + this.markMessages2() } componentWillUnmount() { @@ -808,7 +875,7 @@ class Messages extends React.Component {
- +
@@ -864,7 +931,7 @@ class Messages extends React.Component { } let user_menu = [ - {link: '#', onClick: openMyGroups, icon: 'voters', value: tt('g.groups') + (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: }, @@ -906,7 +973,7 @@ class Messages extends React.Component {
- +
{!isSmall ?
@@ -953,11 +1020,11 @@ class Messages extends React.Component { handleFocusChange = isFocused => { this.windowFocused = isFocused; if (!isFocused) { - if (this.newMessages) { + if (Object.keys(this.newMessages).length) { flash(); } } else { - this.newMessages = 0; + this.newMessages = {} unflash(); } } @@ -1088,6 +1155,7 @@ 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']) @@ -1116,7 +1184,8 @@ export default withRouter(connect( accounts: accounts ? accounts.toJS() : {}, username, locale, - nodeError + nodeError, + fetched } }, dispatch => ({ @@ -1191,6 +1260,11 @@ export default withRouter(connect( message = {...message, ...replyingMessage}; } + let mentions = [] + if (group) { + mentions = parseMentions(message) + } + let data = null try { data = await golos.messages.encodeMsg({ group, @@ -1214,7 +1288,6 @@ export default withRouter(connect( update: editInfo ? true : false, encrypted_message: data.encrypted_message, } - //alert(JSON.stringify(opData)) if (group) { let requester @@ -1226,9 +1299,11 @@ export default withRouter(connect( opData.extensions = [[0, { group: group.name, - requester + requester, + mentions }]] } + //alert(JSON.stringify(opData)) cacheMyOwnMsg(opData, group, message) @@ -1252,10 +1327,14 @@ export default withRouter(connect( successCallback: null, 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 ? toAcc.name : '' + BY: blocker } ), 10000) return @@ -1274,8 +1353,8 @@ export default withRouter(connect( }, })); }, - 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/locales/en.json b/src/locales/en.json index b8faead74..55d6d22e2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -339,10 +339,20 @@ "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": { diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 384b12694..950087b87 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -355,10 +355,20 @@ "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": { diff --git a/src/redux/FetchDataSaga.js b/src/redux/FetchDataSaga.js index a5a556f52..e27de9470 100644 --- a/src/redux/FetchDataSaga.js +++ b/src/redux/FetchDataSaga.js @@ -43,6 +43,7 @@ export function* fetchState(location_change_action) { const state = {} state.nodeError = null + state.fetched = '' if (isFirstRendering || fake) { state.contacts = []; state.the_group = undefined @@ -80,7 +81,7 @@ export function* fetchState(location_change_action) { const conCache = getSpaceInCache(null, 'contacts') if (path.startsWith('@') || !path) { - console.time('prof: getContactsAsync') + if (window._perfo) console.time('prof: getContactsAsync') const con = yield call([auth, 'withNodeLogin'], { account, keys: { posting }, call: async (loginData) => { return await api.getContactsAsync({ @@ -92,11 +93,10 @@ export function* fetchState(location_change_action) { }) } }) - //alert(JSON.stringify(con)) - console.log('procc:' + con._dec_processed) + if (window._perfo) console.log('procc:' + con._dec_processed) state.contacts = con.contacts if (hasErr) return - console.timeEnd('prof: getContactsAsync') + if (window._perfo) console.timeEnd('prof: getContactsAsync') addMiniAccounts(state, con.accounts) } @@ -120,7 +120,7 @@ export function* fetchState(location_change_action) { addMiniAccounts(state, mess.accounts) } else { - console.time('prof: getGroupsAsync') + if (window._perfo) console.time('prof: getGroupsAsync') let the_group = yield callSafe(state, [], 'getGroupsAsync', [api, api.getGroupsAsync], { start_group: path, limit: 1, @@ -135,10 +135,10 @@ export function* fetchState(location_change_action) { the_group = null } state.the_group = the_group - console.timeEnd('prof: getGroupsAsync') + if (window._perfo) console.timeEnd('prof: getGroupsAsync') const space = the_group && getSpaceInCache({ group: the_group.name }) - console.time('prof: getThreadAsync') + if (window._perfo) console.time('prof: getThreadAsync') let query = { group: path, cache: space ? Object.keys(space) : [], @@ -165,7 +165,7 @@ export function* fetchState(location_change_action) { addMiniAccounts(state, thRes.accounts) - console.log('proc:' + thRes._dec_processed) + if (window._perfo) console.log('proc:' + thRes._dec_processed) if (the_group && thRes.error) { the_group.error = thRes.error } @@ -176,7 +176,9 @@ export function* fetchState(location_change_action) { state.messages_update = state.messages[state.messages.length - 1].nonce; } } - console.timeEnd('prof: getThreadAsync') + if (window._perfo) console.timeEnd('prof: getThreadAsync') + + state.fetched = path } } } @@ -243,6 +245,9 @@ export function* fetchMyGroups({ payload: { account } }) { } }) groups = [...groupsOwn, ...groups] + groups.sort((a, b) => { + return b.pendings - a.pendings + }) yield put(g.actions.receiveMyGroups({ groups })) } catch (err) { diff --git a/src/redux/GlobalReducer.js b/src/redux/GlobalReducer.js index a17099b0b..3e421ce00 100644 --- a/src/redux/GlobalReducer.js +++ b/src/redux/GlobalReducer.js @@ -84,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; @@ -93,8 +93,10 @@ export default createModule({ message.donates = '0.000 GOLOS' message.donates_uia = 0 } - const group = opGroup(message) + 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; @@ -123,6 +125,14 @@ export default createModule({ (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 = group || (isMine ? message.to : message.from) contacts = contacts.insert(0, fromJS({ @@ -130,16 +140,20 @@ export default createModule({ 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 }); @@ -199,15 +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, (messages, idx) => { - let msg = messages.get(idx) + return processDatedGroup(message, messages, (msg, idx) => { msg = msg.set('read_date', timestamp) - const msgs = messages.set(idx, msg) - return { msgs } + return { updated: msg } }); }); } @@ -215,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 @@ -229,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; }); } @@ -260,10 +276,8 @@ export default createModule({ new_state = new_state.updateIn(['messages'], List(), messages => { - return processDatedGroup(message, messages, (messages, idx) => { - let msg = messages.get(idx) - const msgs = messages.delete(idx) - return { msgs, fixIdx: idx - 1 } + return processDatedGroup(message, messages, (msg, idx) => { + return { updated: null, fixIdx: idx - 1 } }); }) } diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index a97727c83..7a8410b48 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -46,14 +46,16 @@ function* preBroadcast_custom_json({operation}) { const idx = msgs.findIndex(i => i.get('nonce') === json[1].nonce); if (idx === -1) { 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) + const newMsg = messageOpToObject(json[1], group, mentions) msgs = msgs.insert(0, fromJS(newMsg)) } else { messages_update = json[1].nonce; diff --git a/src/utils/MessageUtils.js b/src/utils/MessageUtils.js index 8089fac00..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,15 +34,19 @@ export function processDatedGroup(group, messages, for_each) { break; } if (inRange) { - const updated = for_each(messages, idx) - if (updated) { - const { msgs, fixIdx } = updated - if (msgs) { - messages = msgs + 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) } } } diff --git a/src/utils/Normalizators.js b/src/utils/Normalizators.js index 40a92385c..8c9eea862 100644 --- a/src/utils/Normalizators.js +++ b/src/utils/Normalizators.js @@ -13,7 +13,7 @@ function getProfileImageLazy(contact, account, cachedProfileImages) { let cached = cachedProfileImages[contact.contact]; if (cached && now - cached.time < 60*1000) return cached.image - console.log('getProfileImageLazy', contact.contact) + //console.log('getProfileImageLazy', contact.contact) const image = contact.kind === 'group' ? getGroupLogo(contact.object_meta) : getProfileImage(account) cachedProfileImages[contact.contact] = { image, time: now } @@ -90,7 +90,7 @@ const loadFromCache = (msg, contact = false) => { return false } -export function messageOpToObject(op, group) { +export function messageOpToObject(op, group, mentions = []) { const obj = { nonce: op.nonce, checksum: op.checksum, @@ -103,7 +103,8 @@ export function messageOpToObject(op, group) { receive_date: zeroDate, encrypted_message: op.encrypted_message, donates: '0.000 GOLOS', - donates_uia: 0 + donates_uia: 0, + mentions, } return obj } @@ -161,11 +162,11 @@ export async function normalizeContacts(contacts, accounts, currentUser, cachedP } } - console.log('FCC1') + if (window._perfo) console.log('FCC1') if (loadFromCache(msg, true)) { return true } - console.log('FCC2') + if (window._perfo) console.log('FCC2') return false; }, for_each: (msg) => { @@ -208,7 +209,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to) { const posting = currentUser.getIn(['private_keys', 'posting_private']) const privateMemo = currentUser.getIn(['private_keys', 'memo_private']); - console.log('ttt', Date.now()) + if (window._perfo) console.log('ttt', Date.now()) const decoded = await decodeMsgs({ msgs: messagesCopy, private_memo: !isGroup && privateMemo, login: { @@ -219,25 +220,32 @@ export async function normalizeMessages(messages, accounts, currentUser, to) { msg.author = msg.from; msg.date = new Date(msg.create_date + 'Z'); - if (!isGroup) { - if (msg.to === currentAcc.name) { - if (msg.read_date.startsWith('19')) { - msg.toMark = true; + if (msg.read_date.startsWith('19')) { + if (!isGroup) { + if (msg.to === currentAcc.name) { + msg.toMark = true + } else { + msg.unread = true } } else { - if (msg.read_date.startsWith('19')) { - msg.unread = true; + 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 - console.log('FC1') + if (window._perfo) console.log('FC1') if (loadFromCache(msg)) { results.push(msg) return true } - console.log('FC2') + if (window._perfo) console.log('FC2') return false; }, for_each: (msg, i) => { @@ -250,7 +258,7 @@ export async function normalizeMessages(messages, accounts, currentUser, to) { begin_idx: messagesCopy.length - 1, end_idx: -1, }) - console.log('ttte', Date.now()) + if (window._perfo) console.log('ttte', Date.now()) return decoded } catch (ex) { console.log(ex); diff --git a/src/utils/groups.js b/src/utils/groups.js index fd2878aed..b99994d98 100644 --- a/src/utils/groups.js +++ b/src/utils/groups.js @@ -55,23 +55,27 @@ const getRoleInGroup = (group, username) => { const opGroup = (op) => { let group = '' - if (!op) return 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) { - group = (ext[1] && ext[1].group) || group + 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 + if (group) return { group, requester, mentions } if (memo) { // donate const { target } = memo if (target && target.group) { - return target.group + group = target.group } } - return group + return { group, requester, mentions } } export { 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/yarn.lock b/yarn.lock index 1b7a63bd9..84575d8e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5256,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.75: - version "0.9.75" - resolved "https://registry.yarnpkg.com/golos-lib-js/-/golos-lib-js-0.9.75.tgz#51b2f05f6c536776d5a9681f2871d4c9d3449e37" - integrity sha512-0upRVfRnCJ+MD9cMCtVCA85eWpXKTF/zg8mjhQEpfuUELFRmNQ1maDuIY/meM3VSIVgkXaoqw1ci0CHjjfP55w== +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" From eda61e6c14a4f7a1459e4e178a090f0ed10f22df Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Tue, 22 Oct 2024 00:43:38 +0300 Subject: [PATCH 48/50] HF 30 - Fix apps (desktop, Android) --- config/mobile.json | 3 +- src/components/elements/VerticalMenu.scss | 4 +++ .../elements/groups/GroupMember.jsx | 5 ++- src/components/modules/CreateGroup.jsx | 16 +++++---- src/components/modules/MessagesTopCenter.jsx | 34 ++++++++++++++++--- src/components/modules/groups/MyGroups.jsx | 23 +++++++++---- src/components/modules/groups/MyGroups.scss | 5 +++ src/components/pages/Messages.jsx | 31 +++++++++++------ src/components/pages/app/AppSettings.jsx | 13 +++++++ src/locales/en.json | 9 +++-- src/locales/ru-RU.json | 6 +++- src/redux/TransactionSaga.js | 1 + src/redux/UserSaga.js | 13 +++++++ src/utils/NotifyApiClient.js | 12 +++---- src/utils/app/SplashUtils.js | 11 +++--- src/utils/initConfig.js | 5 ++- 16 files changed, 148 insertions(+), 43 deletions(-) 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/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/groups/GroupMember.jsx b/src/components/elements/groups/GroupMember.jsx index f43591870..30a7855d6 100644 --- a/src/components/elements/groups/GroupMember.jsx +++ b/src/components/elements/groups/GroupMember.jsx @@ -6,6 +6,7 @@ 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) { @@ -96,6 +97,8 @@ class GroupMember extends React.Component { onClick={e => this.groupMember(e, member, 'retired')} /> } + const isSmall = isScreenSmall() + return @@ -106,7 +109,7 @@ class GroupMember extends React.Component { - {!creatingNew && } + {!isSmall && !creatingNew && } {isOwner && { return { - name: tt('create_group_jsx.step_name'), - logo: tt('create_group_jsx.step_logo'), - members: tt('create_group_jsx.step_members'), - final: tt('create_group_jsx.step_create') -} } +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() { diff --git a/src/components/modules/MessagesTopCenter.jsx b/src/components/modules/MessagesTopCenter.jsx index 0726b4d02..a4744ff6d 100644 --- a/src/components/modules/MessagesTopCenter.jsx +++ b/src/components/modules/MessagesTopCenter.jsx @@ -229,6 +229,31 @@ class MessagesTopCenter extends React.Component {
} + 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 = [] @@ -257,7 +282,7 @@ class MessagesTopCenter extends React.Component { closeOnClickOutside dropdownClassName="GroupDropdown" dropdownPosition="bottom" - dropdownAlignment="center" + dropdownAlignment="right" dropdownContent={this._renderGroupDropdown()} transition={Fade} > @@ -282,12 +307,13 @@ class MessagesTopCenter extends React.Component { } if (notifyErrors >= 30) { + const { refreshing } = this.state items.push(
{isSmall ? - + {tt('messages.sync_error_short')} - { e.preventDefault(); this.props.fetchState(this.props.to) }}> - {tt('g.refresh').toLowerCase()}. + + {refreshing ? '...' : tt('g.refresh')}. : {tt('messages.sync_error')} diff --git a/src/components/modules/groups/MyGroups.jsx b/src/components/modules/groups/MyGroups.jsx index f818bf340..9de07e978 100644 --- a/src/components/modules/groups/MyGroups.jsx +++ b/src/components/modules/groups/MyGroups.jsx @@ -4,6 +4,7 @@ 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' @@ -15,6 +16,7 @@ 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) { @@ -126,10 +128,13 @@ class MyGroups extends React.Component { const meta = getGroupMeta(json_metadata) + const isSmall = isScreenSmall() + + const maxLength = isSmall ? 15 : 20 let title = meta.title || name let titleShr = title - if (titleShr.length > 20) { - titleShr = titleShr.substring(0, 17) + '...' + if (titleShr.length > maxLength) { + titleShr = titleShr.substring(0, maxLength - 3) + '...' } const kebabItems = [] @@ -161,7 +166,9 @@ class MyGroups extends React.Component { e.preventDefault() e.stopPropagation() }}> - {amPending ? : null} - diff --git a/src/components/modules/groups/MyGroups.scss b/src/components/modules/groups/MyGroups.scss index b7634153f..d0cfc5749 100644 --- a/src/components/modules/groups/MyGroups.scss +++ b/src/components/modules/groups/MyGroups.scss @@ -39,4 +39,9 @@ margin-left: 5px; vertical-align: middle; } + .button.icon-only { + .btn-title { + display: none; + } + } } diff --git a/src/components/pages/Messages.jsx b/src/components/pages/Messages.jsx index aa6608a67..6b0c40241 100644 --- a/src/components/pages/Messages.jsx +++ b/src/components/pages/Messages.jsx @@ -34,7 +34,7 @@ import { getProfileImage, } from 'app/utils/NormalizeProfile'; import { normalizeContacts, normalizeMessages, cacheMyOwnMsg } from 'app/utils/Normalizators'; import { fitToPreview } from 'app/utils/ImageUtils'; import { notificationSubscribe, notificationSubscribeWs, notifyWsPing, - notificationShallowUnsubscribe, notificationTake, queueWatch, sendOffchainMessage } from 'app/utils/NotifyApiClient'; + 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' @@ -44,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: [], @@ -78,7 +79,6 @@ class Messages extends React.Component { //alert('scrollToMention ' + messages.length) let nonce for (const msg of messages) { - console.log(msg.read_date) if (msg.to === username && msg.read_date && msg.read_date.startsWith('1970')) { nonce = msg.nonce break @@ -182,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) => { @@ -256,7 +261,7 @@ class Messages extends React.Component { return true } catch (err) { console.error('watchGroup - ', to, err) - this.notifyErrorsInc(30) + this.notifyErrorsInc(30, err, {watchGroup: notifyUrl()}) } return false } @@ -309,7 +314,7 @@ class Messages extends React.Component { console.log('WSS:', subscribed) } catch (ex) { console.error('notificationSubscribe', ex) - this.notifyErrorsInc(15) + this.notifyErrorsInc(15, ex, {subscribeWs: notifyWsHost()}) setTimeout(() => { this.setCallback(username) }, 5000) @@ -330,12 +335,11 @@ class Messages extends React.Component { this.notifyErrorsClear() } catch (err) { console.error('Notify ping failed', err) - this.notifyErrorsInc(10) + this.notifyErrorsInc(10, err, {wsPing: notifyWsHost()}) } } setTimeout(ping, 10000) } - ping(true) this.watchGroup(this.props.to) } @@ -373,6 +377,9 @@ class Messages extends React.Component { 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) @@ -896,15 +903,17 @@ class Messages extends React.Component { }; _renderMessagesTopCenter = ({ isSmall }) => { - const { to } = this.props + const { fetchState, to } = this.props const toAcc = this.getToAcc() - const { notifyErrors } = this.state + const { notifyErrors, } = this.state return }; @@ -966,7 +975,7 @@ class Messages extends React.Component { return (} >
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/locales/en.json b/src/locales/en.json index 55d6d22e2..3f0dbb4e7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -107,7 +107,7 @@ "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." }, @@ -156,8 +156,11 @@ "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 ", @@ -313,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 ", @@ -374,6 +378,7 @@ "name": "Name", "night_mode": "Night Mode", "ok": "OK", + "or": "or", "refresh": "Refresh", "required": "Required", "replies": "Replies", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 950087b87..910190bc4 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -108,7 +108,7 @@ "new_message5": " новых сообщений", "invalid_message": "(Это сообщение не может быть отображено в Golos Messenger)", "sync_error": "Ошибка синхронизации. Для получения новых сообщений обновляйте страницу.", - "sync_error_short": "Ошибка синхронизации. Для получения новых сообщений нажимайте ", + "sync_error_short": "Ошибка синхронизации. ", "blocked_BY": "Вы заблокированы пользователем @%(BY)s.", "do_not_bother_BY": "@%(BY)s просит пользователей с низкой репутацией не беспокоить.", "too_low_gp": "Не хватает Силы Голоса. Для участия в группах нужно не менее ", @@ -162,8 +162,11 @@ "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 символа.", @@ -330,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. Возможно, это проблемы с интернетом. Попробуйте ", diff --git a/src/redux/TransactionSaga.js b/src/redux/TransactionSaga.js index 7a8410b48..5c4ccc8ac 100644 --- a/src/redux/TransactionSaga.js +++ b/src/redux/TransactionSaga.js @@ -57,6 +57,7 @@ function* preBroadcast_custom_json({operation}) { } 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 => { diff --git a/src/redux/UserSaga.js b/src/redux/UserSaga.js index 3b57ef347..b2fa49b8f 100644 --- a/src/redux/UserSaga.js +++ b/src/redux/UserSaga.js @@ -134,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 @@ -148,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); @@ -156,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) } @@ -174,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); } diff --git a/src/utils/NotifyApiClient.js b/src/utils/NotifyApiClient.js index 4df4ebca5..602892e90 100644 --- a/src/utils/NotifyApiClient.js +++ b/src/utils/NotifyApiClient.js @@ -14,13 +14,13 @@ const notifyAvailable = () => { && $GLS_Config.notify_service && $GLS_Config.notify_service.host; }; -const notifyWsAvailable = () => { +export function notifyWsHost() { return notifyAvailable() && $GLS_Config.notify_service.host_ws } -const notifyUrl = (pathname) => { +export function notifyUrl(pathname = '') { return new URL(pathname, window.$GLS_Config.notify_service.host).toString(); -}; +} function notifySession() { return localStorage.getItem('X-Session') @@ -50,7 +50,7 @@ async function connectNotifyWs() { window.notifyWs.close() } await new Promise((resolve, reject) => { - const notifyWs = new WebSocket($GLS_Config.notify_service.host_ws) + const notifyWs = new WebSocket(notifyWsHost()) window.notifyWs = notifyWs const timeout = setTimeout(() => { @@ -211,7 +211,7 @@ export async function notificationSubscribe(account, scopes = 'message,donate_ms } export async function notificationSubscribeWs(account, callback, scopes = 'message,donate_msgs', sidKey = '__subscriber_id') { - if (!notifyWsAvailable()) return null + 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', { @@ -327,7 +327,7 @@ export async function queueWatch(account, group, sidKey = '__subscriber_id') { } export async function queueWatchWs(account, group, sidKey = '__subscriber_id') { - if (!notifyWsAvailable()) return null + if (!notifyWsHost()) return null const xSession = notifySession() return await new Promise(async (resolve, reject) => { await notifyWsSend('queues/subscribe', { 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/initConfig.js b/src/utils/initConfig.js index 290276805..ab252c8dc 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) From f375ebd5458c84d444953928a819b655021bfd83 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Sat, 16 Nov 2024 07:38:18 +0300 Subject: [PATCH 49/50] Mobile app - Fix updates --- src/utils/app/UpdateUtils.js | 9 +++++++-- src/utils/initConfig.js | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) 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/initConfig.js b/src/utils/initConfig.js index ab252c8dc..2dce1ebd2 100644 --- a/src/utils/initConfig.js +++ b/src/utils/initConfig.js @@ -41,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() } From 37cbeebbecb74da54d2a4042940f42994322cc84 Mon Sep 17 00:00:00 2001 From: 1aerostorm Date: Mon, 25 Nov 2024 18:56:36 +0300 Subject: [PATCH 50/50] Mobile app - New icons --- res/icon-hdpi.png | Bin 18102 -> 7938 bytes res/icon-ldpi.png | Bin 15620 -> 4975 bytes res/icon-mdpi.png | Bin 14900 -> 5962 bytes res/icon-xhdpi.png | Bin 17760 -> 11975 bytes res/icon-xxhdpi.png | Bin 26906 -> 20212 bytes res/icon-xxxhdpi.png | Bin 33867 -> 27090 bytes 6 files changed, 0 insertions(+), 0 deletions(-) diff --git a/res/icon-hdpi.png b/res/icon-hdpi.png index ef8a85f0e01abae9ccf9f56481b8fef91bc68507..bb81391579c6f7c7e3e54d89d4dae7218383d6d9 100644 GIT binary patch literal 7938 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 literal 15620 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 diff --git a/res/icon-mdpi.png b/res/icon-mdpi.png index 7bbfea954f57803e6e0eba048dcf678dae3cc601..c239e0c6467d380ba635f0efd946c7a5a004ddcc 100644 GIT binary patch literal 5962 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 baSvvVzF-czJ2!kfEryeoRFbF>GYbBH8JP&@ diff --git a/res/icon-xhdpi.png b/res/icon-xhdpi.png index 56ba338cd558741741ea6ef25bb8779199817bf1..661b0f70906ff15373ac2a0df72d4c33d72128ae 100644 GIT binary patch literal 11975 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 J%gsq~{{x4X=CuF- literal 17760 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 JPDHLkV1mvyCu0Br diff --git a/res/icon-xxxhdpi.png b/res/icon-xxxhdpi.png index 636f95d71223c1871ec695cfba1481b04f084df5..d4075083cc865d57815f13eef4def12b39a21996 100644 GIT binary patch literal 27090 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