Fix browser notification permission request logic (#13543)
* Add notification permission handling code * Request notification permission when enabling any notification setting * Add badge to notification settings when permissions insufficient * Disable alerts by default, requesting permission and enable them on onboarding
This commit is contained in:
		
							parent
							
								
									5e1364c448
								
							
						
					
					
						commit
						f54ca3d08e
					
				| @ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors'; | ||||
| import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; | ||||
| import compareId from 'mastodon/compare_id'; | ||||
| import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; | ||||
| import { requestNotificationPermission } from '../utils/notifications'; | ||||
| 
 | ||||
| export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE'; | ||||
| export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; | ||||
| @ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; | ||||
| export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT'; | ||||
| export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; | ||||
| 
 | ||||
| 
 | ||||
| export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; | ||||
| 
 | ||||
| export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; | ||||
| export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; | ||||
| 
 | ||||
| defineMessages({ | ||||
|   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, | ||||
|   group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, | ||||
| @ -235,6 +240,46 @@ export const unmountNotifications = () => ({ | ||||
|   type: NOTIFICATIONS_UNMOUNT, | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| export const markNotificationsAsRead = () => ({ | ||||
|   type: NOTIFICATIONS_MARK_AS_READ, | ||||
| }); | ||||
| 
 | ||||
| // Browser support
 | ||||
| export function setupBrowserNotifications() { | ||||
|   return dispatch => { | ||||
|     dispatch(setBrowserSupport('Notification' in window)); | ||||
|     if ('Notification' in window) { | ||||
|       dispatch(setBrowserPermission(Notification.permission)); | ||||
|     } | ||||
| 
 | ||||
|     if ('Notification' in window && 'permissions' in navigator) { | ||||
|       navigator.permissions.query({ name: 'notifications' }).then((status) => { | ||||
|         status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function requestBrowserPermission(callback = noOp) { | ||||
|   return dispatch => { | ||||
|     requestNotificationPermission((permission) => { | ||||
|       dispatch(setBrowserPermission(permission)); | ||||
|       callback(permission); | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function setBrowserSupport (value) { | ||||
|   return { | ||||
|     type: NOTIFICATIONS_SET_BROWSER_SUPPORT, | ||||
|     value, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function setBrowserPermission (value) { | ||||
|   return { | ||||
|     type: NOTIFICATIONS_SET_BROWSER_PERMISSION, | ||||
|     value, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,20 @@ | ||||
| import { changeSetting, saveSettings } from './settings'; | ||||
| import { requestBrowserPermission } from './notifications'; | ||||
| 
 | ||||
| export const INTRODUCTION_VERSION = 20181216044202; | ||||
| 
 | ||||
| export const closeOnboarding = () => dispatch => { | ||||
|   dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); | ||||
|   dispatch(saveSettings()); | ||||
| 
 | ||||
|   dispatch(requestBrowserPermission((permission) => { | ||||
|     if (permission === 'granted') { | ||||
|       dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); | ||||
|       dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); | ||||
|       dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); | ||||
|       dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); | ||||
|       dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); | ||||
|       dispatch(saveSettings()); | ||||
|     } | ||||
|   })); | ||||
| }; | ||||
|  | ||||
| @ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { | ||||
|     onMove: PropTypes.func, | ||||
|     onClick: PropTypes.func, | ||||
|     appendContent: PropTypes.node, | ||||
|     collapseIssues: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
| @ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent { | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; | ||||
|     const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; | ||||
|     const { collapsed, animating } = this.state; | ||||
| 
 | ||||
|     const wrapperClassName = classNames('column-header__wrapper', { | ||||
| @ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent { | ||||
|     } | ||||
| 
 | ||||
|     if (children || (multiColumn && this.props.onPin)) { | ||||
|       collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; | ||||
|       collapseButton = ( | ||||
|         <button | ||||
|           className={collapsibleButtonClassName} | ||||
|           title={formatMessage(collapsed ? messages.show : messages.hide)} | ||||
|           aria-label={formatMessage(collapsed ? messages.show : messages.hide)} | ||||
|           aria-pressed={collapsed ? 'false' : 'true'} | ||||
|           onClick={this.handleToggleClick} | ||||
|         > | ||||
|           <i className='icon-with-badge'> | ||||
|             <Icon id='sliders' /> | ||||
|             {collapseIssues && <i className='icon-with-badge__issue-badge' />} | ||||
|           </i> | ||||
|         </button> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     const hasTitle = icon && title; | ||||
|  | ||||
| @ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon'; | ||||
| 
 | ||||
| const formatNumber = num => num > 40 ? '40+' : num; | ||||
| 
 | ||||
| const IconWithBadge = ({ id, count, className }) => ( | ||||
| const IconWithBadge = ({ id, count, issueBadge, className }) => ( | ||||
|   <i className='icon-with-badge'> | ||||
|     <Icon id={id} fixedWidth className={className} /> | ||||
|     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} | ||||
|     {issueBadge && <i className='icon-with-badge__issue-badge' />} | ||||
|   </i> | ||||
| ); | ||||
| 
 | ||||
| IconWithBadge.propTypes = { | ||||
|   id: PropTypes.string.isRequired, | ||||
|   count: PropTypes.number.isRequired, | ||||
|   issueBadge: PropTypes.bool, | ||||
|   className: PropTypes.string, | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import ClearColumnButton from './clear_column_button'; | ||||
| import SettingToggle from './setting_toggle'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| 
 | ||||
| export default class ColumnSettings extends React.PureComponent { | ||||
| 
 | ||||
| @ -12,6 +13,10 @@ export default class ColumnSettings extends React.PureComponent { | ||||
|     pushSettings: ImmutablePropTypes.map.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     onClear: PropTypes.func.isRequired, | ||||
|     onRequestNotificationPermission: PropTypes.func.isRequired, | ||||
|     alertsEnabled: PropTypes.bool, | ||||
|     browserSupport: PropTypes.bool, | ||||
|     browserPermission: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   onPushChange = (path, checked) => { | ||||
| @ -19,7 +24,7 @@ export default class ColumnSettings extends React.PureComponent { | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { settings, pushSettings, onChange, onClear } = this.props; | ||||
|     const { settings, pushSettings, onChange, onClear, onRequestNotificationPermission, alertsEnabled, browserSupport, browserPermission } = this.props; | ||||
| 
 | ||||
|     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; | ||||
|     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; | ||||
| @ -30,8 +35,40 @@ export default class ColumnSettings extends React.PureComponent { | ||||
|     const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); | ||||
|     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; | ||||
| 
 | ||||
|     const settingsIssues = []; | ||||
| 
 | ||||
|     if (alertsEnabled && browserSupport && browserPermission !== 'granted') { | ||||
|       if (browserPermission === 'denied') { | ||||
|         settingsIssues.push( | ||||
|           <button | ||||
|             className='text-btn column-header__issue-btn' | ||||
|             tabIndex='0' | ||||
|             onClick={onRequestNotificationPermission} | ||||
|           > | ||||
|             <Icon id='exclamation-circle' /> <FormattedMessage id='notifications.permission_denied' defaultMessage='Mastodon cannot show notifications because the permission has been denied' /> | ||||
|           </button> | ||||
|         ); | ||||
|       } else if (browserPermission === 'default') { | ||||
|         settingsIssues.push( | ||||
|           <button | ||||
|             className='text-btn column-header__issue-btn' | ||||
|             tabIndex='0' | ||||
|             onClick={onRequestNotificationPermission} | ||||
|           > | ||||
|             <Icon id='exclamation-circle' /> <FormattedMessage id='notifications.request_permission' defaultMessage='Enable browser notifications' /> | ||||
|           </button> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div> | ||||
|         {settingsIssues && ( | ||||
|           <div className='column-settings__row'> | ||||
|             {settingsIssues} | ||||
|           </div> | ||||
|         )} | ||||
| 
 | ||||
|         <div className='column-settings__row'> | ||||
|           <ClearColumnButton onClick={onClear} /> | ||||
|         </div> | ||||
|  | ||||
| @ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import ColumnSettings from '../components/column_settings'; | ||||
| import { changeSetting } from '../../../actions/settings'; | ||||
| import { setFilter } from '../../../actions/notifications'; | ||||
| import { clearNotifications } from '../../../actions/notifications'; | ||||
| import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; | ||||
| import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; | ||||
| import { openModal } from '../../../actions/modal'; | ||||
| import { showAlert } from '../../../actions/alerts'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, | ||||
|   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, | ||||
|   permissionDenied: { id: 'notifications.permission_denied', defaultMessage: 'Cannot enable desktop notifications as permission has been denied.' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   settings: state.getIn(['settings', 'notifications']), | ||||
|   pushSettings: state.get('push_notifications'), | ||||
|   alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), | ||||
|   browserSupport: state.getIn(['notifications', 'browserSupport']), | ||||
|   browserPermission: state.getIn(['notifications', 'browserPermission']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||
| 
 | ||||
|   onChange (path, checked) { | ||||
|     if (path[0] === 'push') { | ||||
|       dispatch(changePushNotifications(path.slice(1), checked)); | ||||
|       if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { | ||||
|         dispatch(requestBrowserPermission((permission) => { | ||||
|           if (permission === 'granted') { | ||||
|             dispatch(changePushNotifications(path.slice(1), checked)); | ||||
|           } else { | ||||
|             dispatch(showAlert(undefined, messages.permissionDenied)); | ||||
|           } | ||||
|         })); | ||||
|       } else { | ||||
|         dispatch(changePushNotifications(path.slice(1), checked)); | ||||
|       } | ||||
|     } else if (path[0] === 'quickFilter') { | ||||
|       dispatch(changeSetting(['notifications', ...path], checked)); | ||||
|       dispatch(setFilter('all')); | ||||
|     } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { | ||||
|       if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { | ||||
|         dispatch(requestBrowserPermission((permission) => { | ||||
|           if (permission === 'granted') { | ||||
|             dispatch(changeSetting(['notifications', ...path], checked)); | ||||
|           } else { | ||||
|             dispatch(showAlert(undefined, messages.permissionDenied)); | ||||
|           } | ||||
|         })); | ||||
|       } else { | ||||
|         dispatch(changeSetting(['notifications', ...path], checked)); | ||||
|       } | ||||
|     } else { | ||||
|       dispatch(changeSetting(['notifications', ...path], checked)); | ||||
|     } | ||||
| @ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||
|     })); | ||||
|   }, | ||||
| 
 | ||||
|   onRequestNotificationPermission () { | ||||
|     dispatch(requestBrowserPermission()); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); | ||||
|  | ||||
| @ -55,6 +55,7 @@ const mapStateToProps = state => ({ | ||||
|   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, | ||||
|   lastReadId: state.getIn(['notifications', 'readMarkerId']), | ||||
|   canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), | ||||
|   needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted', | ||||
| }); | ||||
| 
 | ||||
| export default @connect(mapStateToProps) | ||||
| @ -75,6 +76,7 @@ class Notifications extends React.PureComponent { | ||||
|     numPending: PropTypes.number, | ||||
|     lastReadId: PropTypes.string, | ||||
|     canMarkAsRead: PropTypes.bool, | ||||
|     needsNotificationPermission: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
| @ -250,6 +252,7 @@ class Notifications extends React.PureComponent { | ||||
|           pinned={pinned} | ||||
|           multiColumn={multiColumn} | ||||
|           extraButton={extraButton} | ||||
|           collapseIssues={this.props.needsNotificationPermission} | ||||
|         > | ||||
|           <ColumnSettingsContainer /> | ||||
|         </ColumnHeader> | ||||
|  | ||||
| @ -3,6 +3,7 @@ import IconWithBadge from 'mastodon/components/icon_with_badge'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   count: state.getIn(['notifications', 'unread']), | ||||
|   issueBadge: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted', | ||||
|   id: 'bell', | ||||
| }); | ||||
| 
 | ||||
|  | ||||
| @ -366,10 +366,6 @@ class UI extends React.PureComponent { | ||||
|       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); | ||||
|     } | ||||
| 
 | ||||
|     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { | ||||
|       window.setTimeout(() => Notification.requestPermission(), 120 * 1000); | ||||
|     } | ||||
| 
 | ||||
|     this.props.dispatch(fetchMarkers()); | ||||
|     this.props.dispatch(expandHomeTimeline()); | ||||
|     this.props.dispatch(expandNotifications()); | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import * as registerPushNotifications from './actions/push_notifications'; | ||||
| import { setupBrowserNotifications } from './actions/notifications'; | ||||
| import { default as Mastodon, store } from './containers/mastodon'; | ||||
| import React from 'react'; | ||||
| import ReactDOM from 'react-dom'; | ||||
| @ -22,6 +23,7 @@ function main() { | ||||
|     const props = JSON.parse(mountNode.getAttribute('data-props')); | ||||
| 
 | ||||
|     ReactDOM.render(<Mastodon {...props} />, mountNode); | ||||
|     store.dispatch(setupBrowserNotifications()); | ||||
|     if (process.env.NODE_ENV === 'production') { | ||||
|       // avoid offline in dev mode because it's harder to debug
 | ||||
|       require('offline-plugin/runtime').install(); | ||||
|  | ||||
| @ -10,6 +10,8 @@ import { | ||||
|   NOTIFICATIONS_MOUNT, | ||||
|   NOTIFICATIONS_UNMOUNT, | ||||
|   NOTIFICATIONS_MARK_AS_READ, | ||||
|   NOTIFICATIONS_SET_BROWSER_SUPPORT, | ||||
|   NOTIFICATIONS_SET_BROWSER_PERMISSION, | ||||
| } from '../actions/notifications'; | ||||
| import { | ||||
|   ACCOUNT_BLOCK_SUCCESS, | ||||
| @ -40,6 +42,8 @@ const initialState = ImmutableMap({ | ||||
|   readMarkerId: '0', | ||||
|   isTabVisible: true, | ||||
|   isLoading: false, | ||||
|   browserSupport: false, | ||||
|   browserPermission: 'default', | ||||
| }); | ||||
| 
 | ||||
| const notificationToMap = notification => ImmutableMap({ | ||||
| @ -242,6 +246,10 @@ export default function notifications(state = initialState, action) { | ||||
|   case NOTIFICATIONS_MARK_AS_READ: | ||||
|     const lastNotification = state.get('items').find(item => item !== null); | ||||
|     return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; | ||||
|   case NOTIFICATIONS_SET_BROWSER_SUPPORT: | ||||
|     return state.set('browserSupport', action.value); | ||||
|   case NOTIFICATIONS_SET_BROWSER_PERMISSION: | ||||
|     return state.set('browserPermission', action.value); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  | ||||
| @ -29,12 +29,12 @@ const initialState = ImmutableMap({ | ||||
| 
 | ||||
|   notifications: ImmutableMap({ | ||||
|     alerts: ImmutableMap({ | ||||
|       follow: true, | ||||
|       follow: false, | ||||
|       follow_request: false, | ||||
|       favourite: true, | ||||
|       reblog: true, | ||||
|       mention: true, | ||||
|       poll: true, | ||||
|       favourite: false, | ||||
|       reblog: false, | ||||
|       mention: false, | ||||
|       poll: false, | ||||
|     }), | ||||
| 
 | ||||
|     quickFilter: ImmutableMap({ | ||||
|  | ||||
							
								
								
									
										29
									
								
								app/javascript/mastodon/utils/notifications.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/javascript/mastodon/utils/notifications.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| // Handles browser quirks, based on
 | ||||
| // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
 | ||||
| 
 | ||||
| const checkNotificationPromise = () => { | ||||
|   try { | ||||
|     Notification.requestPermission().then(); | ||||
|   } catch(e) { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   return true; | ||||
| }; | ||||
| 
 | ||||
| const handlePermission = (permission, callback) => { | ||||
|   // Whatever the user answers, we make sure Chrome stores the information
 | ||||
|   if(!('permission' in Notification)) { | ||||
|     Notification.permission = permission; | ||||
|   } | ||||
| 
 | ||||
|   callback(Notification.permission); | ||||
| }; | ||||
| 
 | ||||
| export const requestNotificationPermission = (callback) => { | ||||
|   if (checkNotificationPromise()) { | ||||
|     Notification.requestPermission().then((permission) => handlePermission(permission, callback)); | ||||
|   } else { | ||||
|     Notification.requestPermission((permission) => handlePermission(permission, callback)); | ||||
|   } | ||||
| }; | ||||
| @ -2418,6 +2418,17 @@ a.account__display-name { | ||||
|     line-height: 14px; | ||||
|     color: $primary-text-color; | ||||
|   } | ||||
| 
 | ||||
|   &__issue-badge { | ||||
|     position: absolute; | ||||
|     left: 11px; | ||||
|     bottom: 1px; | ||||
|     display: block; | ||||
|     background: $error-red; | ||||
|     border-radius: 50%; | ||||
|     width: 0.625rem; | ||||
|     height: 0.625rem; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .column-link--transparent .icon-with-badge__badge { | ||||
| @ -3453,6 +3464,15 @@ a.status-card.compact:hover { | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .column-header__issue-btn { | ||||
|   color: $warning-red; | ||||
| 
 | ||||
|   &:hover { | ||||
|     color: $error-red; | ||||
|     text-decoration: underline; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .column-header__icon { | ||||
|   display: inline-block; | ||||
|   margin-right: 5px; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user