feat(chat) Redesign chat
Move some styles from SCSS to JSS Convert some files to TS Implement redesign
This commit is contained in:
parent
e3166e6faa
commit
8e1d96cc48
126
css/_chat.scss
126
css/_chat.scss
|
@ -32,7 +32,7 @@
|
|||
|
||||
#chat-conversation-container {
|
||||
// extract message input height
|
||||
height: calc(100% - 68px);
|
||||
height: calc(100% - 64px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
@ -76,32 +76,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
#chat-recipient {
|
||||
align-items: center;
|
||||
background-color: $chatPrivateMessageBackgroundColor;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: 100;
|
||||
padding: 10px;
|
||||
|
||||
span {
|
||||
color: white;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div {
|
||||
svg {
|
||||
cursor: pointer;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.lobby-chat-recipient {
|
||||
background-color: $chatLobbyMessageBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chat-header {
|
||||
height: 70px;
|
||||
|
@ -124,13 +98,12 @@
|
|||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
|
||||
#chat-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
@ -263,15 +236,6 @@
|
|||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
|
@ -288,24 +252,11 @@
|
|||
}
|
||||
|
||||
.chatmessage {
|
||||
background-color: $chatRemoteMessageBackgroundColor;
|
||||
border-radius: 0px 6px 6px 6px;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
margin-top: 3px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.localuser {
|
||||
background-color: $chatLocalMessageBackgroundColor;
|
||||
border-radius: 6px 0px 6px 6px;
|
||||
}
|
||||
|
||||
.usermessage {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-radius: 0px;
|
||||
|
||||
|
@ -320,22 +271,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.privatemessagenotice {
|
||||
font-size: 11px;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
.messagecontent {
|
||||
margin: 8px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
#smileys {
|
||||
font-size: 20pt;
|
||||
margin: auto;
|
||||
|
@ -409,24 +350,9 @@
|
|||
}
|
||||
|
||||
.chat-message-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.local {
|
||||
align-items: flex-end;
|
||||
|
||||
.chatmessage {
|
||||
background-color: $chatLocalMessageBackgroundColor;
|
||||
border-radius: 6px 0px 6px 6px;
|
||||
|
||||
&.privatemessage {
|
||||
background-color: $chatPrivateMessageBackgroundColor;
|
||||
}
|
||||
&.lobbymessage {
|
||||
background-color: $chatLobbyMessageBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: none;
|
||||
}
|
||||
|
@ -437,58 +363,10 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
.chatmessage {
|
||||
background-color: $defaultWarningColor;
|
||||
border-radius: 0px;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chatmessage-wrapper {
|
||||
max-width: 100%;
|
||||
|
||||
.replywrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.messageactions {
|
||||
align-self: stretch;
|
||||
border-left: 1px solid $chatActionsSeparatorColor;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
|
||||
&.lobbychatmessageactions {
|
||||
border-left-color: $chatLobbyActionsSeparatorColor;
|
||||
}
|
||||
|
||||
.toolbox-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chatmessage {
|
||||
background-color: $chatRemoteMessageBackgroundColor;
|
||||
border-radius: 0px 6px 6px 6px;
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
color: white;
|
||||
|
||||
&.privatemessage {
|
||||
background-color: $chatPrivateMessageBackgroundColor;
|
||||
}
|
||||
&.lobbymessage {
|
||||
background-color: $chatLobbyMessageBackgroundColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-dialog {
|
||||
|
|
|
@ -79,7 +79,6 @@ $modalTextColor: #333;
|
|||
$chatActionsSeparatorColor: rgb(173, 105, 112);
|
||||
$chatBackgroundColor: #131519;
|
||||
$chatInputSeparatorColor: #A4B8D1;
|
||||
$chatLobbyMessageBackgroundColor: #6A50D3;
|
||||
$chatLobbyActionsSeparatorColor: #6A50D3;
|
||||
$chatLocalMessageBackgroundColor: #484A4F;
|
||||
$chatPrivateMessageBackgroundColor: rgb(153, 69, 77);
|
||||
|
|
|
@ -147,6 +147,7 @@
|
|||
"@types/js-md5": "0.4.3",
|
||||
"@types/lodash": "4.14.182",
|
||||
"@types/react": "17.0.14",
|
||||
"@types/react-linkify": "1.0.1",
|
||||
"@types/react-native": "0.68.9",
|
||||
"@types/react-redux": "7.1.24",
|
||||
"@types/react-window": "1.8.5",
|
||||
|
@ -6539,6 +6540,15 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-linkify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.1.tgz",
|
||||
"integrity": "sha512-qPxYwjB41ezoKdLXs0MrQ1FnhF3apyyxf3J7WVQQCBu/GyZQAW7Y3TY4317jdh0450QJ4fLqj0rnhIJvFZOamQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-native": {
|
||||
"version": "0.68.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.68.9.tgz",
|
||||
|
@ -25265,6 +25275,15 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-linkify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.1.tgz",
|
||||
"integrity": "sha512-qPxYwjB41ezoKdLXs0MrQ1FnhF3apyyxf3J7WVQQCBu/GyZQAW7Y3TY4317jdh0450QJ4fLqj0rnhIJvFZOamQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-native": {
|
||||
"version": "0.68.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.68.9.tgz",
|
||||
|
|
|
@ -152,6 +152,7 @@
|
|||
"@types/js-md5": "0.4.3",
|
||||
"@types/lodash": "4.14.182",
|
||||
"@types/react": "17.0.14",
|
||||
"@types/react-linkify": "1.0.1",
|
||||
"@types/react-native": "0.68.9",
|
||||
"@types/react-redux": "7.1.24",
|
||||
"@types/react-window": "1.8.5",
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
// @flow
|
||||
|
||||
import punycode from 'punycode';
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import ReactLinkify from 'react-linkify';
|
||||
|
||||
type Props = {
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The children of the component.
|
||||
*/
|
||||
children: React$Node
|
||||
};
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a react wrapper for the react-linkify component.
|
||||
*/
|
||||
export default class Linkify extends Component<Props> {
|
||||
export default class Linkify extends Component<IProps> {
|
||||
/**
|
||||
* Implements {@Component#render}.
|
||||
*
|
|
@ -1,11 +1,9 @@
|
|||
// @flow
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { toArray } from 'react-emoji-render';
|
||||
|
||||
import GifMessage from '../../../../chat/components/web/GifMessage';
|
||||
import { GIF_PREFIX } from '../../../../gifs/constants';
|
||||
import { isGifMessage } from '../../../../gifs/functions';
|
||||
import { isGifMessage } from '../../../../gifs/functions.web';
|
||||
|
||||
import Linkify from './Linkify';
|
||||
|
||||
|
@ -14,7 +12,7 @@ type Props = {
|
|||
/**
|
||||
* The body of the message.
|
||||
*/
|
||||
text: string
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -41,7 +39,7 @@ class Message extends Component<Props> {
|
|||
*/
|
||||
_processMessage() {
|
||||
const { text } = this.props;
|
||||
const message = [];
|
||||
const message: (string | ReactNode)[] = [];
|
||||
|
||||
// Tokenize the text in order to avoid emoji substitution for URLs
|
||||
const tokens = text ? text.split(' ') : [];
|
||||
|
@ -81,8 +79,6 @@ class Message extends Component<Props> {
|
|||
return message;
|
||||
}
|
||||
|
||||
_processMessage: () => Array<string | React$Element<*>>;
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
|
@ -120,7 +120,7 @@ export function sendMessage(message: string, ignorePrivacy = false) {
|
|||
* type: SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
* }}
|
||||
*/
|
||||
export function setPrivateMessageRecipient(participant: Object) {
|
||||
export function setPrivateMessageRecipient(participant?: Object) {
|
||||
return {
|
||||
participant,
|
||||
type: SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// @flow
|
||||
|
||||
import { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { getLocalizedDateFormatter } from '../../base/i18n';
|
||||
import { getLocalizedDateFormatter } from '../../base/i18n/dateUtil';
|
||||
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL } from '../constants';
|
||||
import { IMessage } from '../reducer';
|
||||
|
||||
/**
|
||||
* Formatter string to display the message timestamp.
|
||||
|
@ -13,46 +13,41 @@ const TIMESTAMP_FORMAT = 'H:mm';
|
|||
/**
|
||||
* The type of the React {@code Component} props of {@code AbstractChatMessage}.
|
||||
*/
|
||||
export type Props = {
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether current participant is currently knocking in the lobby room.
|
||||
*/
|
||||
knocking: boolean;
|
||||
|
||||
/**
|
||||
* The representation of a chat message.
|
||||
*/
|
||||
message: Object,
|
||||
message: IMessage;
|
||||
|
||||
/**
|
||||
* Whether or not the avatar image of the participant which sent the message
|
||||
* should be displayed.
|
||||
*/
|
||||
showAvatar: boolean,
|
||||
showAvatar?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the name of the participant which sent the message should
|
||||
* be displayed.
|
||||
*/
|
||||
showDisplayName: boolean,
|
||||
showDisplayName: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the time at which the message was sent should be
|
||||
* displayed.
|
||||
*/
|
||||
showTimestamp: boolean,
|
||||
|
||||
/**
|
||||
* Whether current participant is currently knocking in the lobby room.
|
||||
*/
|
||||
knocking: boolean,
|
||||
|
||||
/**
|
||||
* Invoked to receive translated strings.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
showTimestamp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract component to display a chat message.
|
||||
*/
|
||||
export default class AbstractChatMessage<P: Props> extends PureComponent<P> {
|
||||
export default class AbstractChatMessage<P extends IProps> extends PureComponent<P> {
|
||||
/**
|
||||
* Returns the timestamp to display for the message.
|
||||
*
|
|
@ -1,53 +1,50 @@
|
|||
// @flow
|
||||
|
||||
import { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants';
|
||||
import { setPrivateMessageRecipient } from '../actions';
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants/functions';
|
||||
import { setLobbyChatActiveState } from '../actions.any';
|
||||
import { setPrivateMessageRecipient } from '../actions.web';
|
||||
|
||||
export type Props = {
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Function used to translate i18n labels.
|
||||
*/
|
||||
t: Function,
|
||||
* Is lobby messaging active.
|
||||
*/
|
||||
_isLobbyChatActive: boolean;
|
||||
|
||||
/**
|
||||
* Function to remove the recipent setting of the chat window.
|
||||
*/
|
||||
_onRemovePrivateMessageRecipient: Function,
|
||||
* The name of the lobby message recipient, if any.
|
||||
*/
|
||||
_lobbyMessageRecipient?: string;
|
||||
|
||||
/**
|
||||
/**
|
||||
* Function to make the lobby message recipient inactive.
|
||||
*/
|
||||
_onHideLobbyChatRecipient: Function,
|
||||
_onHideLobbyChatRecipient: () => void;
|
||||
|
||||
/**
|
||||
* Function to remove the recipient setting of the chat window.
|
||||
*/
|
||||
_onRemovePrivateMessageRecipient: () => void;
|
||||
|
||||
/**
|
||||
* The name of the message recipient, if any.
|
||||
*/
|
||||
_privateMessageRecipient: ?string,
|
||||
_privateMessageRecipient?: string;
|
||||
|
||||
/**
|
||||
* Is lobby messaging active.
|
||||
*/
|
||||
_isLobbyChatActive: boolean,
|
||||
|
||||
/**
|
||||
* The name of the lobby message recipient, if any.
|
||||
*/
|
||||
_lobbyMessageRecipient: ?string,
|
||||
|
||||
/**
|
||||
/**
|
||||
* Shows widget if it is necessary.
|
||||
*/
|
||||
_visible: boolean;
|
||||
};
|
||||
_visible: boolean;
|
||||
|
||||
classes?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the {@code MessageRecipient} component.
|
||||
*/
|
||||
export default class AbstractMessageRecipient<P: Props> extends PureComponent<P> {
|
||||
export default class AbstractMessageRecipient<P extends IProps> extends PureComponent<P> {
|
||||
|
||||
}
|
||||
|
||||
|
@ -55,9 +52,9 @@ export default class AbstractMessageRecipient<P: Props> extends PureComponent<P>
|
|||
* Maps part of the props of this component to Redux actions.
|
||||
*
|
||||
* @param {Function} dispatch - The Redux dispatch function.
|
||||
* @returns {Props}
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapDispatchToProps(dispatch: Function): $Shape<Props> {
|
||||
export function _mapDispatchToProps(dispatch: Function) {
|
||||
return {
|
||||
_onRemovePrivateMessageRecipient: () => {
|
||||
dispatch(setPrivateMessageRecipient());
|
||||
|
@ -72,9 +69,9 @@ export function _mapDispatchToProps(dispatch: Function): $Shape<Props> {
|
|||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Props}
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object): $Shape<Props> {
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const { privateMessageRecipient, lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
|
||||
|
||||
return {
|
|
@ -1,131 +0,0 @@
|
|||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import Message from '../../../base/react/components/web/Message';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { MESSAGE_TYPE_LOCAL } from '../../constants';
|
||||
import AbstractChatMessage, { type Props } from '../AbstractChatMessage';
|
||||
|
||||
import PrivateMessageButton from './PrivateMessageButton';
|
||||
|
||||
/**
|
||||
* Renders a single chat message.
|
||||
*/
|
||||
class ChatMessage extends AbstractChatMessage<Props> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { message, t, knocking } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = 'chatmessage-wrapper'
|
||||
id = { this.props.message.messageId }
|
||||
tabIndex = { -1 }>
|
||||
<div
|
||||
className = { `chatmessage ${message.privateMessage ? 'privatemessage' : ''} ${
|
||||
message.lobbyChat && !knocking ? 'lobbymessage' : ''}` }>
|
||||
<div className = 'replywrapper'>
|
||||
<div className = 'messagecontent'>
|
||||
{ this.props.showDisplayName && this._renderDisplayName() }
|
||||
<div className = 'usermessage'>
|
||||
<span className = 'sr-only'>
|
||||
{ this.props.message.displayName === this.props.message.recipient
|
||||
? t('chat.messageAccessibleTitleMe')
|
||||
: t('chat.messageAccessibleTitle',
|
||||
{ user: this.props.message.displayName }) }
|
||||
</span>
|
||||
<Message text = { this._getMessageText() } />
|
||||
</div>
|
||||
{ (message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& this._renderPrivateNotice() }
|
||||
</div>
|
||||
{ (message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& message.messageType !== MESSAGE_TYPE_LOCAL
|
||||
&& (
|
||||
<div
|
||||
className = { `messageactions ${
|
||||
message.lobbyChat ? 'lobbychatmessageactions' : ''}` }>
|
||||
<PrivateMessageButton
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
participantID = { message.id }
|
||||
reply = { true }
|
||||
showLabel = { false } />
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
{ this.props.showTimestamp && this._renderTimestamp() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_getFormattedTimestamp: () => string;
|
||||
|
||||
_getMessageText: () => string;
|
||||
|
||||
_getPrivateNoticeMessage: () => string;
|
||||
|
||||
/**
|
||||
* Renders the display name of the sender.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderDisplayName() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden = { true }
|
||||
className = 'display-name'>
|
||||
{ this.props.message.displayName }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the message privacy notice.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderPrivateNotice() {
|
||||
return (
|
||||
<div className = 'privatemessagenotice'>
|
||||
{ this._getPrivateNoticeMessage() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the time at which the message was sent.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderTimestamp() {
|
||||
return (
|
||||
<div className = 'timestamp'>
|
||||
{ this._getFormattedTimestamp() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state: Object): $Shape<Props> {
|
||||
const { knocking } = state['features/lobby'];
|
||||
|
||||
return {
|
||||
knocking
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(ChatMessage));
|
|
@ -0,0 +1,223 @@
|
|||
import { Theme } from '@mui/material';
|
||||
import { withStyles } from '@mui/styles';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Message from '../../../base/react/components/web/Message';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import { MESSAGE_TYPE_LOCAL } from '../../constants';
|
||||
import AbstractChatMessage, { IProps as AbstractProps } from '../AbstractChatMessage';
|
||||
|
||||
import PrivateMessageButton from './PrivateMessageButton';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
classes: any;
|
||||
|
||||
type: string;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
chatMessageWrapper: {
|
||||
maxWidth: '100%'
|
||||
},
|
||||
|
||||
chatMessage: {
|
||||
display: 'inline-flex',
|
||||
padding: '12px',
|
||||
backgroundColor: theme.palette.ui02,
|
||||
borderRadius: '4px 12px 12px 12px',
|
||||
boxSizing: 'border-box' as const,
|
||||
maxWidth: '100%',
|
||||
marginTop: '4px',
|
||||
|
||||
'&.privatemessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
},
|
||||
|
||||
'&.local': {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
borderRadius: '12px 4px 12px 12px',
|
||||
|
||||
'&.privatemessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
}
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
backgroundColor: 'rgb(215, 121, 118)',
|
||||
borderRadius: 0,
|
||||
fontWeight: 100
|
||||
},
|
||||
|
||||
'&.lobbymessage': {
|
||||
backgroundColor: theme.palette.support05
|
||||
}
|
||||
},
|
||||
|
||||
replyWrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
messageContent: {
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
flex: 1
|
||||
},
|
||||
|
||||
replyButtonContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
height: '100%'
|
||||
},
|
||||
|
||||
replyButton: {
|
||||
padding: '2px'
|
||||
},
|
||||
|
||||
displayName: {
|
||||
...withPixelLineHeight(theme.typography.labelBold),
|
||||
color: theme.palette.text02,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
marginBottom: theme.spacing(1)
|
||||
},
|
||||
|
||||
userMessage: {
|
||||
...withPixelLineHeight(theme.typography.bodyShortRegular),
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'pre-wrap'
|
||||
},
|
||||
|
||||
privateMessageNotice: {
|
||||
...withPixelLineHeight(theme.typography.labelRegular),
|
||||
color: theme.palette.text02,
|
||||
marginTop: theme.spacing(1)
|
||||
},
|
||||
|
||||
timestamp: {
|
||||
...withPixelLineHeight(theme.typography.labelRegular),
|
||||
color: theme.palette.text03,
|
||||
marginTop: theme.spacing(1)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a single chat message.
|
||||
*/
|
||||
class ChatMessage extends AbstractChatMessage<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { message, t, knocking, classes, type } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.chatMessageWrapper }
|
||||
id = { this.props.message.messageId }
|
||||
tabIndex = { -1 }>
|
||||
<div
|
||||
className = { clsx('chatmessage', classes.chatMessage, type,
|
||||
message.privateMessage && 'privatemessage',
|
||||
message.lobbyChat && !knocking && 'lobbymessage') }>
|
||||
<div className = { classes.replyWrapper }>
|
||||
<div className = { clsx('messagecontent', classes.messageContent) }>
|
||||
{ this.props.showDisplayName && this._renderDisplayName() }
|
||||
<div className = { clsx('usermessage', classes.userMessage) }>
|
||||
<span className = 'sr-only'>
|
||||
{ this.props.message.displayName === this.props.message.recipient
|
||||
? t('chat.messageAccessibleTitleMe')
|
||||
: t('chat.messageAccessibleTitle',
|
||||
{ user: this.props.message.displayName }) }
|
||||
</span>
|
||||
<Message text = { this._getMessageText() } />
|
||||
</div>
|
||||
{ (message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& this._renderPrivateNotice() }
|
||||
</div>
|
||||
{ (message.privateMessage || (message.lobbyChat && !knocking))
|
||||
&& message.messageType !== MESSAGE_TYPE_LOCAL
|
||||
&& (
|
||||
<div
|
||||
className = { classes.replyButtonContainer }>
|
||||
<PrivateMessageButton
|
||||
isLobbyMessage = { message.lobbyChat }
|
||||
participantID = { message.id } />
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
{ this.props.showTimestamp && this._renderTimestamp() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the display name of the sender.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderDisplayName() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden = { true }
|
||||
className = { clsx('display-name', this.props.classes.displayName) }>
|
||||
{ this.props.message.displayName }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the message privacy notice.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderPrivateNotice() {
|
||||
return (
|
||||
<div className = { this.props.classes.privateMessageNotice }>
|
||||
{ this._getPrivateNoticeMessage() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the time at which the message was sent.
|
||||
*
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderTimestamp() {
|
||||
return (
|
||||
<div className = { clsx('timestamp', this.props.classes.timestamp) }>
|
||||
{ this._getFormattedTimestamp() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const { knocking } = state['features/lobby'];
|
||||
|
||||
return {
|
||||
knocking
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(withStyles(styles)(ChatMessage)));
|
|
@ -1,59 +0,0 @@
|
|||
// @flow
|
||||
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Additional CSS classes to apply to the root element.
|
||||
*/
|
||||
className: string,
|
||||
|
||||
/**
|
||||
* The messages to display as a group.
|
||||
*/
|
||||
messages: Array<Object>,
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a list of chat messages. Will show only the display name for the
|
||||
* first chat message and the timestamp for the last chat message.
|
||||
*
|
||||
* @augments React.Component
|
||||
*/
|
||||
class ChatMessageGroup extends Component<Props> {
|
||||
static defaultProps = {
|
||||
className: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { className, messages } = this.props;
|
||||
|
||||
const messagesLength = messages.length;
|
||||
|
||||
if (!messagesLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { `chat-message-group ${className}` }>
|
||||
{ messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key = { i }
|
||||
message = { message }
|
||||
showDisplayName = { i === 0 }
|
||||
showTimestamp = { i === messages.length - 1 } />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatMessageGroup;
|
|
@ -0,0 +1,81 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
// @ts-ignore
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { IMessage } from '../../reducer';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Additional CSS classes to apply to the root element.
|
||||
*/
|
||||
className: string;
|
||||
|
||||
/**
|
||||
* The messages to display as a group.
|
||||
*/
|
||||
messages: Array<IMessage>;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
messageGroup: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
groupContainer: {
|
||||
display: 'flex',
|
||||
|
||||
'&.local': {
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
'& .avatar': {
|
||||
display: 'none'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
avatar: {
|
||||
margin: `${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(3)} 0`,
|
||||
position: 'sticky',
|
||||
top: 0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const ChatMessageGroup = ({ className = '', messages }: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
const messagesLength = messages.length;
|
||||
|
||||
if (!messagesLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { clsx(classes.groupContainer, className) }>
|
||||
<Avatar
|
||||
className = { clsx(classes.avatar, 'avatar') }
|
||||
participantId = { messages[0].id }
|
||||
size = { 32 } />
|
||||
<div className = { `${classes.messageGroup} chat-message-group ${className}` }>
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key = { i }
|
||||
message = { message }
|
||||
showDisplayName = { i === 0 }
|
||||
showTimestamp = { i === messages.length - 1 }
|
||||
type = { className } />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageGroup;
|
|
@ -5,7 +5,6 @@ import { scrollIntoView } from 'seamless-scroll-polyfill';
|
|||
import { MESSAGE_TYPE_REMOTE } from '../../constants';
|
||||
import AbstractMessageContainer, { IProps } from '../AbstractMessageContainer';
|
||||
|
||||
// @ts-ignore
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
import NewMessagesButton from './NewMessagesButton';
|
||||
|
||||
|
|
|
@ -1,35 +1,61 @@
|
|||
// @flow
|
||||
|
||||
import { Theme } from '@mui/material';
|
||||
import { withStyles } from '@mui/styles';
|
||||
import React from 'react';
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { Icon, IconCloseCircle } from '../../../base/icons';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import { connect } from '../../../base/redux/functions';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import AbstractMessageRecipient, {
|
||||
type Props,
|
||||
IProps,
|
||||
_mapDispatchToProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractMessageRecipient';
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
margin: '0 16px 8px',
|
||||
padding: '6px',
|
||||
paddingLeft: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.support05,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
...withPixelLineHeight(theme.typography.bodyShortRegular),
|
||||
color: theme.palette.text01
|
||||
},
|
||||
|
||||
iconButton: {
|
||||
padding: '2px',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action03
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Class to implement the displaying of the recipient of the next message.
|
||||
*/
|
||||
class MessageRecipient extends AbstractMessageRecipient<Props> {
|
||||
class MessageRecipient extends AbstractMessageRecipient<IProps> {
|
||||
/**
|
||||
* Initializes a new {@code MessageRecipient} instance.
|
||||
*
|
||||
* @param {*} props - The read-only properties with which the new instance
|
||||
* @param {IProps} props - The read-only properties with which the new instance
|
||||
* is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onKeyPress = this._onKeyPress.bind(this);
|
||||
}
|
||||
|
||||
_onKeyPress: (Object) => void;
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
|
@ -37,7 +63,7 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
|
|||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyPress(e) {
|
||||
_onKeyPress(e: React.KeyboardEvent) {
|
||||
if (
|
||||
(this.props._onRemovePrivateMessageRecipient || this.props._onHideLobbyChatRecipient)
|
||||
&& (e.key === ' ' || e.key === 'Enter')
|
||||
|
@ -64,11 +90,11 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { t } = this.props;
|
||||
const { classes, t } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { _isLobbyChatActive ? 'lobby-chat-recipient' : '' }
|
||||
className = { classes.container }
|
||||
id = 'chat-recipient'
|
||||
role = 'alert'>
|
||||
<span>
|
||||
|
@ -76,19 +102,18 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
|
|||
recipient: _isLobbyChatActive ? _lobbyMessageRecipient : _privateMessageRecipient
|
||||
}) }
|
||||
</span>
|
||||
<div
|
||||
aria-label = { t('dialog.close') }
|
||||
<Button
|
||||
accessibilityLabel = { t('dialog.close') }
|
||||
className = { classes.iconButton }
|
||||
icon = { IconCloseLarge }
|
||||
onClick = { _isLobbyChatActive
|
||||
? this.props._onHideLobbyChatRecipient : this.props._onRemovePrivateMessageRecipient }
|
||||
onKeyPress = { this._onKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<Icon
|
||||
src = { IconCloseCircle } />
|
||||
</div>
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient));
|
||||
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(
|
||||
withStyles(styles)(MessageRecipient)));
|
|
@ -1,99 +0,0 @@
|
|||
// @flow
|
||||
|
||||
import { CHAT_ENABLED, getFeatureFlag } from '../../../base/flags';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { IconMessage, IconReply } from '../../../base/icons';
|
||||
import { getParticipantById } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
|
||||
import { handleLobbyChatInitialized, openChat } from '../../actions';
|
||||
|
||||
export type Props = AbstractButtonProps & {
|
||||
|
||||
/**
|
||||
* True if the message is a lobby chat message.
|
||||
*/
|
||||
isLobbyMessage: boolean,
|
||||
|
||||
/**
|
||||
* The ID of the participant that the message is to be sent.
|
||||
*/
|
||||
participantID: string,
|
||||
|
||||
/**
|
||||
* True if the button is rendered as a reply button.
|
||||
*/
|
||||
reply: boolean,
|
||||
|
||||
/**
|
||||
* Function to be used to translate i18n labels.
|
||||
*/
|
||||
t: Function,
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: Function,
|
||||
|
||||
/**
|
||||
* The participant object retrieved from Redux.
|
||||
*/
|
||||
_participant: Object,
|
||||
};
|
||||
|
||||
/**
|
||||
* Class to render a button that initiates the sending of a private message through chet.
|
||||
*/
|
||||
class PrivateMessageButton extends AbstractButton<Props, any> {
|
||||
accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage';
|
||||
icon = IconMessage;
|
||||
label = 'toolbar.privateMessage';
|
||||
toggledIcon = IconReply;
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and kicks the participant.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClick() {
|
||||
const { _participant, participantID, dispatch, isLobbyMessage } = this.props;
|
||||
|
||||
if (isLobbyMessage) {
|
||||
dispatch(handleLobbyChatInitialized(participantID));
|
||||
} else {
|
||||
dispatch(openChat(_participant));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which must return a
|
||||
* {@code boolean} value indicating if this button is toggled or not.
|
||||
*
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isToggled() {
|
||||
return this.props.reply;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Props} ownProps - The own props of the component.
|
||||
* @returns {Props}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object, ownProps: Props): $Shape<Props> {
|
||||
const enabled = getFeatureFlag(state, CHAT_ENABLED, true);
|
||||
const { visible = enabled } = ownProps;
|
||||
|
||||
return {
|
||||
_participant: getParticipantById(state, ownProps.participantID),
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(PrivateMessageButton));
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { CHAT_ENABLED } from '../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../base/flags/functions';
|
||||
import { IconReply } from '../../../base/icons/svg';
|
||||
import { getParticipantById } from '../../../base/participants/functions';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* True if the message is a lobby chat message.
|
||||
*/
|
||||
isLobbyMessage: boolean;
|
||||
|
||||
/**
|
||||
* The ID of the participant that the message is to be sent.
|
||||
*/
|
||||
participantID: string;
|
||||
|
||||
/**
|
||||
* Whether the button should be visible or not.
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
replyButton: {
|
||||
padding: '2px',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action03
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PrivateMessageButton = ({ participantID, isLobbyMessage, visible }: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const participant = useSelector((state: IReduxState) => getParticipantById(state, participantID));
|
||||
const isVisible = useSelector((state: IReduxState) => getFeatureFlag(state, CHAT_ENABLED, true)) ?? visible;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isLobbyMessage) {
|
||||
dispatch(handleLobbyChatInitialized(participantID));
|
||||
} else {
|
||||
dispatch(openChat(participant));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage'
|
||||
className = { classes.replyButton }
|
||||
icon = { IconReply }
|
||||
onClick = { handleClick }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivateMessageButton;
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { CHAT_ENABLED, getFeatureFlag } from '../../../base/flags';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { IconMessage } from '../../../base/icons';
|
||||
import { getParticipantById } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
|
||||
import { openChat } from '../../../chat/';
|
||||
import {
|
||||
type Props as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
type Props as AbstractProps
|
||||
} from '../../../chat/components/web/PrivateMessageButton';
|
||||
import { isButtonEnabled } from '../../../toolbox/functions.web';
|
||||
|
||||
|
@ -85,8 +86,12 @@ class PrivateMessageMenuButton extends Component<Props> {
|
|||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state: Object, ownProps: Props): $Shape<Props> {
|
||||
const enabled = getFeatureFlag(state, CHAT_ENABLED, true);
|
||||
const { visible = enabled } = ownProps;
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state, ownProps),
|
||||
_participant: getParticipantById(state, ownProps.participantID),
|
||||
visible,
|
||||
_hidden: typeof interfaceConfig !== 'undefined'
|
||||
&& (interfaceConfig.DISABLE_PRIVATE_MESSAGES || !isButtonEnabled('chat', state))
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue