This commit is contained in:
masnn committed 2020-04-08 21:10:24 +08:00
1 parent 777a4bc927
commit 9c45527522
396 files changed
+13772

No files matched your search

@@ -0,0 +1,28 @@
$stripe-width = 2px
.messagepad__list-item
display: block
padding: rem(20px $section-gap-h 20px ($section-gap-h - $stripe-width))
border-left: $stripe-width solid transparent
cursor: pointer
transition: border-color .1s linear, background-color .1s linear
overflow: hidden
text-overflow: ellipsis
&:hover
border-color: #DDD
background: #F4F4F4
text-decoration: none
&.active
border-color: $secondary-color
background: rgba($secondary-color, 0.07)
.messagepad__desc
font-size: rem($font-size-small)
color: #888
max-height: 2em
margin-top: rem(5px)
overflow: hidden
text-overflow: ellipsis
word-wrap()
@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function DialogueListItemComponent(props) {
const {
userName,
summary,
faceUrl,
active,
onClick,
className,
...rest
} = props;
const cn = classNames(className, 'messagepad__list-item media', {
active,
});
return (
<li {...rest}>
<a className={cn} onClick={onClick}>
<div className="media__left middle">
<img src={faceUrl} alt="avatar" width="50" height="50" className="medium user-profile-avatar" />
</div>
<div className="media__body middle">
<h3 className="messagepad__username">{userName}</h3>
<div className="messagepad__desc">{summary}</div>
</div>
</a>
</li>
);
}
DialogueListItemComponent.propTypes = {
userName: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired,
faceUrl: PropTypes.string.isRequired,
active: PropTypes.bool,
onClick: PropTypes.func,
className: PropTypes.string,
};
@@ -0,0 +1,64 @@
$corner-size = 10px
.messagepad__message
overflow: hidden
display: flex
margin-bottom: rem(30px)
&:last-child
margin-bottom: 0
&.side--self
justify-content: flex-end
.messagepad__message__body
font-size: rem($font-size-secondary)
line-height: 1.5
padding: rem(15px)
margin-bottom: rem(8px)
position: relative
min-width: rem(100px)
white-space: pre-wrap
word-wrap()
time
opacity: 0.6
font-size: rem($font-size-small)
&:after
position: absolute
width: 0
height: 0
content: ''
top: 0
.side--other
.messagepad__message__body
margin-left: rem($corner-size + 10px)
background: $primary-color
color: #FFF
order: 1
&:after
left: rem(-($corner-size))
border-top: $corner-size solid $primary-color
border-right: 0 solid transparent
border-bottom: 0 solid transparent
border-left: $corner-size solid transparent
.side--self
.messagepad__message__body
margin-right: rem($corner-size + 10px)
background: #F0F0F0
color: #222
order: -1
time
text-align: right
&:after
right: rem(-($corner-size))
border-top: $corner-size solid #F0F0F0
border-right: $corner-size solid transparent
border-bottom: 0 solid transparent
border-left: 0 solid transparent
@@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function MessageComponent(props) {
const {
faceUrl,
isSelf,
className,
children,
...rest
} = props;
const cn = classNames(className, 'messagepad__message', {
'side--self': isSelf,
'side--other': !isSelf,
});
return (
<li {...rest} className={cn}>
<div className="messagepad__message__avatar">
<img src={faceUrl} alt="avatar" width="50" height="50" className="medium user-profile-avatar" />
</div>
<div className="messagepad__message__body">
{children}
</div>
</li>
);
}
MessageComponent.propTypes = {
isSelf: PropTypes.bool,
faceUrl: PropTypes.string.isRequired,
className: PropTypes.string,
children: PropTypes.node,
};
@@ -0,0 +1,89 @@
$list-height = 500px
$sidebar-width = 250px
$input-height = 80px
$input-padding = 10px
$send-button-width = 50px
#messagePad
min-height: rem($list-height)
position: relative
.messagepad
position: relative
.messagepad__sidebar
border-right: 1px solid #EEE
float: left
width: rem($sidebar-width)
.messagepad__list
overflow: auto
height: rem($list-height)
.messagepad__content
position: absolute
left: rem($sidebar-width)
right: 0
top: 0
bottom: rem($input-height)
overflow: auto
padding: rem(30px)
.messagepad__input
display: none
position: absolute
left: rem($sidebar-width)
right: 0
bottom: 0
height: $input-height
border-top: 1px solid #EEE
&.visible
display: block
button
display: block
appearance: none
position: absolute
right: 0
width: rem($send-button-width)
top: 0
height: 100%
border: 0
background: none
text-align: center
line-height: rem($input-height)
font-size: rem($font-size-icon)
transition: background-color .1s, color .1s, opacity .1s
transition-timing-function: linear
outline: 0
&:enabled
cursor: pointer
background-color: $primary-color
color: #FFF
&:hover
background-color: lighten($primary-color, 10%)
&:disabled
color: $text-1-color
opacity: 0.3
.messagepad__textarea-container
position: absolute
left: rem($input-padding)
right: rem($send-button-width + $input-padding)
top: rem($input-padding)
bottom: rem($input-padding)
textarea
display: block
border: 0
background: none
padding: 0
width: 100%
height: 100%
font-size: rem($font-size-secondary)
resize: none
outline: 0
@@ -0,0 +1,81 @@
import React from 'react';
import _ from 'lodash';
import { connect } from 'react-redux';
import TimeAgo from 'timeago-react';
import moment from 'moment';
import 'jquery-scroll-lock';
import 'jquery.easing';
import i18n from 'vj/utils/i18n';
import Message from './MessageComponent';
const mapStateToProps = state => ({
activeId: state.activeId,
item: state.activeId !== null
? state.dialogues[state.activeId]
: null,
});
@connect(mapStateToProps, null)
export default class MessagePadDialogueContentContainer extends React.PureComponent {
componentDidMount() {
$(this.refs.list).scrollLock({ strict: true });
}
componentWillUpdate(nextProps) {
if (nextProps.activeId !== this.props.activeId) {
this.scrollToBottom = true;
this.scrollWithAnimation = false;
return;
}
const node = this.refs.list;
if (node.scrollTop + node.offsetHeight === node.scrollHeight) {
this.scrollToBottom = true;
this.scrollWithAnimation = true;
return;
}
this.scrollToBottom = false;
}
componentDidUpdate() {
if (this.scrollToBottom) {
const node = this.refs.list;
const targetScrollTop = node.scrollHeight - node.offsetHeight;
if (this.scrollWithAnimation) {
$(node).stop().animate({ scrollTop: targetScrollTop }, 200, 'easeOutCubic');
} else {
node.scrollTop = targetScrollTop;
}
}
}
renderInner() {
if (this.props.activeId === null) {
return [];
}
return _.map(this.props.item.reply, (reply, idx) => (
<Message
key={idx}
isSelf={reply.sender_uid === UserContext.uid}
faceUrl={
reply.sender_uid === this.props.item.sender_uid
? this.props.item.sender_udoc.gravatar_url
: this.props.item.sendee_udoc.gravatar_url
}
>
<div>{reply.content}</div>
<time data-tooltip={moment(reply.at).format('YYYY-MM-DD HH:mm:ss')}>
<TimeAgo datetime={reply.at} locale={i18n('timeago_locale')} />
</time>
</Message>
));
}
render() {
return (
<ol className="messagepad__content" ref="list">
{this.renderInner()}
</ol>
);
}
}
@@ -0,0 +1,63 @@
import React from 'react';
import _ from 'lodash';
import { connect } from 'react-redux';
import 'jquery-scroll-lock';
import ListItem from './DialogueListItemComponent';
const mapStateToProps = state => ({
activeId: state.activeId,
dialogues: state.dialogues,
});
const mapDispatchToProps = dispatch => ({
handleClick(id) {
dispatch({
type: 'DIALOGUES_SWITCH_TO',
payload: id,
});
},
});
@connect(mapStateToProps, mapDispatchToProps)
export default class MessagePadDialogueListContainer extends React.PureComponent {
componentDidMount() {
$(this.refs.list).scrollLock({ strict: true });
}
render() {
const orderedDialogues = _.orderBy(
_.values(this.props.dialogues),
dialogue => (dialogue.isPlaceholder
? Number.POSITIVE_INFINITY
: _.maxBy(dialogue.reply, 'at').at),
'desc',
);
return (
<ol className="messagepad__list" ref="list">
{_.map(orderedDialogues, dialogue => (
<ListItem
key={dialogue._id}
userName={
dialogue.sender_uid === UserContext.uid
? dialogue.sendee_udoc.uname
: dialogue.sender_udoc.uname
}
summary={
dialogue.isPlaceholder
? ''
: _.last(dialogue.reply).content
}
faceUrl={
dialogue.sender_uid === UserContext.uid
? dialogue.sendee_udoc.gravatar_url
: dialogue.sender_udoc.gravatar_url
}
active={dialogue._id === this.props.activeId}
onClick={() => this.props.handleClick(dialogue._id)}
/>
))}
</ol>
);
}
}
@@ -0,0 +1,135 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import Icon from 'vj/components/react/IconComponent';
import request from 'vj/utils/request';
const mapStateToProps = state => ({
activeId: state.activeId,
isPosting: state.activeId !== null
? state.isPosting[state.activeId]
: false,
inputValue: state.activeId !== null
? state.inputs[state.activeId]
: '',
});
const mapDispatchToProps = dispatch => ({
handleChange(id, value) {
if (id === null) {
return;
}
dispatch({
type: 'DIALOGUES_INPUT_CHANGED',
payload: value,
meta: {
dialogueId: id,
},
});
},
postSend(placeholderId, uid, value) {
if (placeholderId === null) {
return;
}
const req = request.post('', {
operation: 'send_message',
uid,
content: value,
});
dispatch({
type: 'DIALOGUES_POST_SEND',
payload: req,
meta: {
placeholderId,
},
});
},
postReply(id, value) {
if (id === null) {
return;
}
const req = request.post('', {
operation: 'reply_message',
message_id: id,
content: value,
});
dispatch({
type: 'DIALOGUES_POST_REPLY',
payload: req,
meta: {
dialogueId: id,
},
});
},
});
@connect(mapStateToProps, mapDispatchToProps)
export default class MessagePadInputContainer extends React.PureComponent {
static contextTypes = {
store: PropTypes.object,
};
componentWillUpdate(nextProps) {
this.focusInput = (
nextProps.activeId !== this.props.activeId
|| this.props.isPosting !== nextProps.isPosting && nextProps.isPosting === false
);
}
componentDidUpdate() {
if (this.focusInput) {
const { scrollX, scrollY } = window;
this.refs.input.focus();
window.scrollTo(scrollX, scrollY);
}
}
handleKeyDown(ev) {
if (ev.keyCode === 13 && (ev.ctrlKey || ev.metaKey)) {
this.submit();
}
}
submit() {
const state = this.context.store.getState();
if (state.dialogues[this.props.activeId].isPlaceholder) {
this.props.postSend(
this.props.activeId,
state.dialogues[this.props.activeId].sendee_uid,
this.props.inputValue,
);
} else {
this.props.postReply(
this.props.activeId,
this.props.inputValue,
);
}
}
render() {
const cn = classNames('messagepad__input', {
visible: this.props.activeId !== null,
});
return (
<div className={cn}>
<div className="messagepad__textarea-container">
<textarea
ref="input"
disabled={this.props.isPosting}
value={this.props.inputValue}
onKeyDown={ev => this.handleKeyDown(ev)}
onChange={ev => this.props.handleChange(this.props.activeId, ev.target.value)}
/>
</div>
<button
disabled={!this.props.inputValue.trim().length || this.props.isPosting}
onClick={() => this.submit()}
>
<Icon name="send" />
</button>
</div>
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import request from 'vj/utils/request';
import i18n from 'vj/utils/i18n';
import Icon from 'vj/components/react/IconComponent';
import MessagePadDialogueList from './MessagePadDialogueListContainer';
import MessagePadDialogueContent from './MessagePadDialogueContentContainer';
import MessagePadInput from './MessagePadInputContainer';
const mapDispatchToProps = dispatch => ({
loadDialogues() {
dispatch({
type: 'DIALOGUES_LOAD_DIALOGUES',
payload: request.get(''),
});
},
});
@connect(null, mapDispatchToProps)
export default class MessagePadContainer extends React.PureComponent {
static propTypes = {
onAdd: PropTypes.func.isRequired,
};
componentDidMount() {
this.props.loadDialogues();
}
render() {
return (
<div className="messagepad clearfix">
<div className="messagepad__sidebar">
<div className="section__header">
<button
onClick={() => this.props.onAdd()}
className="primary rounded button"
>
<Icon name="add" />
{' '}
{i18n('New')}
</button>
</div>
<MessagePadDialogueList />
</div>
<MessagePadDialogueContent />
<MessagePadInput />
</div>
);
}
}
@@ -0,0 +1,34 @@
import 'jquery.easing';
function scrollToViewport() {
const BOUND_TOP = 60;
const BOUND_BOTTOM = 20;
const node = $('.messagepad')[0];
if (node.offsetHeight + BOUND_TOP + BOUND_BOTTOM < window.innerHeight) {
const rect = node.getBoundingClientRect();
const rectBody = document.body.getBoundingClientRect();
let targetScrollTop = null;
if (rect.top < BOUND_TOP) {
targetScrollTop = rect.top - rectBody.top - BOUND_TOP;
} else if (rect.top + node.offsetHeight > window.innerHeight) {
targetScrollTop = rect.top - rectBody.top + node.offsetHeight + BOUND_BOTTOM - window.innerHeight;
}
if (targetScrollTop !== null) {
$('html, body').stop().animate({ scrollTop: targetScrollTop }, 200, 'easeOutCubic');
}
}
}
export default function reducer(state = null, action) {
switch (action.type) {
case 'DIALOGUES_SWITCH_TO': {
scrollToViewport();
return action.payload;
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
return action.payload.mdoc._id;
}
default:
return state;
}
}
@@ -0,0 +1,83 @@
import _ from 'lodash';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const { messages, udict } = action.payload;
return _.keyBy(
_.filter(
_.map(messages, m => ({
sendee_udoc: udict[m.sendee_uid],
sender_udoc: udict[m.sender_uid],
...m,
})),
m => m.sendee_udoc && m.sender_udoc,
),
'_id',
);
}
case 'DIALOGUES_CREATE': {
const { id, user } = action.payload;
return {
...state,
[id]: {
_id: id,
sender_uid: UserContext.uid,
sendee_uid: user._id,
sendee_udoc: { ...user },
reply: [],
isPlaceholder: true,
},
};
}
case 'DIALOGUES_POST_REPLY_FULFILLED': {
const id = action.meta.dialogueId;
const { reply } = action.payload;
return {
...state,
[id]: {
...state[id],
reply: [
...state[id].reply,
reply,
],
},
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const { placeholderId } = action.meta;
return {
..._.omit(state, placeholderId),
[action.payload.mdoc._id]: action.payload.mdoc,
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { type, data } = action.payload;
const id = data._id;
if (type === 'new') {
return {
...state,
[id]: data,
};
} if (type === 'reply') {
if (state[id] === undefined) {
window.location.reload();
return state;
}
return {
...state,
[id]: {
...state[id],
reply: [
...state[id].reply,
data.reply[0],
],
},
};
}
return state;
}
default:
return state;
}
}
@@ -0,0 +1,14 @@
import { combineReducers } from 'redux';
import activeId from './activeId';
import dialogues from './dialogues';
import inputs from './inputs';
import isPosting from './isPosting';
const reducer = combineReducers({
activeId,
dialogues,
inputs,
isPosting,
});
export default reducer;
@@ -0,0 +1,51 @@
import _ from 'lodash';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return _.fromPairs(_.map(dialogues, d => [d._id, '']));
}
case 'DIALOGUES_CREATE': {
const { id } = action.payload;
return {
...state,
[id]: '',
};
}
case 'DIALOGUES_INPUT_CHANGED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: action.payload,
};
}
case 'DIALOGUES_POST_REPLY_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: '',
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const { placeholderId } = action.meta;
return {
..._.omit(state, placeholderId),
[action.payload.mdoc._id]: '',
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { type, data } = action.payload;
const id = data._id;
if (type === 'new') {
return {
...state,
[id]: '',
};
}
return state;
}
default:
return state;
}
}
@@ -0,0 +1,75 @@
import _ from 'lodash';
import Notification from 'vj/components/notification';
export default function reducer(state = {}, action) {
switch (action.type) {
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
const dialogues = action.payload.messages;
return _.fromPairs(_.map(dialogues, d => [d._id, false]));
}
case 'DIALOGUES_CREATE': {
const { id } = action.payload;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_POST_REPLY_PENDING': {
const id = action.meta.dialogueId;
return {
...state,
[id]: true,
};
}
case 'DIALOGUES_POST_REPLY_REJECTED': {
Notification.error(action.payload.message);
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_POST_REPLY_FULFILLED': {
const id = action.meta.dialogueId;
return {
...state,
[id]: false,
};
}
case 'DIALOGUES_POST_SEND_PENDING': {
const { placeholderId } = action.meta;
return {
...state,
[placeholderId]: true,
};
}
case 'DIALOGUES_POST_SEND_REJECTED': {
Notification.error(action.payload.message);
const { placeholderId } = action.meta;
return {
...state,
[placeholderId]: false,
};
}
case 'DIALOGUES_POST_SEND_FULFILLED': {
const { placeholderId } = action.meta;
return {
..._.omit(state, placeholderId),
[action.payload.mdoc._id]: false,
};
}
case 'DIALOGUES_MESSAGE_PUSH': {
const { type, data } = action.payload;
const id = data._id;
if (type === 'new') {
return {
...state,
[id]: false,
};
}
return state;
}
default:
return state;
}
}