mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 09:52:26 +01:00
Connected indicator info popup (#8293)
* Add popover for informing user about the connected status indicator * Ensure user only sees connected status info popover once * Default connectedStatusPopoverHasBeenShown to true and set it to false in a migration * Add unit test for migration 42 * Initialize AppStateController if it does not exist in migration 42 * Update connect indicator popup locale text * Code cleanup for connected-indicator-info-popup * Code cleanup for connected-indicator-info-popup
This commit is contained in:
parent
f2f70342e2
commit
01985b2cff
@ -22,6 +22,9 @@
|
|||||||
"disconnectAccountConfirmationDescription": {
|
"disconnectAccountConfirmationDescription": {
|
||||||
"message": "Are you sure you want to disconnect? You may lose site functionality."
|
"message": "Are you sure you want to disconnect? You may lose site functionality."
|
||||||
},
|
},
|
||||||
|
"dismiss": {
|
||||||
|
"message": "Dismiss"
|
||||||
|
},
|
||||||
"migrateSai": {
|
"migrateSai": {
|
||||||
"message": "A message from Maker: The new Multi-Collateral Dai token has been released. Your old tokens are now called Sai. Please upgrade your Sai tokens to the new Dai."
|
"message": "A message from Maker: The new Multi-Collateral Dai token has been released. Your old tokens are now called Sai. Please upgrade your Sai tokens to the new Dai."
|
||||||
},
|
},
|
||||||
@ -867,6 +870,12 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"message": "Message"
|
"message": "Message"
|
||||||
},
|
},
|
||||||
|
"metaMaskConnectStatusParagraphOne": {
|
||||||
|
"message": "This is the new MetaMask Connect status indicator. From here you can easily see and manage sites you’ve connected to with your MetaMask wallet."
|
||||||
|
},
|
||||||
|
"metaMaskConnectStatusParagraphTwo": {
|
||||||
|
"message": "Click the Connect status to see your connected sites and their permissions."
|
||||||
|
},
|
||||||
"metamaskDescription": {
|
"metamaskDescription": {
|
||||||
"message": "Connecting you to Ethereum and the Decentralized Web."
|
"message": "Connecting you to Ethereum and the Decentralized Web."
|
||||||
},
|
},
|
||||||
@ -1627,6 +1636,9 @@
|
|||||||
"welcome": {
|
"welcome": {
|
||||||
"message": "Welcome to MetaMask"
|
"message": "Welcome to MetaMask"
|
||||||
},
|
},
|
||||||
|
"whatsThis": {
|
||||||
|
"message": "What's this?"
|
||||||
|
},
|
||||||
"writePhrase": {
|
"writePhrase": {
|
||||||
"message": "Write this phrase on a piece of paper and store in a secure location. If you want even more security, write it down on multiple pieces of paper and store each in 2 - 3 different locations."
|
"message": "Write this phrase on a piece of paper and store in a secure location. If you want even more security, write it down on multiple pieces of paper and store each in 2 - 3 different locations."
|
||||||
},
|
},
|
||||||
|
@ -22,6 +22,7 @@ class AppStateController extends EventEmitter {
|
|||||||
this.store = new ObservableStore(Object.assign({
|
this.store = new ObservableStore(Object.assign({
|
||||||
timeoutMinutes: 0,
|
timeoutMinutes: 0,
|
||||||
mkrMigrationReminderTimestamp: null,
|
mkrMigrationReminderTimestamp: null,
|
||||||
|
connectedStatusPopoverHasBeenShown: true,
|
||||||
}, initState))
|
}, initState))
|
||||||
this.timer = null
|
this.timer = null
|
||||||
|
|
||||||
@ -72,6 +73,15 @@ class AppStateController extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that the user has seen the connected status info popover
|
||||||
|
*/
|
||||||
|
setConnectedStatusPopoverHasBeenShown () {
|
||||||
|
this.store.updateState({
|
||||||
|
connectedStatusPopoverHasBeenShown: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the last active time to the current time
|
* Sets the last active time to the current time
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
@ -511,6 +511,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
// AppStateController
|
// AppStateController
|
||||||
setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController),
|
setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController),
|
||||||
setMkrMigrationReminderTimestamp: nodeify(this.appStateController.setMkrMigrationReminderTimestamp, this.appStateController),
|
setMkrMigrationReminderTimestamp: nodeify(this.appStateController.setMkrMigrationReminderTimestamp, this.appStateController),
|
||||||
|
setConnectedStatusPopoverHasBeenShown: nodeify(this.appStateController.setConnectedStatusPopoverHasBeenShown, this.appStateController),
|
||||||
|
|
||||||
// EnsController
|
// EnsController
|
||||||
tryReverseResolveAddress: nodeify(this.ensController.reverseResolveAddress, this.ensController),
|
tryReverseResolveAddress: nodeify(this.ensController.reverseResolveAddress, this.ensController),
|
||||||
|
27
app/scripts/migrations/042.js
Normal file
27
app/scripts/migrations/042.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const version = 42
|
||||||
|
import { cloneDeep } from 'lodash'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreferencesController.autoLogoutTimeLimit -> autoLockTimeLimit
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
version,
|
||||||
|
migrate: async function (originalVersionedData) {
|
||||||
|
const versionedData = cloneDeep(originalVersionedData)
|
||||||
|
versionedData.meta.version = version
|
||||||
|
const state = versionedData.data
|
||||||
|
versionedData.data = transformState(state)
|
||||||
|
return versionedData
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformState (state) {
|
||||||
|
if (state.AppStateController) {
|
||||||
|
state.AppStateController.connectedStatusPopoverHasBeenShown = false
|
||||||
|
} else {
|
||||||
|
state.AppStateController = {
|
||||||
|
connectedStatusPopoverHasBeenShown: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
@ -52,6 +52,7 @@ const migrations = [
|
|||||||
require('./039').default,
|
require('./039').default,
|
||||||
require('./040').default,
|
require('./040').default,
|
||||||
require('./041').default,
|
require('./041').default,
|
||||||
|
require('./042').default,
|
||||||
]
|
]
|
||||||
|
|
||||||
export default migrations
|
export default migrations
|
||||||
|
70
test/unit/migrations/042-test.js
Normal file
70
test/unit/migrations/042-test.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'assert'
|
||||||
|
import migration42 from '../../../app/scripts/migrations/042'
|
||||||
|
|
||||||
|
describe('migration #42', function () {
|
||||||
|
|
||||||
|
it('should update the version metadata', function (done) {
|
||||||
|
const oldStorage = {
|
||||||
|
'meta': {
|
||||||
|
'version': 41,
|
||||||
|
},
|
||||||
|
'data': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
migration42.migrate(oldStorage)
|
||||||
|
.then((newStorage) => {
|
||||||
|
assert.deepEqual(newStorage.meta, {
|
||||||
|
'version': 42,
|
||||||
|
})
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
.catch(done)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set connectedStatusPopoverHasBeenShown to false', function (done) {
|
||||||
|
const oldStorage = {
|
||||||
|
meta: {},
|
||||||
|
data: {
|
||||||
|
AppStateController: {
|
||||||
|
connectedStatusPopoverHasBeenShown: true,
|
||||||
|
bar: 'baz',
|
||||||
|
},
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
migration42.migrate(oldStorage)
|
||||||
|
.then((newStorage) => {
|
||||||
|
assert.deepEqual(newStorage.data, {
|
||||||
|
AppStateController: {
|
||||||
|
connectedStatusPopoverHasBeenShown: false,
|
||||||
|
bar: 'baz',
|
||||||
|
},
|
||||||
|
foo: 'bar',
|
||||||
|
})
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
.catch(done)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize AppStateController if it does not exist', function (done) {
|
||||||
|
const oldStorage = {
|
||||||
|
meta: {},
|
||||||
|
data: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
migration42.migrate(oldStorage)
|
||||||
|
.then((newStorage) => {
|
||||||
|
assert.deepEqual(newStorage.data, {
|
||||||
|
foo: 'bar',
|
||||||
|
AppStateController: {
|
||||||
|
connectedStatusPopoverHasBeenShown: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
.catch(done)
|
||||||
|
})
|
||||||
|
})
|
@ -25,6 +25,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: white;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -107,4 +110,13 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-arrow {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: white;
|
||||||
|
position: absolute;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
box-shadow: 0px 4px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,27 @@ import PropTypes from 'prop-types'
|
|||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { I18nContext } from '../../../contexts/i18n'
|
import { I18nContext } from '../../../contexts/i18n'
|
||||||
|
|
||||||
const Popover = ({ title, subtitle, children, footer, footerClassName, onBack, onClose }) => {
|
const Popover = ({
|
||||||
|
title,
|
||||||
|
subtitle = '',
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
footerClassName,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
showArrow,
|
||||||
|
CustomBackground,
|
||||||
|
}) => {
|
||||||
const t = useContext(I18nContext)
|
const t = useContext(I18nContext)
|
||||||
return (
|
return (
|
||||||
<div className="popover-container">
|
<div className="popover-container">
|
||||||
<div className="popover-bg" onClick={onClose} />
|
{ CustomBackground
|
||||||
<section className="popover-wrap">
|
? <CustomBackground onClose={onClose} />
|
||||||
|
: <div className="popover-bg" onClick={onClose} />
|
||||||
|
}
|
||||||
|
<section className={classnames('popover-wrap', className)}>
|
||||||
|
{ showArrow ? <div className="popover-arrow" /> : null}
|
||||||
<header className="popover-header">
|
<header className="popover-header">
|
||||||
<div className="popover-header__title">
|
<div className="popover-header__title">
|
||||||
<h2 title={title}>
|
<h2 title={title}>
|
||||||
@ -32,7 +47,7 @@ const Popover = ({ title, subtitle, children, footer, footerClassName, onBack, o
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="popover-header__subtitle">{subtitle}</p>
|
{ subtitle ? <p className="popover-header__subtitle">{subtitle}</p> : null }
|
||||||
</header>
|
</header>
|
||||||
{
|
{
|
||||||
children
|
children
|
||||||
@ -59,12 +74,15 @@ const Popover = ({ title, subtitle, children, footer, footerClassName, onBack, o
|
|||||||
|
|
||||||
Popover.propTypes = {
|
Popover.propTypes = {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
subtitle: PropTypes.string.isRequired,
|
subtitle: PropTypes.string,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
footer: PropTypes.node,
|
footer: PropTypes.node,
|
||||||
footerClassName: PropTypes.string,
|
footerClassName: PropTypes.string,
|
||||||
onBack: PropTypes.func,
|
onBack: PropTypes.func,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
|
CustomBackground: PropTypes.func,
|
||||||
|
className: PropTypes.string,
|
||||||
|
showArrow: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class PopoverPortal extends PureComponent {
|
export default class PopoverPortal extends PureComponent {
|
||||||
|
@ -11,6 +11,8 @@ import WalletView from '../../components/app/wallet-view'
|
|||||||
import TransactionList from '../../components/app/transaction-list'
|
import TransactionList from '../../components/app/transaction-list'
|
||||||
import TransactionViewBalance from '../../components/app/transaction-view-balance'
|
import TransactionViewBalance from '../../components/app/transaction-view-balance'
|
||||||
import MenuBar from '../../components/app/menu-bar'
|
import MenuBar from '../../components/app/menu-bar'
|
||||||
|
import Popover from '../../components/ui/popover'
|
||||||
|
import Button from '../../components/ui/button'
|
||||||
import ConnectedSites from '../connected-sites'
|
import ConnectedSites from '../connected-sites'
|
||||||
import { Tabs, Tab } from '../../components/ui/tabs'
|
import { Tabs, Tab } from '../../components/ui/tabs'
|
||||||
|
|
||||||
@ -51,6 +53,8 @@ export default class Home extends PureComponent {
|
|||||||
hasDaiV1Token: PropTypes.bool,
|
hasDaiV1Token: PropTypes.bool,
|
||||||
firstPermissionsRequestId: PropTypes.string,
|
firstPermissionsRequestId: PropTypes.string,
|
||||||
totalUnapprovedCount: PropTypes.number.isRequired,
|
totalUnapprovedCount: PropTypes.number.isRequired,
|
||||||
|
setConnectedStatusPopoverHasBeenShown: PropTypes.func,
|
||||||
|
connectedStatusPopoverHasBeenShown: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
UNSAFE_componentWillMount () {
|
||||||
@ -172,11 +176,48 @@ export default class Home extends PureComponent {
|
|||||||
</MultipleNotifications>
|
</MultipleNotifications>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
renderPopover = () => {
|
||||||
|
const { setConnectedStatusPopoverHasBeenShown } = this.props
|
||||||
|
const { t } = this.context
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
title={ t('whatsThis') }
|
||||||
|
onClose={setConnectedStatusPopoverHasBeenShown}
|
||||||
|
className="home__connected-status-popover"
|
||||||
|
showArrow
|
||||||
|
CustomBackground={({ onClose }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="home__connected-status-popover-bg-container"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="home__connected-status-popover-bg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={setConnectedStatusPopoverHasBeenShown}
|
||||||
|
>
|
||||||
|
{ t('dismiss') }
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<main className="home__connect-status-text">
|
||||||
|
<div>{ t('metaMaskConnectStatusParagraphOne') }</div>
|
||||||
|
<div>{ t('metaMaskConnectStatusParagraphTwo') }</div>
|
||||||
|
</main>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const {
|
const {
|
||||||
forgottenPassword,
|
forgottenPassword,
|
||||||
history,
|
history,
|
||||||
|
connectedStatusPopoverHasBeenShown,
|
||||||
|
isPopup,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
if (forgottenPassword) {
|
if (forgottenPassword) {
|
||||||
@ -191,6 +232,7 @@ export default class Home extends PureComponent {
|
|||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
<Route path={CONNECTED_ROUTE} component={ConnectedSites} />
|
<Route path={CONNECTED_ROUTE} component={ConnectedSites} />
|
||||||
<div className="home__container">
|
<div className="home__container">
|
||||||
|
{ isPopup && !connectedStatusPopoverHasBeenShown ? this.renderPopover() : null }
|
||||||
<Media
|
<Media
|
||||||
query="(min-width: 576px)"
|
query="(min-width: 576px)"
|
||||||
>
|
>
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
turnThreeBoxSyncingOn,
|
turnThreeBoxSyncingOn,
|
||||||
getThreeBoxLastUpdated,
|
getThreeBoxLastUpdated,
|
||||||
setShowRestorePromptToFalse,
|
setShowRestorePromptToFalse,
|
||||||
|
setConnectedStatusPopoverHasBeenShown,
|
||||||
} from '../../store/actions'
|
} from '../../store/actions'
|
||||||
import { setThreeBoxLastUpdated } from '../../ducks/app/app'
|
import { setThreeBoxLastUpdated } from '../../ducks/app/app'
|
||||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
|
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
|
||||||
@ -33,6 +34,7 @@ const mapStateToProps = (state) => {
|
|||||||
threeBoxSynced,
|
threeBoxSynced,
|
||||||
showRestorePrompt,
|
showRestorePrompt,
|
||||||
selectedAddress,
|
selectedAddress,
|
||||||
|
connectedStatusPopoverHasBeenShown,
|
||||||
} = metamask
|
} = metamask
|
||||||
const accountBalance = getCurrentEthBalance(state)
|
const accountBalance = getCurrentEthBalance(state)
|
||||||
const { forgottenPassword, threeBoxLastUpdated } = appState
|
const { forgottenPassword, threeBoxLastUpdated } = appState
|
||||||
@ -61,6 +63,7 @@ const mapStateToProps = (state) => {
|
|||||||
hasDaiV1Token: Boolean(getDaiV1Token(state)),
|
hasDaiV1Token: Boolean(getDaiV1Token(state)),
|
||||||
firstPermissionsRequestId,
|
firstPermissionsRequestId,
|
||||||
totalUnapprovedCount,
|
totalUnapprovedCount,
|
||||||
|
connectedStatusPopoverHasBeenShown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +82,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
},
|
},
|
||||||
restoreFromThreeBox: (address) => dispatch(restoreFromThreeBox(address)),
|
restoreFromThreeBox: (address) => dispatch(restoreFromThreeBox(address)),
|
||||||
setShowRestorePromptToFalse: () => dispatch(setShowRestorePromptToFalse()),
|
setShowRestorePromptToFalse: () => dispatch(setShowRestorePromptToFalse()),
|
||||||
|
setConnectedStatusPopoverHasBeenShown: () => dispatch(setConnectedStatusPopoverHasBeenShown()),
|
||||||
})
|
})
|
||||||
|
|
||||||
export default compose(
|
export default compose(
|
||||||
|
@ -36,4 +36,69 @@
|
|||||||
&__tab {
|
&__tab {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__connect-status-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
@extend %content-text;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
&:last-child {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connected-status-popover {
|
||||||
|
width: 329px;
|
||||||
|
height: 295px;
|
||||||
|
margin-top: -12px;
|
||||||
|
|
||||||
|
.popover-header {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-arrow {
|
||||||
|
top: -6px;
|
||||||
|
left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
& :only-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 39px;
|
||||||
|
width: 133px;
|
||||||
|
border-radius: 39px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connected-status-popover-bg {
|
||||||
|
height: 34px;
|
||||||
|
width: 110px;
|
||||||
|
border-radius: 34px;
|
||||||
|
position: absolute;
|
||||||
|
top: 82px;
|
||||||
|
left: 12px;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.2);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__connected-status-popover-bg-container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2208,6 +2208,16 @@ export function setMkrMigrationReminderTimestamp (timestamp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setConnectedStatusPopoverHasBeenShown () {
|
||||||
|
return () => {
|
||||||
|
background.setConnectedStatusPopoverHasBeenShown((err) => {
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function loadingMethoDataStarted () {
|
export function loadingMethoDataStarted () {
|
||||||
return {
|
return {
|
||||||
type: actionConstants.LOADING_METHOD_DATA_STARTED,
|
type: actionConstants.LOADING_METHOD_DATA_STARTED,
|
||||||
|
Loading…
Reference in New Issue
Block a user