wewechat++ init

仓库提交至星火社区作品集

Signed-off-by: Riceneeder <86492950+Riceneeder@users.noreply.github.com>
This commit is contained in:
Riceneeder
2022-09-01 20:38:13 +08:00
commit 58ce6cb67b
165 changed files with 58249 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './style.global.css';
export default class Avatar extends Component {
static propTypes = {
src: PropTypes.string,
fallback: PropTypes.string,
};
static defaultProps = {
fallback: 'assets/images/user-fallback.png',
};
handleError(e) {
e.target.src = this.props.fallback;
}
handleLoad(e) {
e.target.classList.remove('fadein');
}
render() {
if (!this.props.src) return false;
return (
<img
className={`Avatar fade fadein ${this.props.className}`}
onClick={this.props.onClick}
onLoad={e => this.handleLoad(e)}
onError={e => this.handleError(e)}
src={this.props.src}
/>
);
}
}

View File

@@ -0,0 +1,6 @@
.Avatar {
width: 48px;
height: 48px;
border-radius: 100%;
}

View File

@@ -0,0 +1,53 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Transition from 'react-addons-css-transition-group';
import clazz from 'classname';
import './style.global.css';
export default class Button extends Component {
static propTypes = {
show: PropTypes.bool,
fullscreen: PropTypes.bool,
};
static defaultProps = {
show: false,
fullscreen: false,
};
renderContent() {
if (!this.props.show) {
return;
}
return (
<div className={clazz('Loader', this.props.className, {
'Loader--fullscreen': this.props.fullscreen
})}>
<svg className="Loader-circular">
<circle
className="Loader-path"
cx="50"
cy="50"
fill="none"
r="20"
strokeWidth="5"
strokeMiterlimit="10" />
</svg>
</div>
);
}
render() {
return (
<Transition
transitionName="Loader"
transitionEnterTimeout={200}
transitionLeaveTimeout={200}>
{this.renderContent()}
</Transition>
);
}
}

View File

@@ -0,0 +1,82 @@
.Loader {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 1px;
background: rgba(255, 255, 255, .7);
z-index: 999;
&.Loader--fullscreen {
position: fixed;
}
}
.Loader-enter {
opacity: 0;
visibility: hidden;
transition: .2s;
&.Loader-enter-active {
opacity: 1;
visibility: visible;
}
}
.Loader-leave {
opacity: 1;
visibility: visible;
transition: .2s;
&.Loader-leave-active {
opacity: 0;
visibility: hidden;
}
}
.Loader-circular {
position: absolute;
top: 50%;
left: 50%;
display: block;
height: 100px;
width: 100px;
margin-top: -50px;
margin-left: -50px;
animation: Loader-rotate 2s linear infinite;
}
.Loader-path {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
stroke: #039be5;
stroke-width: 3;
animation: Loader-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes Loader-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes Loader-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124;
}
}

View File

@@ -0,0 +1,84 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import clazz from 'classname';
import delegate from 'delegate';
import classes from './style.css';
import { emoji } from 'utils/emoji';
export default class Emoji extends Component {
static propTypes = {
output: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
};
componentDidMount() {
delegate(this.refs.container, 'a.qqemoji', 'click', e => {
e.preventDefault();
e.stopPropagation();
this.props.output(e.target.title);
this.props.close();
});
}
componentDidUpdate() {
if (this.props.show) {
this.refs.container.focus();
}
}
renderEmoji(emoji) {
return emoji.map((e, index) => {
var { key, className } = e;
return (
<a
className={className}
key={index}
title={key} />
);
});
}
render() {
return (
<div
ref="container"
tabIndex="-1"
className={clazz(classes.container, {
[classes.show]: this.props.show
})}
onBlur={e => this.props.close()}>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(0, 15))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(15, 30))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(30, 45))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(45, 60))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(60, 75))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(75, 90))}
</div>
<div className={classes.row}>
{this.renderEmoji(emoji.slice(90, 105))}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,47 @@
.container {
position: absolute;
bottom: 60px;
right: 0;
padding: 8px 12px;
background: #fff;
box-shadow: 0 6px 28px 0 rgba(230, 230, 230, 1);
z-index: 99;
outline: 0;
opacity: 0;
visibility: hidden;
& a {
margin: 4px;
cursor: pointer;
zoom: 1.1;
transition: .2s;
&:hover {
transform: scale(1.2);
}
}
&.show {
opacity: 1;
visibility: visible;
}
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
@media (width <= 800px) {
.container {
bottom: 46px;
padding: 6px 10px;
& a {
margin: 3px;
zoom: .9;
}
}
}

View File

@@ -0,0 +1,56 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import clazz from 'classname';
import './style.global.css';
import TransitionPortal from 'components/TransitionPortal';
export default class Suggestion extends Component {
static propTypes = {
show: PropTypes.bool.isRequired,
list: PropTypes.array.isRequired,
};
renderContent() {
var { show, list, selected } = this.props;
if (!show) {
return false;
}
console.log(window.event);
return (
<div className="Suggestion">
{
list.map((e, index) => {
return (
<div
key={index}
className={clazz('Suggestion-item', {
'Suggestion--selected': e.UserName === selected
})}>
<img src={e.HeadImgUrl} />
<div className="Suggestion-user">
<p className="Suggestion-username" dangerouslySetInnerHTML={{__html: e.RemarkName || e.NickName}} />
</div>
</div>
);
})
}
</div>
);
}
render() {
return (
<TransitionPortal transitionEnterTimeout={0} transitionLeaveTimeout={150} transitionName="Suggestion">
{
this.renderContent()
}
</TransitionPortal>
);
};
}

View File

@@ -0,0 +1,120 @@
.Suggestion {
position: fixed;
bottom: 60px;
left: 311px;
height: calc(100vh - 200px);
background: #fff;
box-shadow: 0 0 24px 0 rgba(0, 0, 0, .2);
border-radius: 1px;
overflow: hidden;
overflow-y: auto;
}
.Suggestion-item {
display: flex;
padding: 6px 12px;
justify-content: flex-start;
align-items: center;
cursor: pointer;
}
.Suggestion--selected,
.Suggestion-item:hover {
background: #405de6;
}
.Suggestion--selected .Suggestion-username,
.Suggestion-item:hover .Suggestion-username {
color: #fff;
}
.Suggestion-item img {
width: 24px;
height: 24px;
margin-right: 12px;
}
.Suggestion-username {
color: #777;
margin-bottom: 2px;
}
.Suggestion-enter {
transform: translateY(24px);
opacity: 0;
transition: .2s cubic-bezier(.5, -.55, .4, 1.55);
}
.Suggestion-enter.Suggestion-enter-active {
transform: translateY(0);
opacity: 1;
}
.Suggestion-leave {
opacity: 1;
transition: .14s;
}
.Suggestion-leave.Suggestion-leave-active {
transform: translateY(-24px);
opacity: 0;
}
.Suggestion-input-user {
display: flex;
padding: 0 23px;
margin: 0 2px;
height: 32px;
align-items: center;
background: rgba(230, 230, 230, 1);
color: #777;
border-radius: 32px;
white-space: nowrap;
}
.Suggestion-input-user img {
height: 20px;
width: 20px;
margin-right: 2px;
}
.Suggestion-input-user * {
display: inline-block;
white-space: nowrap;
}
@media (width <= 800px) {
.Suggestion {
bottom: 48px;
left: 280px;
}
.Suggestion-item {
display: flex;
padding: 6px 12px;
}
.Suggestion-item img {
width: 24px;
height: 24px;
margin-right: 12px;
}
.Suggestion-username {
margin-bottom: 2px;
}
.Suggestion-input-user {
padding: 0 23px;
margin: 0 2px;
height: 32px;
border-radius: 32px;
}
.Suggestion-input-user img {
height: 20px;
width: 20px;
margin-right: 2px;
}
}

View File

@@ -0,0 +1,226 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ipcRenderer } from 'electron';
import clazz from 'classname';
import classes from './style.css';
import Emoji from './Emoji';
export default class MessageInput extends Component {
static propTypes = {
me: PropTypes.object,
sendMessage: PropTypes.func.isRequired,
showMessage: PropTypes.func.isRequired,
user: PropTypes.array.isRequired,
confirmSendImage: PropTypes.func.isRequired,
process: PropTypes.func.isRequired,
};
static defaultProps = {
me: {},
};
canisend() {
var user = this.props.user;
if (
true
&& user.length === 1
&& user.slice(-1).pop().UserName === this.props.me.UserName
) {
this.props.showMessage('Can\'t send messages to yourself.');
return false;
}
return true;
}
async handleEnter(e) {
var message = this.refs.input.value.trim();
var user = this.props.user;
var batch = user.length > 1;
if (
false
|| !this.canisend()
|| !message
|| e.charCode !== 13
) return;
// You can not send message to yourself
Promise.all(
user.filter(e => e.UserName !== this.props.me.UserName).map(
async e => {
let res = await this.props.sendMessage(
e,
{
content: message,
type: 1,
},
true
);
if (!res) {
await this.props.showMessage(batch ? `Sending message to ${e.NickName} has failed!` : 'Failed to send message.');
}
return true;
}
)
);
this.refs.input.value = '';
}
state = {
showEmoji: false
};
toggleEmoji(show = !this.state.showEmoji) {
this.setState({ showEmoji: show });
}
writeEmoji(emoji) {
var input = this.refs.input;
input.value += `[${emoji}]`;
input.focus();
}
async batchProcess(file) {
var message;
var batch = this.props.user.length > 1;
var receiver = this.props.user.filter(e => e.UserName !== this.props.me.UserName);
var showMessage = this.props.showMessage;
if (this.canisend() === false) {
return;
}
for (let user of receiver) {
if (message) {
await this.props.sendMessage(user, message, true)
.catch(ex => showMessage(`Sending message to ${user.NickName} has failed!`));
continue;
}
// Do not repeat upload file, forward the message to another user
message = await this.props.process(file, user);
if (message === false) {
if (batch) {
showMessage(`Send message to ${user.NickName} is failed!`);
continue;
}
// In batch mode just show the failed message
showMessage('Failed to send image.');
}
}
}
async handlePaste(e) {
var args = ipcRenderer.sendSync('file-paste');
if (args.hasImage && this.canisend()) {
e.preventDefault();
if ((await this.props.confirmSendImage(args.filename)) === false) {
return;
}
let parts = [
new window.Blob([new window.Uint8Array(args.raw.data)], { type: 'image/png' })
];
let file = new window.File(parts, args.filename, {
lastModified: new Date(),
type: 'image/png'
});
this.batchProcess(file);
}
}
componentWillReceiveProps(nextProps) {
var input = this.refs.input;
// When user has changed clear the input
if (
true
&& input
&& input.value
&& this.props.user.map(e => e.UserName).join() !== nextProps.user.map(e => e.UserName).join()
) {
input.value = '';
}
}
render() {
var canisend = !!this.props.user.length;
return (
<div
className={
clazz(
classes.container,
this.props.className,
{
[classes.shouldSelectUser]: !canisend,
}
)
}
>
<div
className={classes.tips}
>
You should choose a contact first.
</div>
<input
id="messageInput"
ref="input"
type="text"
placeholder="Type something to send..."
readOnly={!canisend}
onPaste={e => this.handlePaste(e)}
onKeyPress={e => this.handleEnter(e)}
/>
<div className={classes.action}>
<i
className="icon-ion-android-attach"
id="showUploader"
onClick={e => canisend && this.refs.uploader.click()}
/>
<i
className="icon-ion-ios-heart"
id="showEmoji"
onClick={e => canisend && this.toggleEmoji(true)}
style={{
color: 'red',
}}
/>
<input
onChange={e => {
this.batchProcess(e.target.files[0]);
e.target.value = '';
}}
ref="uploader"
style={{
display: 'none',
}}
type="file"
/>
<Emoji
close={e => setTimeout(() => this.toggleEmoji(false), 100)}
output={emoji => this.writeEmoji(emoji)}
show={this.state.showEmoji}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,94 @@
.container {
position: relative;
display: flex;
height: 60px;
align-items: center;
justify-content: space-between;
& input {
height: 60px;
width: 100%;
margin-left: 32px;
line-height: 60px;
border: 0;
padding-right: 17px;
background: 0;
color: #333;
font-size: 14px;
outline: 0;
}
& i {
font-size: 24px;
color: #000;
cursor: pointer;
}
& i:hover {
color: #34b7f1;
}
}
.action {
width: 65px;
margin-right: 17px;
display: flex;
justify-content: space-between;
}
.tips {
position: absolute;
height: 32px;
left: 14px;
top: -32px;
padding: 0 16px;
line-height: 32px;
font-size: 14px;
color: #fff;
background: color(#616161 -alpha(10%));
border-radius: 1px;
opacity: 0;
transform-origin: center bottom;
transition: .15s cubic-bezier(0, 0, .2, 1);
pointer-events: none;
}
.shouldSelectUser:hover .tips {
opacity: 1;
top: calc(-32px - 24px);
}
@media (width <= 800px) {
.container {
height: 46px;
& input {
height: 46px;
margin-left: 20px;
line-height: 46px;
}
& i {
font-size: 16px;
}
}
& .action {
width: 50px;
}
.tips {
height: 24px;
top: -24px;
padding: 0 12px;
line-height: 24px;
font-size: 12px;
}
.shouldSelectUser:hover .tips {
opacity: 1;
top: calc(-24px - 12px);
}
}

View File

@@ -0,0 +1,130 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Transition from 'react-addons-css-transition-group';
import clazz from 'classname';
import './style.css';
import TransitionPortal from '../TransitionPortal';
import { on, off } from 'utils/event';
class ModalBody extends Component {
render() {
return (
<Transition
transitionName="fade"
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}>
<div
className={clazz('Modal-body', this.props.className)}
style={this.props.style}>
{this.props.children}
</div>
</Transition>
);
}
};
class ModalHeader extends Component {
render() {
return (
<div className={clazz('Modal-header', this.props.className)}>
{this.props.children}
</div>
);
}
}
class ModalFooter extends Component {
render() {
return (
<div className={clazz('Modal-footer', this.props.className)}>
{this.props.children}
</div>
);
}
}
class Modal extends Component {
static propTypes = {
show: PropTypes.bool.isRequired,
overlay: PropTypes.bool,
onCancel: PropTypes.func,
transition4overlay: PropTypes.string,
transition4body: PropTypes.string
};
static defaultProps = {
overlay: true,
transition4overlay: 'Modal-overlay',
transition4body: 'Modal-body',
onCancel: Function,
};
renderOverlay() {
if (!this.props.show || !this.props.overlay) {
return;
}
return (
<div
className={clazz('Modal-overlay', this.props.className)}
onClick={this.props.onCancel} />
);
}
renderBody() {
if (!this.props.show) {
return;
}
return (
<div className={clazz('Modal-content', this.props.className)}>
{this.props.children}
</div>
);
}
handleEscKey(e) {
if (e.keyCode === 27 && this.props.show) {
this.props.onCancel();
}
}
componentWillUnmount() {
off(document, 'keydown', this.handleEscKey);
}
componentDidMount() {
this.handleEscKey = this.handleEscKey.bind(this);
on(document, 'keydown', this.handleEscKey);
}
render() {
if (!/MSIE\s8\.0/.test(window.navigator.userAgent)) {
document.body.style.overflow = this.props.show ? 'hidden' : null;
}
return (
<div className="Modal" ref="node">
<Transition
transitionName={this.props.transition4overlay}
transitionEnterTimeout={200}
transitionLeaveTimeout={200}
ref="overlay">
{this.renderOverlay()}
</Transition>
<TransitionPortal
transitionName={this.props.transition4body}
transitionEnterTimeout={200}
transitionLeaveTimeout={140}
ref="content">
{this.renderBody()}
</TransitionPortal>
</div>
);
}
};
export { Modal, ModalBody, ModalHeader, ModalFooter };

View File

@@ -0,0 +1,99 @@
:global .Modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, .7);
z-index: 999;
}
:global .Modal-header {
position: relative;
padding: 8px 20px 4px;
color: #777;
border-bottom: thin solid #d9d9d9;
& .Modal-close {
float: right;
cursor: pointer;
font-size: 14px;
&:hover {
color: $google;
}
}
}
:global .Modal-footer {
padding: 8px 12px;
}
:global .Modal-content {
position: fixed;
top: 50%;
left: 50%;
min-width: 240px;
font-size: 12px;
color: #777;
z-index: 999;
transform: translate(-50%, -50%);
& > div {
pointer-events: all;
background: #fff;
border-radius: 1px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
}
}
:global .Modal-body {
padding: 12px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
}
:global .Modal-overlay-enter {
opacity: 0;
visibility: hidden;
transition: .2s;
&.Modal-overlay-enter-active {
opacity: 1;
visibility: visible;
}
}
:global .Modal-overlay-leave {
opacity: 1;
visibility: visible;
transition: .2s;
&.Modal-overlay-leave-active {
opacity: 0;
visibility: hidden;
}
}
:global .Modal-body-enter {
transform: translate(-50%, -50%) scale(.8);
opacity: 0;
transition: .2s cubic-bezier(.5, -.55, .4, 1.55);
&.Modal-body-enter-active {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
:global .Modal-body-leave {
transform: translate(-50%, -50%);
opacity: 1;
transition: .14s;
&.Modal-body-leave-active {
transform: translate(-50%, -60%);
opacity: 0;
}
}

View File

@@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classes from './style.css';
export default class Avatar extends Component {
static propTypes = {
show: PropTypes.bool.isRequired,
};
static defaultProps = {
show: false,
};
render() {
if (!this.props.show) return false;
return (
<div
className={classes.container}
{...this.props}>
<div>
<img
className="disabledDrag"
src="assets/images/offline.png" />
<h1>Oops, seems like you are offline!</h1>
<button onClick={e => window.location.reload()}>Reload</button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,50 @@
.container {
position: fixed;
top: 40px;
left: 0;
width: 100%;
height: 100%;
background: #f9f9f9;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 99;
& img {
transform: translateY(60px);
width: 300px;
}
& h1 {
color: #777;
font-family: 'Roboto';
font-weight: 100;
}
& button {
cursor: pointer;
font-size: 120%;
margin-top: 4%;
padding: 2% 5%;
border: solid 0 transparent;
color: #fff;
text-transform: uppercase;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .26);
background: #4285f4 radial-gradient(circle at 0 0, rgba(0, 0, 0, .3) 0%, rgba(0, 0, 0, 0) 0) no-repeat;
background-color: #4285f4;
transition: all .2s;
}
& > div {
transform: translateY(-110px);
}
}
@media (width <= 800px) {
.container {
top: 30px;
}
}

View File

@@ -0,0 +1,44 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './style.global.css';
import TransitionPortal from '../TransitionPortal';
export default class Snackbar extends Component {
static propTypes = {
show: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired,
close: PropTypes.func.isRequired,
};
renderContent() {
if (!this.props.show) {
return false;
}
return (
<div className="Snackbar">
<div
className="Snackbar-text"
dangerouslySetInnerHTML={{__html: this.props.text}} />
<div
className="Snackbar-action"
onClick={() => this.props.close()}>
DONE
</div>
</div>
);
}
render() {
return (
<TransitionPortal
transitionEnterTimeout={0}
transitionLeaveTimeout={150}
transitionName="Snackbar">
{this.renderContent()}
</TransitionPortal>
);
}
}

View File

@@ -0,0 +1,71 @@
.Snackbar {
position: fixed;
right: 17px;
bottom: 84px;
display: flex;
height: 48px;
line-height: 48px;
min-width: 288px;
max-width: 568px;
border-radius: 2px;
align-items: center;
justify-content: space-between;
padding-left: 24px;
font-family: 'Roboto';
background-color: #323232;
z-index: 999;
}
.Snackbar-action {
padding: 0 24px;
color: #ff4081;
font-weight: 500;
cursor: pointer;
}
.Snackbar-enter {
transform: translateY(24px);
opacity: 0;
transition: .2s cubic-bezier(.5, -.55, .4, 1.55);
}
.Snackbar-enter.Snackbar-enter-active {
transform: translateY(0);
opacity: 1;
}
.Snackbar-leave {
opacity: 1;
transition: .14s;
}
.Snackbar-leave.Snackbar-leave-active {
transform: translateY(-24px);
opacity: 0;
}
@media screen and (max-width: 800px) {
.Snackbar {
right: 17px;
bottom: 59px;
height: 36px;
line-height: 36px;
min-width: 220px;
max-width: 460px;
padding-left: 12px;
font-size: 12px;
}
.Snackbar-action {
padding: 0 12px;
}
.Snackbar-enter {
transform: translateY(12px);
}
.Snackbar-leave.Snackbar-leave-active {
transform: translateY(-12px);
}
}

View File

@@ -0,0 +1,18 @@
import React, { Component } from 'react';
import blacklist from 'utils/blacklist';
import './style.global.css';
export default class Switch extends Component {
render() {
return (
<span className="Switch">
<input
{...blacklist(this.props, 'className', 'children')}
type="checkbox" />
<span className="Switch--fake" />
</span>
);
}
}

View File

@@ -0,0 +1,66 @@
.Switch {
display: inline-block;
margin: 0;
padding: 0;
cursor: pointer;
}
.Switch input {
display: none;
}
.Switch input:checked + .Switch--fake::before {
background: rgba(157, 166, 216, 1);
}
.Switch input:checked + .Switch--fake::after {
left: auto;
right: 0;
background: #3f51b5;
}
.Switch--fake {
position: relative;
display: inline-block;
width: 35px;
height: 20px;
}
.Switch--fake::before,
.Switch--fake::after {
content: '';
}
.Switch--fake::before {
display: block;
width: 35px;
height: 14px;
margin-top: 3px;
background: rgba(0, 0, 0, .26);
border-radius: 14px;
transition: .5s ease-in-out;
}
.Switch--fake::after {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
border-radius: 20px;
background: #fff;
box-sizing: border-box;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, .5);
transition: .2s;
}
.Switch input:disabled + .Switch--fake::before {
background: rgba(0, 0, 0, .12);
}
.Switch input:disabled + .Switch--fake::after {
left: auto;
right: 0;
background: #bdbdbd;
}

View File

@@ -0,0 +1,26 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Transition from 'react-addons-css-transition-group';
export default class TransitionPortal extends Component {
ele;
componentDidMount() {
this.ele = document.createElement('div');
document.body.appendChild(this.ele);
this.componentDidUpdate();
}
componentDidUpdate() {
ReactDOM.render(<Transition {...this.props}>{this.props.children}</Transition>, this.ele);
}
componentWillUnmount() {
document.body.removeChild(this.ele);
}
render() {
return null;
}
}

View File

@@ -0,0 +1,195 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import clazz from 'classname';
import classes from './style.css';
export default class UserList extends Component {
static propTypes = {
max: PropTypes.number.isRequired,
searching: PropTypes.string.isRequired,
search: PropTypes.func.isRequired,
getList: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
static defaultProps = {
max: 20,
};
state = {
selected: [],
active: '',
};
highlight(offset) {
var scroller = this.refs.list;
var users = Array.from(scroller.querySelectorAll('li[data-userid]'));
var index = users.findIndex(e => e.classList.contains(classes.active));
if (index > -1) {
users[index].classList.remove(classes.active);
}
index += offset;
if (index < 0) {
// Fallback to the last element
index = users.length - 1;
} else if (index > users.length - 1) {
// Fallback to the 1th element
index = 0;
}
var active = users[index];
if (active) {
// Keep active item always in the viewport
active.classList.add(classes.active);
scroller.scrollTop = active.offsetTop + active.offsetHeight - scroller.offsetHeight;
}
}
navigation(e) {
var keyCode = e.keyCode;
var offset = {
// Up
'38': -1,
'40': 1,
}[keyCode];
if (offset) {
this.highlight(offset);
}
if (keyCode !== 13) {
return;
}
var active = this.refs.list.querySelector(`.${classes.active}`);
if (active) {
let userid = active.dataset.userid;
if (!this.state.selected.includes(userid)) {
// Add
this.addSelected(userid, userid);
} else {
// Remove
this.removeSelected(userid, userid);
}
setTimeout(() => this.props.onChange(this.state.selected));
}
}
timer;
search(text) {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.props.search(text);
}, 300);
}
addSelected(userid, active = this.state.active) {
var selected = [
userid,
...this.state.selected,
];
var max = this.props.max;
if (max > 0) {
selected = selected.slice(0, this.props.max);
}
this.setState({
active,
selected,
});
setTimeout(() => this.props.onChange(this.state.selected));
}
removeSelected(userid, active = this.state.active) {
var selected = this.state.selected;
var index = selected.indexOf(userid);
this.setState({
active,
selected: [
...selected.slice(0, index),
...selected.slice(index + 1, selected.length)
]
});
setTimeout(() => this.props.onChange(this.state.selected));
}
toggleSelected(userid) {
if (!this.state.selected.includes(userid)) {
// Add
this.addSelected(userid);
} else {
// Remove
this.removeSelected(userid);
}
setTimeout(() => this.refs.input.focus());
}
renderList() {
var { searching, getList } = this.props;
var list = getList();
if (searching && list.length === 0) {
return (
<li className={classes.notfound}>
<img src="assets/images/crash.png" />
<h3>Can't find any people matching '{searching}'</h3>
</li>
);
}
return list.map((e, index) => {
return (
<li
className={clazz({
[classes.selected]: this.state.selected.includes(e.UserName),
[classes.active]: this.state.active === e.UserName,
})}
data-userid={e.UserName}
key={index}
onClick={ev => this.toggleSelected(e.UserName)}>
<img
className={classes.avatar}
src={e.HeadImgUrl} />
<span
className={classes.username}
dangerouslySetInnerHTML={{__html: e.RemarkName || e.NickName}} />
<i className="icon-ion-android-done-all" />
</li>
);
});
}
render() {
return (
<div className={classes.container}>
<input
autoFocus={true}
onKeyUp={e => this.navigation(e)}
onInput={e => this.search(e.target.value)}
placeholder="Type to Search..."
ref="input"
type="text" />
<ul
className={classes.list}
ref="list">
{this.renderList()}
</ul>
</div>
);
}
}

View File

@@ -0,0 +1,97 @@
.list {
padding: 0;
margin: 0;
list-style: none;
height: 60vh;
width: 45vw;
overflow-x: hidden;
overflow-y: auto;
& li {
position: relative;
display: flex;
margin: 24px;
justify-content: flex-start;
align-items: center;
font-size: 16px;
color: #333;
cursor: pointer;
&:not(.notfound):hover,
&.active,
&.active i {
color: rgba(33, 150, 243, .9) !important;
}
&.selected,
&.selected i {
color: #3f51b5;
}
}
& li i {
position: absolute;
top: 50%;
right: 24px;
font-size: 20px;
color: #ddd;
transform: translateY(-50%);
}
}
.avatar {
height: 32px;
width: 32px;
margin-right: 24px;
border-radius: 0;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, .5);
}
.notfound {
justify-content: center !important;
flex-direction: column;
& img {
width: 240px;
}
& h3 {
font-weight: 100;
white-space: nowrap;
}
}
.username {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
display: inline-block;
}
@media (width <= 800px) {
.list {
& li {
margin: 20px;
font-size: 13px;
}
& li i {
right: 24px;
font-size: 16px;
}
}
.avatar {
height: 24px;
width: 24px;
margin-right: 20px;
}
.notfound {
& img {
width: 200px;
}
}
}

View File

@@ -0,0 +1,47 @@
import React, { Component } from 'react';
import { Modal, ModalBody } from 'components/Modal';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
@inject(stores => ({
me: stores.session.user,
show: stores.addfriend.show,
close: () => stores.addfriend.toggle(false),
sendRequest: stores.addfriend.sendRequest,
}))
@observer
export default class AddFriend extends Component {
addFriend() {
this.props.sendRequest(this.refs.input.value);
this.props.close();
}
render() {
var { me, show, close } = this.props;
return (
<Modal
fullscreen={true}
onCancel={e => close()}
show={show}>
<ModalBody className={classes.container}>
Send friend request first
<input
autoFocus={true}
defaultValue={`Hallo, im ${me && me.User.NickName}`}
ref="input"
type="text" />
<div>
<button onClick={e => this.addFriend()}>Send</button>
<button onClick={e => close()}>Cancel</button>
</div>
</ModalBody>
</Modal>
);
}
}

View File

@@ -0,0 +1,76 @@
.container {
background: rgba(0, 0, 0, .9);
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Roboto';
font-size: 36px;
color: #fff;
font-weight: 100;
letter-spacing: 2px;
word-spacing: 4px;
& input {
height: 64px;
line-height: 64px;
margin-top: 5%;
width: 80%;
text-align: center;
border: none;
background: none;
outline: 0;
font-size: 32px;
font-weight: 100;
color: #fff;
}
& button {
display: inline-block;
height: 36px;
background-color: rgba(99, 99, 99, 0);
font-family: 'Roboto';
font-weight: 500;
color: rgba(33, 150, 243, .9);
line-height: 36px;
text-align: center;
letter-spacing: .4px;
padding: 0 16px;
border: 0;
text-transform: uppercase;
margin: 24px 12px;
font-size: 16px;
outline: 0;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
transition: .2s;
}
& button:hover {
background: rgba(99, 99, 99, .2);
}
}
@media (width <= 800px) {
.container {
font-size: 26px;
word-spacing: 2px;
& input {
height: 46px;
line-height: 46px;
font-size: 24px;
}
& button {
height: 26px;
line-height: 26px;
padding: 0 12px;
margin: 24px 12px;
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,122 @@
import React, { Component } from 'react';
import { Modal, ModalBody } from 'components/Modal';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
import UserList from 'components/UserList';
import helper from 'utils/helper';
@inject(stores => ({
show: stores.addmember.show,
searching: stores.addmember.query,
getList: () => {
var { addmember, contacts } = stores;
if (addmember.query) {
return addmember.list;
}
return contacts.memberList.filter(
e => !helper.isChatRoom(e.UserName)
&& !helper.isFileHelper(e)
&& e.UserName !== stores.session.user.User.UserName
);
},
addMember: async(userids) => {
var roomid = stores.chat.user.UserName;
return stores.addmember.addMember(roomid, userids);
},
getUser: (userid) => {
return stores.contacts.memberList.find(e => e.UserName === userid);
},
search: stores.addmember.search,
close: () => {
stores.addmember.reset();
stores.addmember.toggle(false);
},
}))
@observer
export default class AddMember extends Component {
state = {
selected: [],
};
close() {
this.props.close();
this.setState({
selected: [],
});
}
async add(userids) {
await this.props.addMember(userids);
this.close();
}
renderList() {
var self = this;
var { show, searching, search, getList } = this.props;
if (!show) {
return false;
}
return (
<UserList {...{
ref: 'users',
search,
getList,
searching,
max: -1,
onChange(selected) {
self.setState({
selected,
});
}
}} />
);
}
render() {
return (
<Modal
fullscreen={true}
onCancel={e => this.close()}
show={this.props.show}>
<ModalBody className={classes.container}>
Add Members
<div className={classes.avatars}>
{
this.state.selected.map((e, index) => {
var user = this.props.getUser(e);
return (
<img
key={index}
onClick={ev => this.refs.users.removeSelected(e)}
src={user.HeadImgUrl} />
);
})
}
</div>
{this.renderList()}
<div>
<button
disabled={!this.state.selected.length}
onClick={e => this.add(this.state.selected)}>
Add
</button>
<button onClick={e => this.close()}>Cancel</button>
</div>
</ModalBody>
</Modal>
);
}
}

View File

@@ -0,0 +1,103 @@
.container {
background: #fff;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Roboto';
font-size: 36px;
color: #000;
font-weight: 100;
letter-spacing: 2px;
word-spacing: 4px;
& input {
height: 64px;
line-height: 64px;
width: 80%;
text-align: center;
border: none;
background: none;
outline: 0;
font-size: 32px;
font-weight: 100;
}
& button {
display: inline-block;
height: 36px;
background-color: rgba(99, 99, 99, 0);
font-family: 'Roboto';
font-weight: 500;
color: rgba(33, 150, 243, .9);
line-height: 36px;
text-align: center;
letter-spacing: .4px;
padding: 0 16px;
border: 0;
text-transform: uppercase;
margin: 0 12px;
font-size: 16px;
outline: 0;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
transition: .2s;
}
& button:disabled {
background: rgba(99, 99, 99, .2);
opacity: .5;
}
& button:hover {
background: rgba(99, 99, 99, .2);
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, .3);
cursor: pointer;
}
}
@media (width <= 800px) {
.container {
font-size: 26px;
letter-spacing: 2px;
word-spacing: 2px;
& input {
height: 46px;
line-height: 46px;
font-size: 24px;
}
& button {
height: 26px;
line-height: 26px;
letter-spacing: .4px;
padding: 0 12px;
margin: 0 10px;
font-size: 14px;
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
}
}
}

View File

@@ -0,0 +1,174 @@
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import clazz from 'classname';
import classes from './style.css';
import MessageInput from 'components/MessageInput';
@inject(stores => ({
show: stores.batchsend.show,
close: () => stores.batchsend.toggle(false),
search: stores.batchsend.search,
searching: stores.batchsend.query,
contacts: stores.contacts.memberList,
filtered: stores.batchsend.filtered,
sendMessage: stores.chat.sendMessage,
showMessage: stores.snackbar.showMessage,
me: stores.session.user,
confirmSendImage: async(image) => {
if (!stores.settings.confirmImagePaste) {
return true;
}
var confirmed = await stores.confirmImagePaste.toggle(true, image);
return confirmed;
},
process: stores.chat.process,
}))
@observer
export default class BatchSend extends Component {
state = {
selected: [],
};
close() {
this.setState({
selected: [],
});
this.props.close();
}
componentDidMount() {
this.setState({
selected: [],
});
this.props.search();
}
handleSelected(user) {
var selected = this.state.selected;
var index = selected.findIndex(e => e.UserName === user.UserName);
if (index === -1) {
selected.push(user);
} else {
selected = [
...selected.slice(0, index),
...selected.slice(index + 1, selected.length),
];
}
this.setState({
selected,
});
}
selectAll() {
var contacts = this.props.contacts;
var selected = this.state.selected;
var isall = contacts.length === selected.length;
if (isall) {
// Unselected all user
selected = [];
} else {
selected = contacts.map(e => Object.assign({}, e));
}
this.setState({
selected,
});
}
search(text = '') {
text = text.trim();
clearTimeout(this.search.timer);
this.search.timer = setTimeout(() => {
this.props.search(text);
}, 300);
}
render() {
var { contacts, searching, filtered, showMessage, sendMessage, me = {}, confirmSendImage, process } = this.props;
if (!this.props.show) {
return false;
}
return (
<div className={classes.container}>
<header>
<input
autoFocus={true}
onInput={e => this.search(e.target.value)}
placeholder="Batch to send message, Choose one or more user."
type="text" />
<span>
<i
className={clazz('icon-ion-android-done-all', {
[classes.active]: this.state.selected.length === contacts.length
})}
onClick={() => this.selectAll()}
style={{
marginRight: 20,
}} />
<i
className="icon-ion-android-close"
onClick={e => this.close()} />
</span>
</header>
<ul className={classes.list}>
{
(searching && filtered.length === 0) && (
<div className={classes.notfound}>
<img src="assets/images/crash.png" />
<h1>Can't find any people matching '{searching}'</h1>
</div>
)
}
{
(searching ? filtered : contacts).map((e, index) => {
return (
<li
key={index}
onClick={() => this.handleSelected(e)}>
<div
className={classes.cover}
style={{
backgroundImage: `url(${e.HeadImgUrl})`,
}} />
<span
className={classes.username}
dangerouslySetInnerHTML={{ __html: e.RemarkName || e.NickName }} />
{
this.state.selected.find(user => user.UserName === e.UserName) && (
<i className="icon-ion-android-done" />
)
}
</li>
);
})
}
</ul>
<div className={classes.footer}>
<MessageInput {...{
className: classes.input,
me: me.User,
sendMessage,
showMessage,
user: this.state.selected,
confirmSendImage,
process,
}} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,206 @@
.container {
position: fixed;
top: 40px;
left: 0;
width: 100vw;
background: #fff;
color: #333;
z-index: 9;
& header {
display: flex;
padding: 0 12px;
height: 50px;
line-height: 50px;
justify-content: space-between;
align-items: center;
font-family: 'Roboto';
font-weight: 100;
font-size: 20px;
color: #333;
}
& input {
height: 60px;
width: 100%;
text-align: center;
line-height: 60px;
border: 0;
background: 0;
color: #333;
font-size: 14px;
outline: 0;
}
& header i {
cursor: pointer;
}
& header input {
height: 50px;
padding-right: 17px;
line-height: 50px;
text-align: left;
}
& .active,
& header i:hover {
color: #34b7f1;
}
}
.list {
position: relative;
padding: 0;
padding-top: 8px;
margin: 0;
list-style: none;
height: calc(100vh - 90px - 60px - 8px); /* Height - Header - Search Input - Message Input - Padding */
overflow: hidden;
overflow-y: auto;
& li {
position: relative;
display: inline-block;
padding: 4px 18px;
padding-right: 32px;
margin: 6px 8px;
min-width: 80px;
line-height: 32px;
text-align: left;
border-radius: 48px;
color: #fff;
background: #f7f7f7;
cursor: pointer;
overflow: hidden;
transition: .2s;
}
& li:hover .cover {
opacity: 1;
transform: scale(1.2);
}
& li i {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
line-height: 24px;
text-align: center;
border-radius: 24px;
background: #405de6;
}
}
.cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transform: scale(1);
opacity: .6;
transition: .2s;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, .3);
}
}
.username {
position: relative;
font-size: 13px;
}
.footer {
height: 60px;
box-shadow: inset 0 1px 0 0 #eaedea;
}
.notfound {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
height: 100%;
& img {
width: 240px;
}
& h1 {
font-family: 'Roboto';
font-weight: 100;
color: rgba(0, 0, 0, .8);
letter-spacing: 2px;
word-spacing: 4px;
}
}
@media (width <= 800px) {
.container {
top: 30px;
& header {
padding: 0 10px;
height: 40px;
line-height: 40px;
font-size: 16px;
}
& header input {
height: 40px;
line-height: 40px;
}
}
.list {
height: calc(100vh - 80px - 46px - 8px);
& li {
padding: 2px 14px;
padding-right: 24px;
margin: 4px 6px;
min-width: 70px;
line-height: 28px;
border-radius: 32px;
}
}
.footer {
height: 46px;
& input {
height: 46px;
line-height: 46px;
font-size: 13px;
}
}
.username {
font-size: 12px;
}
.notfound {
& img {
width: 220px;
}
& h1 {
word-spacing: 2px;
}
}
}

View File

@@ -0,0 +1,65 @@
import React, { Component } from 'react';
import { Modal, ModalBody } from 'components/Modal';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
@inject(stores => {
var confirmImagePaste = stores.confirmImagePaste;
return {
show: confirmImagePaste.show,
image: confirmImagePaste.image,
ok: () => {
console.log('ok');
confirmImagePaste.ok();
confirmImagePaste.toggle(false);
},
cancel: () => {
console.log('cancel');
confirmImagePaste.cancel();
confirmImagePaste.toggle(false);
},
};
})
@observer
export default class ConfirmImagePaste extends Component {
navigation(e) {
// User press ESC
if (e.keyCode === 81) {
console.log(81);
this.props.cancel();
}
if (e.keyCode === 13) {
console.log(13);
this.props.ok();
}
}
render() {
var { show, cancel, ok, image } = this.props;
setTimeout(() => {
document.querySelector('#imageInputHidden').focus();
}, 1000);
return (
<Modal
fullscreen={true}
show={show}>
<ModalBody className={classes.container}>
Send image ?
<img src={image} />
<div>
<input onKeyUp={e => this.navigation(e)} id="imageInputHidden" style={{'zIndex': '-1', 'position': 'absolute', 'top': '-20px'}} />
<button onClick={e => ok()}>Send</button>
<button onClick={e => cancel()}>Cancel</button>
</div>
</ModalBody>
</Modal>
);
}
}

View File

@@ -0,0 +1,73 @@
.container {
background: rgba(0, 0, 0, .9);
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Roboto';
font-size: 36px;
color: #fff;
font-weight: 100;
letter-spacing: 2px;
word-spacing: 4px;
& button {
display: inline-block;
height: 36px;
background-color: rgba(99, 99, 99, 0);
font-family: 'Roboto';
font-weight: 500;
color: rgba(33, 150, 243, .9);
line-height: 36px;
text-align: center;
letter-spacing: .4px;
padding: 0 16px;
border: 0;
text-transform: uppercase;
margin: 24px 12px;
font-size: 16px;
outline: 0;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
transition: .2s;
}
& button:hover {
background: rgba(99, 99, 99, .2);
}
& img {
max-width: 70vw;
max-height: 60vh;
margin-top: 20px;
margin-bottom: 30px;
border: 12px solid #fff;
border-radius: 1px;
box-shadow: 0 0 20px rgba(0, 0, 0, .3);
}
}
@media (width <= 800px) {
.container {
font-size: 26px;
word-spacing: 2px;
& button {
height: 26px;
line-height: 26px;
padding: 0 12px;
margin: 24px 12px;
font-size: 14px;
}
& img {
max-width: 54vw;
margin-top: 14px;
margin-bottom: 20px;
border: 6px solid #fff;
}
}
}

View File

@@ -0,0 +1,113 @@
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import clazz from 'classname';
import randomColor from 'randomcolor';
import classes from './style.css';
@inject(stores => ({
filter: stores.contacts.filter,
filtered: stores.contacts.filtered,
getContats: stores.contacts.getContats,
showUserinfo: stores.userinfo.toggle,
}))
@observer
export default class Contacts extends Component {
renderColumns(data, index) {
var list = data.filter((e, i) => i % 3 === index);
return list.map((e, index) => {
return (
<div
className={classes.group}
key={index}>
<div className={classes.header}>
<label>{e.prefix}</label>
<span>{e.list.length} people</span>
<span style={{
position: 'absolute',
left: 0,
bottom: 0,
height: 4,
width: '100%',
background: randomColor(),
}} />
</div>
<div className={classes.list}>
{
e.list.map((e, index) => {
return (
<div
className={classes.item}
key={index}
onClick={() => this.props.showUserinfo(true, e)}>
<div className={classes.avatar}>
<img
src={e.HeadImgUrl}
style={{
height: 32,
width: 32,
}} />
</div>
<div className={classes.info}>
<p
className={classes.username}
dangerouslySetInnerHTML={{__html: e.RemarkName || e.NickName}} />
<p
className={classes.signature}
dangerouslySetInnerHTML={{__html: e.Signature || 'No Signature'}} />
</div>
</div>
);
})
}
</div>
</div>
);
});
}
componentWillMount() {
this.props.filter();
}
render() {
var { query, result } = this.props.filtered;
if (query && result.length === 0) {
return (
<div className={clazz(classes.container, classes.notfound)}>
<div className={classes.inner}>
<img src="assets/images/crash.png" />
<h1>Can't find any people matching '{query}'</h1>
</div>
</div>
);
}
return (
<div className={classes.container}>
<div className={classes.columns}>
<div className={classes.column}>
{
this.renderColumns(result, 0)
}
</div>
<div className={classes.column}>
{
this.renderColumns(result, 1)
}
</div>
<div className={classes.column}>
{
this.renderColumns(result, 2)
}
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,183 @@
.columns {
display: flex;
padding: 30px 17px 0;
justify-content: space-between;
}
.notfound {
display: flex;
height: 100vh;
width: 100vw;
justify-content: center;
align-items: center;
& h1 {
margin-bottom: 25vh;
font-family: 'Roboto';
font-weight: 100;
color: rgba(0, 0, 0, .8);
letter-spacing: 2px;
word-spacing: 4px;
}
}
.inner {
text-align: center;
& img {
width: 220px;
margin-bottom: 30px;
}
}
.column {
width: 274px;
}
.group {
width: 274px;
padding: 8px 0;
margin-bottom: 24px;
box-shadow: 0 0 10px 0 rgba(119, 119, 119, 50%);
}
.header {
position: relative;
width: 140px;
height: 36px;
& label {
position: absolute;
left: 12px;
bottom: 8px;
font-family: 'Helvetica Neue';
font-size: 24px;
font-weight: 300;
color: #000;
}
& span {
position: absolute;
right: 0;
bottom: 8px;
display: block;
font-family: 'Roboto';
font-size: 12px;
color: #9b9b9b;
}
}
.item {
display: flex;
height: 40px;
margin: 10px 0;
padding: 0 10px;
align-items: center;
cursor: pointer;
& p {
transition: .2s;
}
&:hover p {
color: #405de6 !important;
}
& img {
margin-top: 3px;
margin-right: 19px;
border-radius: 32px;
}
}
.avatar,
.info {
display: inline-block;
}
.username,
.signature {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.username {
font-family: 'Helvetica';
font-size: 14px;
color: #4a4a4a;
margin: 0;
margin-bottom: 4px;
}
.signature {
font-family: 'Helvetica';
font-size: 12px;
color: #777;
margin: 0;
}
@media (width <= 800px) {
.columns {
padding: 20px 17px 0;
}
.notfound {
& h1 {
word-spacing: 2px;
}
}
.column {
width: 230px;
}
.group {
width: 230px;
padding: 6px 0;
margin-bottom: 20px;
}
.header {
width: 110px;
height: 26px;
& label {
bottom: 6px;
font-size: 18px;
}
& span {
font-size: 11px;
}
}
.item {
height: 36px;
margin: 8px 0;
padding: 0 8px;
& img {
margin-top: 4px;
margin-right: 14px;
}
}
.avatar img {
width: 28px !important;
height: 28px !important;
border-radius: 28px;
}
.username,
.signature {
max-width: 160px;
}
.username {
font-size: 13px;
margin-bottom: 2px;
}
}

View File

@@ -0,0 +1,56 @@
import React, { Component } from 'react';
import { inject } from 'mobx-react';
import classes from './style.css';
import Switch from 'components/Switch';
@inject(stores => ({
filter: stores.contacts.filter,
showGroup: stores.contacts.showGroup,
toggleGroup: stores.contacts.toggleGroup,
}))
export default class Filter extends Component {
// Improve filter performance
timer;
doFilter(text = '') {
text = text.trim();
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.props.filter(text);
}, 300);
}
handleShowGroup(e) {
this.props.toggleGroup(e.target.checked);
this.doFilter(this.refs.filter.value);
}
componentWillUnmount() {
this.props.filter();
}
render() {
return (
<div className={classes.contacts}>
<input
onInput={e => this.doFilter(e.target.value)}
placeholder="Type something to search..."
ref="filter"
type="text" />
<div className={classes.action}>
<label htmlFor="showGroup">
<span className={classes.options}>Show Groups</span>
<Switch
defaultChecked={this.props.showGroup}
id="showGroup"
onClick={e => this.handleShowGroup(e)} />
</label>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { inject } from 'mobx-react';
import MessageInput from 'components/MessageInput';
@inject(stores => ({
sendMessage: stores.chat.sendMessage,
user: stores.chat.user,
showMessage: stores.snackbar.showMessage,
me: stores.session.user,
confirmSendImage: async(image) => {
if (!stores.settings.confirmImagePaste) {
return true;
}
var confirmed = await stores.confirmImagePaste.toggle(true, image);
return confirmed;
},
process: stores.chat.process,
}))
export default class Message extends Component {
render() {
var { sendMessage, showMessage, user, me = {}, confirmSendImage, process } = this.props;
return (
<MessageInput {...{
sendMessage,
showMessage,
user: user ? [user] : [],
me: me.User,
confirmSendImage,
process,
}} />
);
}
}

View File

@@ -0,0 +1,28 @@
import React, { Component } from 'react';
import classes from './style.css';
export default class Placeholder extends Component {
render() {
return (
<div className={classes.settings}>
<a
className={classes.button}
href="mailto:var.845541909@qq.com?Subject=WeWeChat%20Feedback"
target="_blank">
Send feedback
<i className="icon-ion-ios-email-outline" />
</a>
<a
className={classes.button}
href="https://github.com/Riceneeder/weweChat"
target="_blank">
Fork on Github
<i className="icon-ion-social-github" />
</a>
</div>
);
}
}

View File

@@ -0,0 +1,65 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import clazz from 'classname';
import classes from './style.css';
import Home from './Home';
import Contacts from './Contacts';
import Settings from './Settings';
export default class Footer extends Component {
render() {
var pathname = this.props.location.pathname;
var component = {
'/': Home,
'/contacts': Contacts,
'/settings': Settings,
}[pathname];
return (
<footer className={classes.footer}>
<nav>
<Link
className="link"
tabIndex="-1"
to="/">
<span className={clazz({
[classes.active]: pathname === '/'
})}>
<i className="icon-ion-android-chat" />
</span>
</Link>
<Link
className="link"
tabIndex="-1"
to="/contacts">
<span className={clazz({
[classes.active]: pathname === '/contacts'
})}>
<i className="icon-ion-ios-book-outline" />
</span>
</Link>
<Link
className="link"
tabIndex="-1"
to="/settings">
<span className={clazz({
[classes.active]: pathname === '/settings'
})}>
<i className="icon-ion-android-more-vertical" />
</span>
</Link>
</nav>
<div className={classes.right}>
{
React.createElement(component)
}
</div>
</footer>
);
}
}

View File

@@ -0,0 +1,201 @@
:root {
--icon-color: #777;
--active: #34b7f1;
--shadow-color: #eaedea;
}
.footer {
position: relative;
background: #fff;
box-shadow: inset 0 1px 0 0 var(--shadow-color);
z-index: 9;
& nav {
display: flex;
width: calc(311px - 34px);
height: 60px;
padding: 17px 17px 0;
justify-content: space-between;
box-shadow: inset -1px 0 0 0 var(--shadow-color);
}
& nav span {
display: block;
width: 24px;
height: 27px;
text-align: center;
cursor: pointer;
}
& nav i {
color: var(--icon-color);
font-size: 24px;
cursor: pointer;
}
& nav span:hover i {
color: var(--active) !important;
}
}
.footer .active {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 1px;
width: 100%;
background: var(--active);
}
& i {
color: var(--active) !important;
}
}
.right {
position: fixed;
bottom: 0;
right: 0;
width: calc(100% - 311px);
}
.contacts {
position: relative;
display: flex;
height: 60px;
align-items: center;
justify-content: space-between;
& input {
height: 60px;
width: calc(100% - 200px);
margin-left: 32px;
line-height: 60px;
border: 0;
background: 0;
color: #333;
font-size: 14px;
outline: 0;
}
& label {
display: flex;
height: 23px;
line-height: 23px;
}
& .action {
margin-right: 17px;
display: flex;
justify-content: space-between;
}
}
.settings {
display: flex;
height: 60px;
text-align: right;
justify-content: flex-end;
align-items: center;
& .button {
position: relative;
margin-right: 17px;
width: 166px;
color: rgba(0, 0, 0, .8);
font-size: 14px;
padding: 9px 8px;
font-family: 'Roboto';
border: 0;
border-radius: 2px;
background: 0;
outline: 0;
text-transform: uppercase;
text-align: left;
cursor: pointer;
text-decoration: none;
transform: translateY(4px);
transition: .2s;
&:hover {
background: color(var(--shadow-color) alpha(-50%));
}
}
& .button i {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
}
}
.options {
display: inline-block;
margin-right: 12px;
font-family: 'Roboto';
font-size: 14px;
color: rgba(0, 0, 0, .8);
text-transform: uppercase;
}
@media (width <= 800px) {
.footer {
& nav {
width: calc(280px - 34px);
padding: 14px 17px 0;
height: 46px;
}
& nav span {
width: 20px;
height: 20px;
}
& nav i {
font-size: 16px;
}
}
.right {
width: calc(100% - 280px);
}
.contacts {
height: 46px;
& input {
height: 46px;
width: calc(100% - 200px);
margin-left: 20px;
line-height: 46px;
}
}
.settings {
height: 46px;
& .button {
margin-right: 8px;
width: 130px;
font-size: 12px;
padding: 6px 8px;
transform: translateY(0);
}
& .button i {
font-size: 16px;
}
}
.options {
font-size: 12px;
}
}

View File

@@ -0,0 +1,112 @@
import React, { Component } from 'react';
import { Modal, ModalBody } from 'components/Modal';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
import UserList from 'components/UserList';
@inject(stores => ({
show: stores.forward.show,
searching: stores.forward.query,
getList: () => {
var { forward, contacts } = stores;
if (forward.query) {
return forward.list;
}
return contacts.memberList.filter(e => e.UserName !== stores.session.user.User.UserName);
},
getUser: (userid) => {
return stores.contacts.memberList.find(e => e.UserName === userid);
},
search: stores.forward.search,
send: (userids) => stores.forward.send(userids),
close: () => stores.forward.toggle(false),
}))
@observer
export default class Forward extends Component {
state = {
selected: [],
};
close() {
this.props.close();
this.setState({
selected: [],
});
}
send(userids) {
userids.map(e => {
this.props.send(e);
});
this.close();
}
renderList() {
var self = this;
var { show, searching, search, getList } = this.props;
if (!show) {
return false;
}
return (
<UserList {...{
ref: 'users',
search,
getList,
searching,
max: -1,
onChange(selected) {
self.setState({
selected,
});
}
}} />
);
}
render() {
return (
<Modal
fullscreen={true}
onCancel={e => this.close()}
show={this.props.show}>
<ModalBody className={classes.container}>
Forward Message
<div className={classes.avatars}>
{
this.state.selected.map((e, index) => {
var user = this.props.getUser(e);
return (
<img
key={index}
onClick={ev => this.refs.users.removeSelected(e)}
src={user.HeadImgUrl} />
);
})
}
</div>
{this.renderList()}
<div>
<button
disabled={!this.state.selected.length}
onClick={e => this.send(this.state.selected)}>
Send Message
</button>
<button onClick={e => this.close()}>Cancel</button>
</div>
</ModalBody>
</Modal>
);
}
}

View File

@@ -0,0 +1,103 @@
.container {
background: #fff;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Roboto';
font-size: 36px;
color: #000;
font-weight: 100;
letter-spacing: 2px;
word-spacing: 4px;
& input {
height: 64px;
line-height: 64px;
width: 80%;
text-align: center;
border: none;
background: none;
outline: 0;
font-size: 32px;
font-weight: 100;
}
& button {
display: inline-block;
height: 36px;
background-color: rgba(99, 99, 99, 0);
font-family: 'Roboto';
font-weight: 500;
color: rgba(33, 150, 243, .9);
line-height: 36px;
text-align: center;
letter-spacing: .4px;
padding: 0 16px;
border: 0;
text-transform: uppercase;
margin: 0 12px;
font-size: 16px;
outline: 0;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
transition: .2s;
}
& button:disabled {
background: rgba(99, 99, 99, .2);
opacity: .5;
}
& button:hover {
background: rgba(99, 99, 99, .2);
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, .3);
cursor: pointer;
}
}
@media (width <= 800px) {
.container {
font-size: 26px;
letter-spacing: 2px;
word-spacing: 2px;
& input {
height: 46px;
line-height: 46px;
font-size: 24px;
}
& button {
height: 26px;
line-height: 26px;
letter-spacing: .4px;
padding: 0 12px;
margin: 0 10px;
font-size: 14px;
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
}
}
}

View File

@@ -0,0 +1,27 @@
import React, { Component } from 'react';
import classes from './style.css';
export default class Header extends Component {
getTitle() {
switch (this.props.location.pathname) {
case '/contacts':
return 'Contacts - WeWeChat';
case '/settings':
return 'Settings - WeWeChat';
default:
return 'WeWeChat';
}
}
render() {
return (
<header className={classes.container}>
<h1>{this.getTitle()}</h1>
</header>
);
}
}

View File

@@ -0,0 +1,23 @@
.container h1 {
height: 40px;
line-height: 40px;
margin: 0;
padding: 0;
font-size: 14px;
font-family: "system-ui";
font-weight: normal;
width: 100%;
color: #777;
text-align: center;
background: rgba(255, 255, 255, 1);
-webkit-user-select: none;
-webkit-app-region: drag;
cursor: default;
}
@media (width <= 800px) {
.container h1 {
font-size: 12px;
}
}

View File

@@ -0,0 +1,686 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import { ipcRenderer, remote } from 'electron';
import clazz from 'classname';
import moment from 'moment';
import axios from 'axios';
import classes from './style.css';
import Avatar from 'components/Avatar';
import helper from 'utils/helper';
import { parser as emojiParse } from 'utils/emoji';
import { on, off } from 'utils/event';
@inject(stores => ({
user: stores.chat.user,
sticky: stores.chat.sticky,
empty: stores.chat.empty,
removeChat: stores.chat.removeChat,
messages: stores.chat.messages,
loading: stores.session.loading,
reset: () => {
stores.chat.user = false;
},
isFriend: (id) => {
var user = stores.contacts.memberList.find(e => e.UserName === id) || {};
return helper.isContact(user);
},
showUserinfo: async(isme, user) => {
var caniremove = helper.isChatRoomOwner(stores.chat.user);
if (isme) {
user = stores.session.user.User;
} else {
stores.contacts.memberList.find(e => {
// Try to find contact in your contacts
if (e.UserName === user.UserName) {
return (user = e);
}
});
}
stores.userinfo.toggle(true, user, caniremove);
},
getMessage: (messageid) => {
var list = stores.chat.messages.get(stores.chat.user.UserName);
return list.data.find(e => e.MsgId === messageid);
},
deleteMessage: (messageid) => {
stores.chat.deleteMessage(stores.chat.user.UserName, messageid);
},
showMembers: (user) => {
if (helper.isChatRoom(user.UserName)) {
stores.members.toggle(true, user);
}
},
showContact: (userid) => {
var user = stores.contacts.memberList.find(e => e.UserName === userid);
stores.userinfo.toggle(true, user);
},
showForward: (message) => stores.forward.toggle(true, message),
parseMessage: (message, from) => {
var isChatRoom = message.isme ? false : helper.isChatRoom(message.FromUserName);
var user = from;
message = Object.assign({}, message);
if (isChatRoom) {
let matchs = message.Content.split(':<br/>');
// Get the newest chat room infomation
from = stores.contacts.memberList.find(e => from.UserName === e.UserName);
user = from.MemberList.find(e => e.UserName === matchs[0]);
message.Content = matchs[1];
}
// If user is null, that mean user has been removed from this chat room
return { message, user };
},
showAddFriend: (user) => stores.addfriend.toggle(true, user),
recallMessage: stores.chat.recallMessage,
downloads: stores.settings.downloads,
rememberConversation: stores.settings.rememberConversation,
showConversation: stores.chat.showConversation,
toggleConversation: stores.chat.toggleConversation,
}))
@observer
export default class ChatContent extends Component {
getMessageContent(message) {
var uploading = message.uploading;
switch (message.MsgType) {
case 1:
if (message.location) {
return `
<img class="open-map unload" data-map="${message.location.href}" src="${message.location.image}" />
<label>${message.location.label}</label>
`;
}
// Text message
return emojiParse(message.Content);
case 3:
// Image
let image = message.image;
if (uploading) {
return `
<div>
<img class="open-image unload" data-id="${message.MsgId}" src="${image.src}" data-fallback="${image.fallback}" />
<i class="icon-ion-android-arrow-up"></i>
</div>
`;
}
return `<img class="open-image unload" data-id="${message.MsgId}" src="${image.src}" data-fallback="${image.fallback}" />`;
case 34:
/* eslint-disable */
// Voice
let voice = message.voice;
let times = message.VoiceLength;
let width = 40 + 7 * (times / 2000);
let seconds = 0;
/* eslint-enable */
if (times < 60 * 1000) {
seconds = Math.ceil(times / 1000);
}
return `
<div class="play-voice" style="width: ${width}px" data-voice="${voice.src}">
<i class="icon-ion-android-volume-up"></i>
<span>
${seconds || '60+'}"
</span>
<audio controls="controls">
<source src="${voice.src}" />
</audio>
</div>
`;
case 47:
case 49 + 8:
// External emoji
let emoji = message.emoji;
if (emoji) {
if (uploading) {
return `
<div>
<img class="unload disabledDrag" src="${emoji.src}" data-fallback="${emoji.fallback}" />
<i class="icon-ion-android-arrow-up"></i>
</div>
`;
}
return `<img src="${emoji.src}" class="unload disabledDrag" data-fallback="${emoji.fallback}" />`;
}
return `
<div class="${classes.invalidEmoji}">
<div></div>
<span>Send an emoji, view it on mobile</span>
</div>
`;
case 42:
// Contact Card
let contact = message.contact;
let isFriend = this.props.isFriend(contact.UserName);
let html = `
<div class="${clazz(classes.contact, { 'is-friend': isFriend })}" data-userid="${contact.UserName}">
<img src="${contact.image}" class="unload disabledDrag" />
<div>
<p>${contact.name}</p>
<p>${contact.address}</p>
</div>
`;
if (!isFriend) {
html += `
<i class="icon-ion-android-add" data-userid="${contact.UserName}"></i>
`;
}
html += '</div>';
return html;
case 43:
// Video message
let video = message.video;
if (uploading) {
return `
<div>
<video preload="metadata" controls src="${video.src}"></video>
<i class="icon-ion-android-arrow-up"></i>
</div>
`;
}
if (!video) {
console.error('Invalid video message: %o', message);
return `
Receive an invalid video message, please see the console output.
`;
}
return `
<video preload="metadata" poster="${video.cover}" controls src="${video.src}" />
`;
case 49 + 2000:
// Money transfer
let transfer = message.transfer;
return `
<div class="${classes.transfer}">
<h4>Money Transfer</h4>
<span>💰 ${transfer.money}</span>
<p>如需收钱,请打开手机微信确认收款。</p>
</div>
`;
case 49 + 6:
// File message
let file = message.file;
let download = message.download;
/* eslint-disable */
return `
<div class="${classes.file}" data-id="${message.MsgId}">
<img src="assets/images/filetypes/${helper.getFiletypeIcon(file.extension)}" class="disabledDrag" />
<div>
<p>${file.name}</p>
<p>${helper.humanSize(file.size)}</p>
</div>
${
uploading
? '<i class="icon-ion-android-arrow-up"></i>'
: (download.done ? '<i class="icon-ion-android-more-horizontal is-file"></i>' : '<i class="icon-ion-android-arrow-down is-download"></i>')
}
<div class="logding" style="${uploading ? 'display: block' : 'display: none'}">
<div class="spinner">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
</div>
</div>
`;
/* eslint-enable */
case 49 + 17:
// Location sharing...
return `
<div class="${classes.locationSharing}">
<i class="icon-ion-ios-location"></i>
Location sharing, Please check your phone.
</div>
`;
}
}
renderMessages(list, from) {
return list.data.map((e, index) => {
var { message, user } = this.props.parseMessage(e, from);
var type = message.MsgType;
if ([
// WeChat system message
10000,
// Custome message
19999
].includes(type)) {
return (
<div
key={index}
className={clazz('unread', classes.message, classes.system)}
dangerouslySetInnerHTML={{__html: e.Content}} />
);
}
if (!user) {
return false;
}
return (
<div className={clazz('unread', classes.message, {
// File is uploading
[classes.uploading]: message.uploading === true,
[classes.isme]: message.isme,
[classes.isText]: type === 1 && !message.location,
[classes.isLocation]: type === 1 && message.location,
[classes.isImage]: type === 3,
[classes.isEmoji]: type === 47 || type === 49 + 8,
[classes.isVoice]: type === 34,
[classes.isContact]: type === 42,
[classes.isVideo]: type === 43,
// App messages
[classes.appMessage]: [49 + 2000, 49 + 17, 49 + 6].includes(type),
[classes.isTransfer]: type === 49 + 2000,
[classes.isLocationSharing]: type === 49 + 17,
[classes.isFile]: type === 49 + 6,
})} key={index}>
<div>
<Avatar
src={message.isme ? message.HeadImgUrl : user.HeadImgUrl}
className={classes.avatar}
onClick={ev => this.props.showUserinfo(message.isme, user)}
/>
<p
className={classes.username}
dangerouslySetInnerHTML={{__html: user.DisplayName || user.RemarkName || user.NickName}}
/>
<div className={classes.content}>
<p
onContextMenu={e => this.showMessageAction(message)}
dangerouslySetInnerHTML={{__html: this.getMessageContent(message)}} />
<span className={classes.times}>{ moment(message.CreateTime * 1000).fromNow() }</span>
</div>
</div>
</div>
);
});
}
async handleClick(e) {
var target = e.target;
// Open the image
if (target.tagName === 'IMG'
&& target.classList.contains('open-image')) {
// Get image from cache and convert to base64
let response = await axios.get(target.src, { responseType: 'arraybuffer' });
// eslint-disable-next-line
let base64 = new window.Buffer(response.data, 'binary').toString('base64');
ipcRenderer.send('open-image', {
dataset: target.dataset,
base64,
});
return;
}
// Play the voice message
if (target.tagName === 'DIV'
&& target.classList.contains('play-voice')) {
let audio = target.querySelector('audio');
audio.onplay = () => target.classList.add(classes.playing);
audio.onended = () => target.classList.remove(classes.playing);
audio.play();
return;
}
// Open the location
if (target.tagName === 'IMG'
&& target.classList.contains('open-map')) {
ipcRenderer.send('open-map', {
map: target.dataset.map,
});
}
// Show contact card
if (target.tagName === 'DIV'
&& target.classList.contains('is-friend')) {
this.props.showContact(target.dataset.userid);
}
// Add new friend
if (target.tagName === 'I'
&& target.classList.contains('icon-ion-android-add')) {
this.props.showAddFriend({
UserName: target.dataset.userid
});
}
// Add new friend
if (target.tagName === 'A'
&& target.classList.contains('add-friend')) {
this.props.showAddFriend({
UserName: target.dataset.userid
});
}
// Open file & open folder
if (target.tagName === 'I'
&& target.classList.contains('is-file')) {
let message = this.props.getMessage(e.target.parentElement.dataset.id);
this.showFileAction(message.download);
}
// Download file
if (target.tagName === 'I'
&& target.classList.contains('is-download')) {
let message = this.props.getMessage(e.target.parentElement.dataset.id);
let response = await axios.get(message.file.download, { responseType: 'arraybuffer' });
// eslint-disable-next-line
let base64 = new window.Buffer(response.data, 'binary').toString('base64');
let filename = ipcRenderer.sendSync(
'file-download',
{
filename: `${this.props.downloads}/${message.MsgId}_${message.file.name}`,
raw: base64,
},
);
setTimeout(() => {
message.download = {
done: true,
path: filename,
};
});
}
}
showFileAction(download) {
var templates = [
{
label: 'Open file',
click: () => {
ipcRenderer.send('open-file', download.path);
}
},
{
label: 'Open the folder',
click: () => {
let dir = download.path.split('/').slice(0, -1).join('/');
ipcRenderer.send('open-folder', dir);
}
},
];
var menu = new remote.Menu.buildFromTemplate(templates);
menu.popup(remote.getCurrentWindow());
}
showMessageAction(message) {
var caniforward = [1, 3, 47, 43, 49 + 6].includes(message.MsgType);
var templates = [
{
label: 'Delete',
click: () => {
this.props.deleteMessage(message.MsgId);
}
},
];
var menu;
if (caniforward) {
templates.unshift({
label: 'Forward',
click: () => {
this.props.showForward(message);
}
});
}
if (message.isme
&& message.CreateTime - new Date() < 2 * 60 * 1000) {
templates.unshift({
label: 'Recall',
click: () => {
this.props.recallMessage(message);
}
});
}
if (message.uploading) return;
menu = new remote.Menu.buildFromTemplate(templates);
menu.popup(remote.getCurrentWindow());
}
showMenu() {
var user = this.props.user;
var menu = new remote.Menu.buildFromTemplate([
{
label: 'Toggle the conversation',
click: () => {
this.props.toggleConversation();
}
},
{
type: 'separator',
},
{
label: 'Empty Content',
click: () => {
this.props.empty(user);
}
},
{
type: 'separator'
},
{
label: helper.isTop(user) ? 'Unsticky' : 'Sticky on Top',
click: () => {
this.props.sticky(user);
}
},
{
label: 'Delete',
click: () => {
this.props.removeChat(user);
}
},
]);
menu.popup(remote.getCurrentWindow());
}
handleScroll(e) {
var tips = this.refs.tips;
var viewport = e.target;
var unread = viewport.querySelectorAll(`.${classes.message}.unread`);
var rect = viewport.getBoundingClientRect();
var counter = 0;
Array.from(unread).map(e => {
if (e.getBoundingClientRect().top > rect.bottom) {
counter += 1;
} else {
e.classList.remove('unread');
}
});
if (counter) {
tips.innerHTML = `You has ${counter} unread messages.`;
tips.classList.add(classes.show);
} else {
tips.classList.remove(classes.show);
}
}
scrollBottomWhenSentMessage() {
var { user, messages } = this.props;
var list = messages.get(user.id);
return list.slice(-1).isme;
}
componentWillUnmount() {
!this.props.rememberConversation && this.props.reset();
}
componentDidUpdate() {
var viewport = this.refs.viewport;
var tips = this.refs.tips;
if (viewport) {
let newestMessage = this.props.messages.get(this.props.user.UserName).data.slice(-1)[0];
let images = viewport.querySelectorAll('img.unload');
// Scroll to bottom when you sent message
if (newestMessage
&& newestMessage.isme) {
viewport.scrollTop = viewport.scrollHeight;
return;
}
// Show the unread messages count
if (viewport.scrollTop < this.scrollTop) {
let counter = viewport.querySelectorAll(`.${classes.message}.unread`).length;
if (counter) {
tips.innerHTML = `You has ${counter} unread messages.`;
tips.classList.add(classes.show);
}
return;
}
// Auto scroll to bottom when message has been loaded
Array.from(images).map(e => {
on(e, 'load', ev => {
off(e, 'load');
e.classList.remove('unload');
viewport.scrollTop = viewport.scrollHeight;
this.scrollTop = viewport.scrollTop;
});
on(e, 'error', ev => {
var fallback = ev.target.dataset.fallback;
if (fallback === 'undefined') {
fallback = 'assets/images/broken.png';
}
ev.target.src = fallback;
ev.target.removeAttribute('data-fallback');
off(e, 'error');
});
});
// Hide the unread message count
tips.classList.remove(classes.show);
viewport.scrollTop = viewport.scrollHeight;
this.scrollTop = viewport.scrollTop;
// Mark message has been loaded
Array.from(viewport.querySelectorAll(`.${classes.message}.unread`)).map(e => e.classList.remove('unread'));
}
}
componentWillReceiveProps(nextProps) {
// When the chat user has been changed, show the last message in viewport
if (this.props.user && nextProps.user
&& this.props.user.UserName !== nextProps.user.UserName) {
this.scrollTop = -1;
}
}
render() {
var { loading, showConversation, user, messages } = this.props;
var title = user.RemarkName || user.NickName;
var signature = user.Signature;
if (loading) return false;
return (
<div
className={clazz(classes.container, {
[classes.hideConversation]: !showConversation,
})}
onClick={e => this.handleClick(e)}>
{
user ? (
<div>
<header>
<div className={classes.info}>
<p
dangerouslySetInnerHTML={{__html: title}}
title={title} />
<span
className={classes.signature}
dangerouslySetInnerHTML={{__html: signature || 'No Signature'}}
onClick={e => this.props.showMembers(user)}
title={signature} />
</div>
<i
className="icon-ion-android-more-vertical"
onClick={() => this.showMenu()} />
</header>
<div
className={classes.messages}
onScroll={e => this.handleScroll(e)}
ref="viewport">
{
this.renderMessages(messages.get(user.UserName), user)
}
</div>
</div>
) : (
<div className={clazz({
[classes.noselected]: !user,
})}>
<img
className="disabledDrag"
src="assets/images/noselected.png" />
<h1>No Chat selected :(</h1>
</div>
)
}
<div
className={classes.tips}
ref="tips">
Unread message.
</div>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import { remote } from 'electron';
import clazz from 'classname';
import moment from 'moment';
import classes from './style.css';
import helper from 'utils/helper';
moment.updateLocale('en', {
relativeTime: {
past: '%s',
m: '1 min',
mm: '%d mins',
h: 'an hour',
hh: '%d h',
s: 'now',
ss: '%d s',
},
});
@inject(stores => ({
chats: stores.chat.sessions,
chatTo: stores.chat.chatTo,
selected: stores.chat.user,
messages: stores.chat.messages,
markedRead: stores.chat.markedRead,
sticky: stores.chat.sticky,
removeChat: stores.chat.removeChat,
loading: stores.session.loading,
searching: stores.search.searching,
}))
@observer
export default class Chats extends Component {
getTheLastestMessage(userid) {
var list = this.props.messages.get(userid);
var res;
if (list) {
// Make sure all chatset has be loaded
res = list.data.slice(-1)[0];
}
return res;
}
hasUnreadMessage(userid) {
var list = this.props.messages.get(userid);
if (list) {
return list.data.length !== (list.unread || 0);
}
}
showContextMenu(user) {
var menu = new remote.Menu.buildFromTemplate([
{
label: 'Send Message',
click: () => {
this.props.chatTo(user);
}
},
{
type: 'separator'
},
{
label: helper.isTop(user) ? 'Unsticky' : 'Sticky on Top',
click: () => {
this.props.sticky(user);
}
},
{
label: 'Delete',
click: () => {
this.props.removeChat(user);
}
},
{
label: 'Mark as Read',
click: () => {
this.props.markedRead(user.UserName);
}
},
]);
menu.popup(remote.getCurrentWindow());
}
componentDidUpdate() {
var container = this.refs.container;
var active = container.querySelector(`.${classes.chat}.${classes.active}`);
if (active) {
let rect4active = active.getBoundingClientRect();
let rect4viewport = container.getBoundingClientRect();
// Keep the conversation always in the viewport
if (!(rect4active.top >= rect4viewport.top
&& rect4active.bottom <= rect4viewport.bottom)) {
active.scrollIntoViewIfNeeded();
}
}
}
render() {
var { loading, chats, selected, chatTo, searching } = this.props;
if (loading) return false;
return (
<div className={classes.container}>
<div
className={classes.chats}
ref="container">
{
!searching && chats.map((e, index) => {
var message = this.getTheLastestMessage(e.UserName) || {};
var muted = helper.isMuted(e);
var isTop = helper.isTop(e);
return (
<div
className={clazz(classes.chat, {
[classes.sticky]: isTop,
[classes.active]: selected && selected.UserName === e.UserName
})}
key={index}
onContextMenu={ev => this.showContextMenu(e)}
onClick={ev => chatTo(e)}>
<div className={classes.inner}>
<div className={clazz(classes.dot, {
[classes.green]: !muted && this.hasUnreadMessage(e.UserName),
[classes.red]: muted && this.hasUnreadMessage(e.UserName)
})}>
<img
className="disabledDrag"
src={e.HeadImgUrl}
onError={e => (e.target.src = 'assets/images/user-fallback.png')}
/>
</div>
<div className={classes.info}>
<p
className={classes.username}
dangerouslySetInnerHTML={{__html: e.RemarkName || e.NickName}} />
<span
className={classes.message}
dangerouslySetInnerHTML={{__html: helper.getMessageContent(message) || 'No Message'}} />
</div>
</div>
<span className={classes.times}>
{
message.CreateTime ? moment(message.CreateTime * 1000).fromNow() : ''
}
</span>
</div>
);
})
}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,154 @@
.container {
width: 100%;
}
.chats {
height: calc(100vh - 160px); /* Height - Header - Footer - Search Input */
overflow-x: hidden;
overflow-y: auto;
}
.chat {
position: relative;
display: flex;
height: 48px;
padding: 13px 16px;
justify-content: space-between;
align-items: center;
cursor: pointer;
& img {
height: 48px;
width: 48px;
border-radius: 48px;
}
& > div {
display: flex;
justify-content: space-between;
align-items: center;
}
&.active,
&:hover {
background: #e8edf3;
}
&.sticky::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: red;
}
}
.info {
display: flex;
margin-left: 16px;
justify-content: space-between;
flex-direction: column;
}
.username {
max-width: 170px;
margin: 0;
padding: 0;
padding-right: 4px;
margin-bottom: 12px;
font-family: 'Helvetica';
font-size: 15px;
color: rgba(74, 74, 74, .9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message {
max-width: 170px;
font-size: 14px;
color: #9b9b9b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.times {
font-size: 12px;
color: #979797;
}
.dot {
position: relative;
&::after {
position: absolute;
top: -8px;
right: -12px;
width: 20px;
height: 20px;
}
&.green::after {
content: '';
background: url(../../../../assets/images/messageGreen.png) 0 0 no-repeat;
background-size: 100% 100%;
}
&.red::after {
content: '';
background: url(../../../../assets/images/messageRed.png) 0 0 no-repeat;
background-size: 100% 100%;
}
}
@media (width <= 800px) {
.chats {
height: calc(100vh - 86px - 46px);
}
.chat {
height: 36px;
padding: 8px 16px;
& img {
height: 36px;
width: 36px;
}
&.sticky::before {
width: 4px;
}
}
.info {
margin-left: 12px;
}
.username {
max-width: 160px;
margin-bottom: 4px;
font-size: 13px;
}
.message {
max-width: 160px;
font-size: 12px;
}
.times {
font-size: 11px;
}
.dot {
&::after {
bottom: 3px;
width: 6px;
height: 6px;
border-radius: 6px;
}
}
}

View File

@@ -0,0 +1,232 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
@inject(stores => ({
history: stores.search.history,
searching: stores.search.searching,
toggle: stores.search.toggle,
filter: stores.search.filter,
result: stores.search.result,
getPlaceholder: () => {
stores.contacts.filter('', true);
return stores.contacts.filtered.result;
},
chat: async(user) => {
stores.chat.chatTo(user);
stores.search.reset();
await stores.search.addHistory(user);
},
clear: (e) => {
e.preventDefault();
e.stopPropagation();
stores.search.clearHistory();
stores.search.reset();
}
}))
@observer
export default class SearchBar extends Component {
timer;
filter(text = '') {
text = text.trim();
clearTimeout(this.filter.timer);
this.filter.timer = setTimeout(() => {
this.props.filter(text);
}, 300);
}
handleBlur(value) {
setTimeout(() => {
if (!value) {
this.props.toggle(false);
}
}, 500);
}
chatTo(user) {
this.props.chat(user);
this.refs.search.value = '';
document.querySelector('#messageInput').focus();
}
highlight(offset) {
var scroller = this.refs.dropdown;
var users = Array.from(scroller.querySelectorAll(`.${classes.user}`));
var index = users.findIndex(e => e.classList.contains(classes.active));
if (index > -1) {
users[index].classList.remove(classes.active);
}
index += offset;
if (index < 0) {
// Fallback to the last element
index = users.length - 1;
} else if (index > users.length - 1) {
// Fallback to the 1th element
index = 0;
}
var active = users[index];
if (active) {
// Keep active item always in the viewport
active.classList.add(classes.active);
scroller.scrollTop = active.offsetTop + active.offsetHeight - scroller.offsetHeight;
}
}
navigation(e) {
var { result, history, getPlaceholder } = this.props;
// User press ESC
if (e.keyCode === 27) {
e.target.blur();
}
if (![
38, // Up
40, // Down
13, // Enter
].includes(e.keyCode)) {
return;
}
switch (e.keyCode) {
case 38:
// Up
this.highlight(-1);
break;
case 40:
// Down
this.highlight(1);
break;
case 13:
let active = this.refs.dropdown.querySelector(`.${classes.user}.${classes.active}`);
if (!active) {
break;
}
this.chatTo([...result.friend, ...result.groups, ...history, ...getPlaceholder()].find(e => e.UserName === active.dataset.userid));
}
}
renderUser(user) {
return (
<div
className={classes.user}
onClick={e => this.chatTo(user)} data-userid={user.UserName}>
<img src={user.HeadImgUrl} />
<div className={classes.info}>
<p
className={classes.username}
dangerouslySetInnerHTML={{__html: user.RemarkName || user.NickName}} />
<span
className={classes.signature}
dangerouslySetInnerHTML={{__html: user.Signature || 'No Signature'}} />
</div>
</div>
);
}
renderList(list, title) {
if (!list.length) return false;
return (
<div>
<header>
<h3>{title}</h3>
</header>
{
list.map((e, index) => {
return (
<div key={index}>
{this.renderUser(e)}
</div>
);
})
}
</div>
);
}
renderHistory(list) {
return (
<div>
<header>
<h3>History</h3>
<a
href=""
onClick={e => this.props.clear(e)}>
CLEAR
</a>
</header>
{
list.map((e, index) => {
return (
<div key={index}>
{this.renderUser(e)}
</div>
);
})
}
</div>
);
}
renderPlaceholder() {
var list = this.props.getPlaceholder();
return list.map((e, index) => {
return (
<div key={index}>
{this.renderList(e.list, e.prefix)}
</div>
);
});
}
render() {
var { searching, history, result } = this.props;
return (
<div className={classes.container}>
<i className="icon-ion-ios-search-strong" />
<input
id="search"
onBlur={e => this.handleBlur(e.target.value)}
onFocus={e => this.filter(e.target.value)}
onInput={e => this.filter(e.target.value)}
onKeyUp={e => this.navigation(e)}
placeholder="Search ..."
ref="search"
type="text" />
{
searching && (
<div
className={classes.dropdown}
ref="dropdown">
{
!result.query && (history.length ? this.renderHistory(history) : this.renderPlaceholder())
}
{this.renderList(result.friend, 'Friend')}
{this.renderList(result.groups, 'Group')}
</div>
)
}
</div>
);
}
}

View File

@@ -0,0 +1,176 @@
.container {
position: relative;
height: 60px;
width: 100%;
box-shadow: inset 0 -1px 0 0 #eaedea;
& input {
height: 60px;
width: 100%;
line-height: 60px;
background: none;
outline: 0;
border: 0;
font-family: 'Helvetica';
font-size: 24px;
font-weight: 100;
text-indent: 62px;
}
& i {
position: absolute;
left: 14px;
top: 50%;
font-size: 28px;
color: #d9d9d9;
transform: translateY(-50%);
}
}
.dropdown {
position: absolute;
width: 100%;
top: 60px;
left: 0;
height: calc(100vh - 100px - 60px); /* Height - Header - Footer - Search input */
overflow-y: auto;
overflow-x: hidden;
& header {
display: flex;
padding: 13px 16px;
color: #777;
font-family: 'Roboto';
justify-content: space-between;
align-items: baseline;
}
& header h3 {
margin: 0;
font-family: 'Helvetica';
font-weight: 100;
color: #000;
border-bottom: 1px solid #000;
padding: 0 4px 2px 0;
text-transform: uppercase;
}
& header a {
font-size: 12px;
text-decoration: none;
color: #405de6;
}
}
.user {
position: relative;
display: flex;
padding: 13px 16px;
justify-content: flex-start;
align-items: center;
cursor: pointer;
& img {
height: 32px;
width: 32px;
border-radius: 32px;
}
& > div {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&.active,
&:hover {
background: #e8edf3;
}
}
.info {
margin-left: 16px;
}
.username {
max-width: 170px;
margin: 0;
padding: 0;
margin-bottom: 4px;
font-family: 'Helvetica';
font-size: 15px;
color: rgba(74, 74, 74, .9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signature {
max-width: 170px;
font-size: 14px;
color: #9b9b9b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (width <= 800px) {
.container {
height: 46px;
& input {
height: 46px;
line-height: 46px;
font-size: 16px;
text-indent: 36px;
}
& i {
left: 14px;
font-size: 20px;
}
}
.dropdown {
top: 46px;
height: calc(100vh - 86px - 46px);
& header {
padding: 6px 16px;
}
& header h3 {
padding: 0 8px 2px 0;
}
& header a {
font-size: 11px;
}
}
.user {
padding: 6px 16px;
& img {
height: 24px;
width: 24px;
border-radius: 24px;
}
}
.info {
margin-left: 16px;
}
.username {
max-width: 160px;
font-size: 13px;
margin-bottom: 2px;
}
.signature {
max-width: 160px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,56 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import clazz from 'classname';
import classes from './style.css';
import Loader from 'components/Loader';
import SearchBar from './SearchBar';
import Chats from './Chats';
import ChatContent from './ChatContent';
@inject(stores => ({
loading: stores.session.loading,
showConversation: stores.chat.showConversation,
toggleConversation: stores.chat.toggleConversation,
showRedIcon: stores.settings.showRedIcon,
newChat: () => stores.newchat.toggle(true),
}))
@observer
export default class Home extends Component {
componentDidMount() {
this.props.toggleConversation(true);
}
render() {
return (
<div className={classes.container}>
<Loader
fullscreen={true}
show={this.props.loading} />
<div className={clazz(classes.inner, {
[classes.hideConversation]: !this.props.showConversation
})}>
<div className={classes.left}>
<SearchBar />
<Chats />
{
this.props.showRedIcon && (
<div
className={classes.addChat}
onClick={() => this.props.newChat()}>
<i className="icon-ion-android-add" />
</div>
)
}
</div>
<div className={classes.right}>
<ChatContent />
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,60 @@
.inner {
display: flex;
justify-content: space-between;
}
.inner.hideConversation {
& .left {
display: none;
}
& .right {
width: 100vw;
}
}
.left {
position: relative;
width: 311px;
box-shadow: inset -1px 0 0 0 #eaedea;
& .addChat {
position: absolute;
right: -20px;
top: 40px;
height: 40px;
width: 40px;
border-radius: 100%;
line-height: 40px;
text-align: center;
font-size: 20px;
background: #e1306c;
box-shadow: 0 0 24px 0 rgba(119, 119, 119, .5);
cursor: pointer;
z-index: 9;
}
}
.right {
width: calc(100vw - 311px);
}
@media (width <= 800px) {
.left {
width: 280px;
& .addChat {
right: -15px;
top: 30px;
height: 30px;
width: 30px;
line-height: 30px;
font-size: 15px;
}
}
.right {
width: calc(100vw - 280px);
}
}

68
src/js/pages/Layout.css Normal file
View File

@@ -0,0 +1,68 @@
.container {
height: calc(100vh - 100px);
overflow: hidden;
overflow-y: auto;
background: rgba(255, 255, 255, .8);
box-shadow: inset 0 1px 0 0 #eaedea;
filter: blur(0);
transition: .2s;
&.blur {
filter: blur(6px);
}
}
.dragDropHolder {
position: fixed;
display: flex;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, .7);
font-family: 'Roboto';
color: #777;
z-index: 999;
opacity: 0;
visibility: hidden;
transition: .2s;
pointer-events: none;
&.show {
opacity: 1;
visibility: visible;
}
& i {
margin-top: 24px;
font-size: 48px;
}
& h2 {
font-weight: 100;
}
& img {
margin: 12px;
}
& .inner {
display: flex;
width: 60vw;
height: 56vh;
padding-top: 14px;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
@media (width <= 800px) {
.container {
height: calc(100vh - 86px);
}
}

204
src/js/pages/Layout.js Normal file
View File

@@ -0,0 +1,204 @@
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import { ipcRenderer, remote } from 'electron';
import classes from './Layout.css';
import Header from './Header';
import Footer from './Footer';
import Login from './Login';
import UserInfo from './UserInfo';
import AddFriend from './AddFriend';
import NewChat from './NewChat';
import Members from './Members';
import AddMember from './AddMember';
import BatchSend from './BatchSend';
import Forward from './Forward';
import ConfirmImagePaste from './ConfirmImagePaste';
import Loader from 'components/Loader';
import Snackbar from 'components/Snackbar';
import Offline from 'components/Offline';
@inject(stores => ({
isLogin: () => !!stores.session.auth,
loading: stores.session.loading,
message: stores.snackbar.text,
show: stores.snackbar.show,
process: stores.chat.process,
reconnect: stores.session.checkTimeout,
close: () => stores.snackbar.toggle(false),
canidrag: () => !!stores.chat.user && !stores.batchsend.show,
}))
@observer
export default class Layout extends Component {
state = {
offline: false,
};
componentDidMount() {
var templates = [
{
label: 'Undo',
role: 'undo',
}, {
label: 'Redo',
role: 'redo',
}, {
type: 'separator',
}, {
label: 'Cut',
role: 'cut',
}, {
label: 'Copy',
role: 'copy',
}, {
label: 'Paste',
role: 'paste',
}, {
type: 'separator',
}, {
label: 'Select all',
role: 'selectall',
},
];
var menu = new remote.Menu.buildFromTemplate(templates);
var canidrag = this.props.canidrag;
document.body.addEventListener('contextmenu', e => {
e.preventDefault();
let node = e.target;
while (node) {
if (node.nodeName.match(/^(input|textarea)$/i)
|| node.isContentEditable) {
menu.popup(remote.getCurrentWindow());
break;
}
node = node.parentNode;
}
});
window.addEventListener('offline', () => {
this.setState({
offline: true,
});
});
window.addEventListener('online', () => {
// Reconnect to wechat
this.props.reconnect();
this.setState({
offline: false,
});
});
if (window.process.platform === 'win32') {
document.body.classList.add('isWin');
}
window.ondragover = e => {
if (canidrag()) {
this.refs.holder.classList.add(classes.show);
this.refs.viewport.classList.add(classes.blur);
}
// If not st as 'copy', electron will open the drop file
e.dataTransfer.dropEffect = 'copy';
return false;
};
window.ondragleave = () => {
if (!canidrag()) return false;
this.refs.holder.classList.remove(classes.show);
this.refs.viewport.classList.remove(classes.blur);
};
window.ondragend = e => {
return false;
};
window.ondrop = e => {
var files = e.dataTransfer.files;
e.preventDefault();
e.stopPropagation();
if (files.length && canidrag()) {
Array.from(files).map(e => this.props.process(e));
}
this.refs.holder.classList.remove(classes.show);
this.refs.viewport.classList.remove(classes.blur);
return false;
};
}
render() {
var { isLogin, loading, show, close, message, location } = this.props;
if (!window.navigator.onLine) {
return (
<Offline show={true} style={{
top: 0,
paddingTop: 30
}} />
);
}
if (!isLogin()) {
return <Login />;
}
ipcRenderer.send('logined');
return (
<div>
<Snackbar
close={close}
show={show}
text={message} />
<Loader show={loading} />
<Header location={location} />
<div
className={classes.container}
ref="viewport">
{this.props.children}
</div>
<Footer
location={location}
ref="footer" />
<UserInfo />
<AddFriend />
<NewChat />
<Members />
<BatchSend />
<AddMember />
<ConfirmImagePaste />
<Forward />
<Offline show={this.state.offline} />;
<div
className={classes.dragDropHolder}
ref="holder">
<div className={classes.inner}>
<div>
<img src="assets/images/filetypes/image.png" />
<img src="assets/images/filetypes/word.png" />
<img src="assets/images/filetypes/pdf.png" />
<img src="assets/images/filetypes/archive.png" />
<img src="assets/images/filetypes/video.png" />
<img src="assets/images/filetypes/audio.png" />
</div>
<i className="icon-ion-ios-cloud-upload-outline" />
<h2>Drop your file here</h2>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,59 @@
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import classes from './style.css';
@inject(stores => ({
avatar: stores.session.avatar,
code: stores.session.code,
getCode: stores.session.getCode,
}))
@observer
export default class Login extends Component {
componentDidMount() {
this.props.getCode();
}
renderUser() {
return (
<div className={classes.inner}>
{
<img
className="disabledDrag"
src={this.props.avatar} />
}
<p>Scan successful</p>
<p>Confirm login on mobile WeChat</p>
</div>
);
}
renderCode() {
var { code } = this.props;
return (
<div className={classes.inner}>
{
code && (<img className="disabledDrag" src={`https://login.weixin.qq.com/qrcode/${code}`} />)
}
<a href={window.location.pathname + '?' + +new Date()}>Refresh the QR Code</a>
<p>Scan to log in to WeChat</p>
<p>Log in on phone to use WeChat on Web</p>
</div>
);
}
render() {
return (
<div className={classes.container}>
{
this.props.avatar ? this.renderUser() : this.renderCode()
}
</div>
);
}
}

View File

@@ -0,0 +1,55 @@
.container {
position: fixed;
display: flex;
width: 100vw;
height: 100vh;
justify-content: center;
align-items: center;
background: #fff;
}
.inner {
display: inline-block;
width: 300px;
margin: 0 auto;
margin-top: 44px;
text-align: center;
& img {
display: block;
width: 100%;
}
& p {
color: #000;
font-family: "Helvetica-Light";
}
& p:last-child {
margin-top: 20px;
color: #888;
font-size: 16px;
}
& a {
text-decoration: none;
border-bottom: 1px solid #ddd;
cursor: pointer;
transition: .2s;
}
& a:hover {
border-bottom-color: #777;
}
}
@media (width <= 800px) {
.inner {
width: 260px;
& p:last-child {
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,119 @@
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import classes from './style.css';
import helper from 'utils/helper';
@inject(stores => ({
show: stores.members.show,
close: () => stores.members.toggle(false),
user: stores.members.user,
list: stores.members.list,
search: stores.members.search,
searching: stores.members.query,
filtered: stores.members.filtered,
showUserinfo: async(user) => {
var me = stores.session.user.User;
var caniremove = helper.isChatRoomOwner(stores.members.user);
if (user.UserName === me.UserName) {
user = me;
} else {
stores.contacts.memberList.find(e => {
// Try to find contact in contacts
if (e.UserName === user.UserName) {
return (user = e);
}
});
}
stores.userinfo.toggle(true, user, caniremove);
},
addMember: () => {
stores.members.toggle(false);
stores.addmember.toggle(true);
}
}))
@observer
export default class Members extends Component {
render() {
var { user, searching, list, filtered } = this.props;
if (!this.props.show) {
return false;
}
return (
<div className={classes.container}>
<header>
<span dangerouslySetInnerHTML={{ __html: `Group '${user.NickName}' has ${list.length} members` }} />
<span>
<i
className="icon-ion-android-add"
onClick={e => this.props.addMember()}
style={{
marginRight: 20,
}} />
<i
className="icon-ion-android-close"
onClick={e => this.props.close()} />
</span>
</header>
<ul className={classes.list}>
{
(searching && filtered.length === 0) && (
<div className={classes.notfound}>
<img src="assets/images/crash.png" />
<h1>Can't find any people matching '{searching}'</h1>
</div>
)
}
{
(searching ? filtered : list).map((e, index) => {
var pallet = e.pallet || [];
var frontColor = pallet[1] || [0, 0, 0];
return (
<li
key={index}
onClick={ev => this.props.showUserinfo(e)}
style={{
color: `rgb(
${frontColor[0]},
${frontColor[1]},
${frontColor[2]}
)`,
}}>
<div
className={classes.cover}
style={{
backgroundImage: `url(${e.HeadImgUrl})`,
}} />
<span
className={classes.username}
dangerouslySetInnerHTML={{ __html: e.NickName }} />
</li>
);
})
}
</ul>
<div className={classes.footer}>
<input
autoFocus={true}
id="messageInput"
maxLength={30}
onInput={e => this.props.search(e.target.value)}
placeholder="Type something to search..."
ref="input"
type="text" />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,183 @@
.container {
position: fixed;
top: 40px;
left: 0;
width: 100vw;
background: #fff;
color: #333;
z-index: 9;
& header {
display: flex;
padding: 0 12px;
height: 50px;
line-height: 50px;
justify-content: space-between;
align-items: center;
font-family: 'Roboto';
font-weight: 100;
font-size: 20px;
color: #333;
box-shadow: inset 0 -1px 0 0 #eaedea;
}
& header i {
cursor: pointer;
}
& header span:nth-child(1) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 17px;
}
& header i:hover {
color: #34b7f1;
}
}
.list {
position: relative;
padding: 0;
margin: 0;
list-style: none;
height: calc(100vh - 90px - 60px);
overflow: hidden;
overflow-y: auto;
& li {
position: relative;
display: inline-block;
padding: 0 14px;
margin: 6px 8px;
height: 32px;
min-width: 70px;
line-height: 32px;
border-radius: 1px;
text-align: center;
cursor: pointer;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .3);
overflow: hidden;
transition: .2s;
}
& li:hover .cover {
filter: opacity(1);
transform: scale(1.2);
}
}
.cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: opacity(.6);
transform: scale(1);
transition: .2s;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, .3);
}
}
.username {
position: relative;
font-size: 13px;
}
.footer {
height: 60px;
box-shadow: inset 0 1px 0 0 #eaedea;
& input {
height: 60px;
width: 100%;
text-align: center;
line-height: 60px;
border: 0;
background: 0;
color: #333;
font-size: 14px;
outline: 0;
}
}
.notfound {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
height: 100%;
& img {
width: 240px;
}
& h1 {
font-family: 'Roboto';
font-weight: 100;
color: rgba(0, 0, 0, .8);
letter-spacing: 2px;
word-spacing: 4px;
}
}
@media (width <= 800px) {
.container {
& header {
padding: 0 10px;
height: 40px;
line-height: 40px;
font-size: 16px;
}
}
.list {
height: calc(100vh - 80px - 46px);
& li {
padding: 0 10px;
margin: 4px 6px;
height: 28px;
min-width: 70px;
line-height: 28px;
}
}
.footer {
height: 46px;
& input {
height: 46px;
line-height: 46px;
font-size: 13px;
}
}
.username {
font-size: 12px;
}
.notfound {
& img {
width: 220px;
}
& h1 {
word-spacing: 2px;
}
}
}

View File

@@ -0,0 +1,126 @@
import React, { Component } from 'react';
import { Modal, ModalBody } from 'components/Modal';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
import UserList from 'components/UserList';
import helper from 'utils/helper';
@inject(stores => ({
show: stores.newchat.show,
searching: stores.newchat.query,
getList: () => {
var { newchat, contacts } = stores;
if (newchat.query) {
return newchat.list;
}
return contacts.memberList;
},
getUser: (userid) => {
return stores.contacts.memberList.find(e => e.UserName === userid);
},
search: stores.newchat.search,
createChatRoom: stores.newchat.createChatRoom,
close: () => {
stores.newchat.reset();
stores.newchat.toggle(false);
},
chatTo: (user) => stores.chat.chatTo(user),
}))
@observer
export default class NewChat extends Component {
state = {
selected: [],
};
async chat() {
var selected = this.state.selected;
if (selected.length === 1) {
this.props.chatTo(this.props.getUser(selected[0]));
} else {
// You can not create a chat room by another chat room
let user = await this.props.createChatRoom(selected.filter(e => !helper.isChatRoom(e)));
this.props.chatTo(user);
}
this.close();
setTimeout(() => {
document.querySelector('#messageInput').focus();
});
}
close() {
this.props.close();
this.setState({
selected: [],
});
}
renderList() {
var self = this;
var { show, searching, search, getList } = this.props;
if (!show) {
return false;
}
return (
<UserList {...{
ref: 'users',
search,
getList,
searching,
onChange(selected) {
self.setState({
selected,
});
}
}} />
);
}
render() {
return (
<Modal
fullscreen={true}
onCancel={e => this.props.close()}
show={this.props.show}>
<ModalBody className={classes.container}>
New Chat ({this.state.selected.length} / 20)
<div className={classes.avatars}>
{
this.state.selected.map((e, index) => {
var user = this.props.getUser(e);
return (
<img
key={index}
onClick={ev => this.refs.users.removeSelected(e)}
src={user.HeadImgUrl} />
);
})
}
</div>
{this.renderList()}
<div>
<button
disabled={!this.state.selected.length}
onClick={e => this.chat()}>
Chat
</button>
<button onClick={e => this.close()}>Cancel</button>
</div>
</ModalBody>
</Modal>
);
}
}

View File

@@ -0,0 +1,104 @@
.container {
background: #fff;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-family: 'Roboto';
font-size: 36px;
color: #000;
font-weight: 100;
letter-spacing: 2px;
word-spacing: 4px;
& input {
height: 64px;
line-height: 64px;
width: 80%;
text-align: center;
border: none;
background: none;
outline: 0;
font-size: 32px;
font-weight: 100;
}
& button {
display: inline-block;
height: 36px;
background-color: rgba(99, 99, 99, 0);
font-family: 'Roboto';
font-weight: 500;
color: rgba(33, 150, 243, .9);
line-height: 36px;
text-align: center;
letter-spacing: .4px;
padding: 0 16px;
border: 0;
border-radius: 1px;
text-transform: uppercase;
margin: 0 12px;
font-size: 16px;
outline: 0;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
transition: .2s;
}
& button:disabled {
background: rgba(99, 99, 99, .2);
opacity: .5;
}
& button:hover {
background: rgba(99, 99, 99, .2);
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, .3);
cursor: pointer;
}
}
@media (width <= 800px) {
.container {
font-size: 26px;
letter-spacing: 2px;
word-spacing: 2px;
& input {
height: 46px;
line-height: 46px;
font-size: 24px;
}
& button {
height: 26px;
line-height: 26px;
letter-spacing: .4px;
padding: 0 12px;
margin: 0 10px;
font-size: 14px;
}
}
.avatars {
min-height: 20px;
& img {
height: 24px;
width: 24px;
margin: 12px 4px;
}
}
}

View File

@@ -0,0 +1,180 @@
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import classes from './style.css';
import Switch from 'components/Switch';
import Avatar from 'components/Avatar';
import helper from 'utils/helper';
@inject(stores => ({
alwaysOnTop: stores.settings.alwaysOnTop,
setAlwaysOnTop: stores.settings.setAlwaysOnTop,
showOnTray: stores.settings.showOnTray,
setShowOnTray: stores.settings.setShowOnTray,
showNotification: stores.settings.showNotification,
setShowNotification: stores.settings.setShowNotification,
startup: stores.settings.startup,
setStartup: stores.settings.setStartup,
downloads: stores.settings.downloads,
setDownloads: stores.settings.setDownloads,
confirmImagePaste: stores.settings.confirmImagePaste,
setConfirmImagePaste: stores.settings.setConfirmImagePaste,
blockRecall: stores.settings.blockRecall,
setBlockRecall: stores.settings.setBlockRecall,
rememberConversation: stores.settings.rememberConversation,
setRememberConversation: stores.settings.setRememberConversation,
showRedIcon: stores.settings.showRedIcon,
setShowRedIcon: stores.settings.setShowRedIcon,
user: stores.session.user,
logout: stores.session.logout,
}))
@observer
export default class Settings extends Component {
choiceDownloadDir() {
this.refs.downloads.click();
}
componentDidMount() {
this.refs.downloads.webkitdirectory = true;
}
render() {
var {
alwaysOnTop,
setAlwaysOnTop,
showOnTray,
setShowOnTray,
showNotification,
setShowNotification,
startup,
setStartup,
downloads,
setDownloads,
confirmImagePaste,
setConfirmImagePaste,
blockRecall,
setBlockRecall,
rememberConversation,
setRememberConversation,
showRedIcon,
setShowRedIcon,
user,
} = this.props;
return (
<div className={classes.container}>
<div className={classes.column}>
<h2>Settings</h2>
<ul>
{
user && (
<li className={classes.user}>
<Avatar src={this.props.user.User.HeadImgUrl} />
<button onClick={e => this.props.logout()}>Logout</button>
</li>
)
}
<li className={classes.downloads}>
<div>
<input
onChange={e => setDownloads(e.target.files[0])}
ref="downloads"
type="file" />
<p>Downloads</p>
<p onClick={e => this.choiceDownloadDir()}>{downloads}</p>
</div>
<button onClick={e => this.choiceDownloadDir()}>Change</button>
</li>
<li>
<label htmlFor="alwaysOnTop">
<span>Always on Top</span>
<Switch
checked={alwaysOnTop}
id="alwaysOnTop"
onChange={e => setAlwaysOnTop(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="showOnTray">
<span>Show on Tray</span>
<Switch
checked={showOnTray}
disabled={!helper.isOsx}
id="showOnTray"
onChange={e => setShowOnTray(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="showNotification">
<span>Send Desktop Notifications</span>
<Switch
checked={showNotification}
id="showNotification"
onChange={e => setShowNotification(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="blockRecall">
<span>Block Message Recall</span>
<Switch
checked={blockRecall}
id="blockRecall"
onChange={e => setBlockRecall(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="rememberConversation">
<span>Remember the last Conversation</span>
<Switch
checked={rememberConversation}
id="rememberConversation"
onChange={e => setRememberConversation(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="showRedIcon">
<span>Show the red button</span>
<Switch
checked={showRedIcon}
id="showRedIcon"
onChange={e => setShowRedIcon(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="confirmImagePaste">
<span>Image paste Confirmation</span>
<Switch
checked={confirmImagePaste}
id="confirmImagePaste"
onChange={e => setConfirmImagePaste(e.target.checked)} />
</label>
</li>
<li>
<label htmlFor="startup">
<span>Launch at startup</span>
<Switch
checked={startup}
id="startup"
onChange={e => setStartup(e.target.checked)} />
</label>
</li>
</ul>
</div>
<div className={classes.column}>
<h2>TODO:</h2>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,145 @@
.container {
display: flex;
justify-content: space-between;
padding: 12px 17px 0;
font-family: 'Helvetica Neue';
color: #000;
& h2 {
font-weight: 300;
font-size: 24px;
}
}
.column {
width: 50%;
& ul {
padding: 0;
margin: 0;
margin-top: 42px;
list-style: none;
}
& li {
padding-right: 42px;
margin-bottom: 24px;
font-size: 13px;
font-family: 'Roboto';
}
& label {
display: flex;
justify-content: space-between;
align-items: center;
}
& button {
padding: 8px 12px;
text-transform: uppercase;
border: 0;
border-radius: 1px;
background: none;
color: rgb(117, 117, 117);
cursor: pointer;
outline: 0;
transition: .2s;
}
& button:hover {
background: rgba(0, 0, 0, .1);
}
}
.user,
.downloads {
display: flex;
justify-content: space-between;
align-items: center;
}
.user img {
height: 32px;
width: 32px;
}
.downloads {
& p {
margin: 0;
height: 20px;
line-height: 20px;
}
& p:last-child {
color: rgb(117, 117, 117);
cursor: pointer;
}
& input {
display: none;
}
& > div {
display: flex;
flex-direction: column;
}
}
.plugin {
display: flex;
justify-content: space-between;
& img {
display: block;
height: 40px;
width: 40px;
margin-top: 12px;
margin-right: 14px;
}
/* stylelint-disable */
& :global(.Switch) {
margin-top: 12px;
}
/* stylelint-enable */
& :any-link {
color: #4990e2;
}
}
.version {
display: inline-block;
margin-left: 24px;
font-size: 12px;
color: #9e9e9e;
}
.description {
font: 12px;
color: #9b9b9b;
line-height: 20px;
}
@media (width <= 800px) {
.container {
padding: 8px 17px 0;
& h2 {
font-size: 20px;
}
}
.column {
& ul {
margin-top: 32px;
}
& li {
padding-right: 32px;
margin-bottom: 18px;
font-size: 12px;
}
}
}

View File

@@ -0,0 +1,236 @@
import React, { Component } from 'react';
import {withRouter} from 'react-router-dom';
import { inject, observer } from 'mobx-react';
import pinyin from 'han';
import clazz from 'classname';
import classes from './style.css';
import Avatar from 'components/Avatar';
import { Modal, ModalBody } from 'components/Modal';
import helper from 'utils/helper';
@inject(stores => ({
chatTo: (userid) => {
var user = stores.contacts.memberList.find(e => e.UserName === userid);
stores.chat.chatTo(user);
},
pallet: stores.userinfo.pallet,
show: stores.userinfo.show,
user: stores.userinfo.user,
remove: stores.userinfo.remove,
toggle: stores.userinfo.toggle,
setRemarkName: stores.userinfo.setRemarkName,
removeMember: async(user) => {
var roomid = (stores.members.show && stores.members.user.UserName)
|| stores.chat.user.UserName;
await stores.userinfo.removeMember(roomid, user.UserName);
stores.userinfo.toggle(false);
},
refreshContacts: async(user) => {
var { updateUser, filter, filtered } = stores.contacts;
stores.userinfo.updateUser(user);
updateUser(user);
filter(filtered.query);
},
showAddFriend: (user) => stores.addfriend.toggle(true, user),
showMessage: stores.snackbar.showMessage,
isme: () => {
return stores.session.user
&& stores.userinfo.user.UserName === stores.session.user.User.UserName;
},
}))
@observer
class UserInfo extends Component {
state = {
showEdit: false,
};
toggleEdit(showEdit = !this.state.showEdit) {
this.setState({ showEdit });
}
handleClose() {
this.toggleEdit(false);
this.props.toggle(false);
}
handleError(e) {
e.target.src = 'http://i.pravatar.cc/200';
}
async handleEnter(e) {
if (e.charCode !== 13) return;
var value = e.target.value.trim();
var res = await this.props.setRemarkName(value, this.props.user.UserName);
if (res) {
this.props.refreshContacts({
...this.props.user,
RemarkName: value,
RemarkPYInitial: value ? (pinyin.letter(value)[0]).toUpperCase() : value,
});
this.toggleEdit(false);
} else {
this.props.showMessage('Failed to set remark name.');
}
}
handleAction(user) {
if (this.props.history.location.pathname !== '/') {
this.props.history.push('/');
}
setTimeout(() => {
if (helper.isContact(user) || helper.isChatRoom(user.UserName)) {
this.props.toggle(false);
this.props.chatTo(user.UserName);
document.querySelector('#messageInput').focus();
} else {
this.props.showAddFriend(user);
}
});
}
render() {
var { UserName, HeadImgUrl, NickName, RemarkName, Signature, City, Province } = this.props.user;
var isFriend = helper.isContact(this.props.user);
var pallet = this.props.pallet;
var isme = this.props.isme();
var background = pallet[0];
var gradient = 'none';
var fontColor = '#777';
var buttonColor = '#777';
if (background) {
let pallet4font = pallet[1] || [0, 0, 0];
let pallet4button = pallet[2] || [0, 0, 0];
gradient = `
-webkit-linear-gradient(top, rgb(${background[0]}, ${background[1]}, ${background[2]}) 5%, rgba(${background[0]}, ${background[1]}, ${background[2]}, 0) 15%),
-webkit-linear-gradient(bottom, rgb(${background[0]}, ${background[1]}, ${background[2]}) 5%, rgba(${background[0]}, ${background[1]}, ${background[2]}, 0) 15%),
-webkit-linear-gradient(left, rgb(${background[0]}, ${background[1]}, ${background[2]}) 5%, rgba(${background[0]}, ${background[1]}, ${background[2]}, 0) 15%),
-webkit-linear-gradient(right, rgb(${background[0]}, ${background[1]}, ${background[2]}) 5%, rgba(${background[0]}, ${background[1]}, ${background[2]}, 0) 15%)
`;
background = `rgba(${background[0]}, ${background[1]}, ${background[2]}, 1)`;
fontColor = `rgb(
${pallet4font[0]},
${pallet4font[1]},
${pallet4font[2]},
)`;
buttonColor = `rgb(
${pallet4button[0]},
${pallet4button[1]},
${pallet4button[2]},
)`;
} else {
background = '#fff';
}
return (
<Modal
onCancel={() => this.handleClose()}
show={this.props.show}>
<ModalBody className={classes.container}>
<div
className={clazz(classes.hero, {
[classes.showEdit]: this.state.showEdit,
[classes.large]: !this.props.remove,
[classes.isme]: isme,
})}
onClick={() => {
var showEdit = this.state.showEdit;
if (showEdit) {
this.toggleEdit();
}
}} style={{
background,
color: fontColor,
}}>
{
(!isme && isFriend) && (
<div
className={classes.edit}
onClick={() => this.toggleEdit()}>
<i className="icon-ion-edit" />
</div>
)
}
<div className={classes.inner}>
<div
className={classes.mask}
style={{
background: gradient
}} />
<Avatar src={HeadImgUrl} />
</div>
<div
className={classes.username}
dangerouslySetInnerHTML={{__html: NickName}} />
{
!this.props.remove ? (
<div className={classes.wrap}>
<p dangerouslySetInnerHTML={{__html: Signature || 'No Signature'}} />
<div className={classes.address}>
<i
className="icon-ion-android-map"
style={{ color: fontColor }} />
{City || 'UNKNOW'}, {Province || 'UNKNOW'}
</div>
</div>
) : (
<div
className={classes.action}
onClick={() => this.props.removeMember(this.props.user)}
style={{
color: buttonColor,
opacity: .6,
marginTop: 20,
marginBottom: -30,
}}>
Delete
</div>
)
}
<div
className={classes.action}
onClick={() => this.handleAction(this.props.user)}
style={{
color: buttonColor,
opacity: .6,
}}>
{helper.isChatRoom(UserName) || isFriend ? 'Send Message' : 'Add Friend'}
</div>
</div>
{
/* eslint-disable */
this.state.showEdit && (
<input
autoFocus={true}
defaultValue={RemarkName}
onKeyPress={e => this.handleEnter(e)}
placeholder="Type the remark name"
ref="input"
type="text" />
)
/* eslint-enable */
}
</ModalBody>
</Modal>
);
}
}
export default withRouter(UserInfo);

View File

@@ -0,0 +1,241 @@
.container {
position: relative;
padding: 0 !important;
width: 320px;
overflow: hidden;
& input {
position: absolute;
bottom: 0;
left: 0;
height: 60px;
width: 100%;
text-align: center;
font-size: 16px;
outline: 0;
}
}
.edit {
position: absolute;
top: 10px;
right: 10px;
height: 40px;
width: 40px;
line-height: 40px;
border-radius: 40px;
text-align: center;
background: #e1306c;
box-shadow: 0 0 24px 0 rgba(119, 119, 119, .6);
cursor: pointer;
& i {
color: #fff;
cursor: pointer;
}
}
.hero {
display: flex;
height: 370px;
padding-top: 20px;
font-family: 'Helvetica';
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
transition: .2s ease-in-out;
&.large {
height: 400px;
padding-top: 0;
}
& .username {
display: -webkit-box;
padding: 0 12px;
line-height: 16px; /* fallback */
max-height: 32px; /* fallback */
-webkit-line-clamp: 2; /* number of lines to show */
-webkit-box-orient: vertical;
letter-spacing: 1px;
color: #4a4a4a;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
}
& .wrap {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
& p {
display: -webkit-box;
line-height: 16px;
max-height: 32px;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
padding: 0 12px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
font-size: 12px;
opacity: .5;
}
}
.hero.showEdit {
transform: translateY(-60px);
}
.hero.isme {
height: 350px;
& .address {
border: 0;
margin-top: 24px;
}
& .action {
display: none;
}
}
.address {
display: inline-block;
height: 32px;
margin: 0 auto;
padding: 0 12px;
line-height: 32px;
font-size: 14px;
font-family: 'Roboto';
text-align: center;
border-bottom: thin solid;
opacity: .8;
& i {
display: inline-block;
margin: 0 5px;
}
}
.inner {
position: relative;
display: inline-block;
margin-bottom: 14px;
& img {
width: 130px;
height: 130px;
border-radius: 0;
}
}
.mask {
position: absolute;
top: 0;
left: 0;
width: 130px;
height: 130px;
z-index: 9;
}
.action {
position: relative;
display: inline-block;
margin-top: 44px;
padding: 8px 12px;
font-family: 'Roboto';
font-size: 24px;
font-weight: 200;
line-height: 1;
text-align: center;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: .2s;
&:hover {
opacity: 1 !important;
}
}
@media (width <= 800px) {
.container {
width: 240px;
& input {
height: 46px;
font-size: 13px;
}
}
.edit {
top: 10px;
right: 10px;
height: 30px;
width: 30px;
line-height: 30px;
border-radius: 30px;
}
.hero {
height: 280px;
padding-top: 10px;
&.large {
height: 300px;
padding-top: 10px;
}
& h3 {
letter-spacing: 1px;
color: #4a4a4a;
}
& p {
padding: 0 8px;
max-width: 280px;
font-size: 12px;
}
}
.hero.showEdit {
transform: translateY(-46px);
}
.hero.isme {
height: 270px;
}
.address {
height: 24px;
padding: 0 8px;
line-height: 24px;
font-size: 13px;
}
.inner {
& img {
width: 100px;
height: 100px;
}
}
.mask {
width: 100px;
height: 100px;
}
.action {
margin-top: 33px;
padding: 6px 10px;
font-size: 18px;
letter-spacing: 2px;
}
}

7
src/js/pages/index.js Normal file
View File

@@ -0,0 +1,7 @@
import Layout from './Layout';
import Home from './Home';
import Contacts from './Contacts';
import Settings from './Settings';
export { Layout, Home, Contacts, Settings };

21
src/js/routes.js Normal file
View File

@@ -0,0 +1,21 @@
import React from 'react';
import { withRouter, Route, Switch } from 'react-router-dom';
import { Layout, Settings, Contacts, Home } from './pages';
const Main = withRouter(props => <Layout {...props} />);
export default () => {
/* eslint-disable */
return (
<Main>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/contacts" component={Contacts} />
<Route exact path="/settings" component={Settings} />
</Switch>
</Main>
);
/* eslint-enable */
};

View File

@@ -0,0 +1,41 @@
import { observable, action } from 'mobx';
import axios from 'axios';
import storage from 'utils/storage';
class AddFriend {
@observable show = false;
user;
@action toggle(show = self.show, user = self.user) {
self.show = show;
self.user = user;
}
@action async sendRequest(message) {
var auth = await storage.get('auth');
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxverifyuser?r=${+new Date()}`, {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
Opcode: 2,
SceneList: [33],
SceneListCount: 1,
VerifyContent: message,
VerifyUserList: [{
Value: self.user.UserName,
VerifyUserTicket: '',
}],
VerifyUserListSize: 1,
skey: auth.skey,
});
return +response.data.BaseResponse.Ret === 0;
}
}
const self = new AddFriend();
export default self;

View File

@@ -0,0 +1,66 @@
import { observable, action } from 'mobx';
import axios from 'axios';
import pinyin from 'han';
import contacts from './contacts';
import session from './session';
import storage from 'utils/storage';
import helper from 'utils/helper';
class AddMember {
@observable show = false;
@observable query = '';
@observable list = [];
@action toggle(show = !self.show) {
self.show = show;
}
@action search(text) {
text = pinyin.letter(text.toLocaleLowerCase());
var list = contacts.memberList.filter(e => {
var res = pinyin.letter(e.NickName).toLowerCase().indexOf(text) > -1;
if (e.UserName === session.user.User.UserName
|| !helper.isContact(e)
|| helper.isChatRoom(e.UserName)
|| helper.isFileHelper(e)) {
return false;
}
if (e.RemarkName) {
res = res || pinyin.letter(e.RemarkName).toLowerCase().indexOf(text) > -1;
}
return res;
});
self.query = text;
self.list.replace(list);
}
@action reset() {
self.query = '';
self.list.replace([]);
}
@action async addMember(roomId, userids) {
var auth = await storage.get('auth');
var response = await axios.post('/cgi-bin/mmwebwx-bin/webwxupdatechatroom?fun=addmember', {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
ChatRoomName: roomId,
AddMemberList: userids.join(','),
});
return +response.data.BaseResponse.Ret === 0;
}
}
const self = new AddMember();
export default self;

View File

@@ -0,0 +1,48 @@
import { observable, action } from 'mobx';
import pinyin from 'han';
import contacts from './contacts';
class BatchSend {
@observable show = false;
@observable query = '';
@observable filtered = [];
@action async toggle(show = !self.show) {
self.show = show;
if (show === false) {
self.query = '';
self.filtered.replace([]);
}
}
@action search(text = '') {
var list = contacts.memberList;
self.query = text;
if (text) {
text = pinyin.letter(text.toLocaleLowerCase());
list = list.filter(e => {
var res = pinyin.letter(e.NickName).toLowerCase().indexOf(text) > -1;
if (e.RemarkName) {
res = res || pinyin.letter(e.RemarkName).toLowerCase().indexOf(text) > -1;
}
return res;
});
self.filtered.replace(list);
return;
}
self.filtered.replace([]);
}
}
const self = new BatchSend();
export default self;

1092
src/js/stores/chat.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
import { observable, action } from 'mobx';
class ConfirmImagePaste {
@observable show = false;
@observable image;
ok;
cancel;
@action toggle(show = self.show, image = self.image) {
var promise = new Promise((resolve, reject) => {
self.ok = () => resolve(true);
self.cancel = () => resolve(false);
});
self.show = show;
self.image = image;
return promise;
}
}
const self = new ConfirmImagePaste();
export default self;

253
src/js/stores/contacts.js Normal file
View File

@@ -0,0 +1,253 @@
import { observable, action } from 'mobx';
import { ipcRenderer } from 'electron';
import axios from 'axios';
import pinyin from 'han';
import session from './session';
import chat from './chat';
import storage from 'utils/storage';
import helper from 'utils/helper';
import { normalize } from 'utils/emoji';
class Contacts {
@observable loading = false;
@observable showGroup = true;
@observable memberList = [];
@observable filtered = {
query: '',
result: [],
};
@action group(list, showall = false) {
var mappings = {};
var sorted = [];
list.map(e => {
if (!e) {
return;
}
// If 'showall' is false, just show your friends
if (showall === false
&& !helper.isContact(e)) {
return;
}
var prefix = ((e.RemarkPYInitial || e.PYInitial || pinyin.letter(e.NickName)).toString()[0] + '').replace('?', '#');
var group = mappings[prefix];
if (!group) {
group = mappings[prefix] = [];
}
group.push(e);
});
for (let key in mappings) {
sorted.push({
prefix: key,
list: mappings[key],
});
}
sorted.sort((a, b) => a.prefix.charCodeAt() - b.prefix.charCodeAt());
return sorted;
}
@action async getUser(userid) {
var user = self.memberList.find(e => e.UserName === userid);
if (user) {
return user;
}
await self.batch([userid]);
user = await self.getUser(userid);
return user;
}
@action async getContats() {
self.loading = true;
var auth = await storage.get('auth');
var me = session.user.User;
var response = await axios.get('/cgi-bin/mmwebwx-bin/webwxgetcontact', {
params: {
r: +new Date(),
seq: 0,
skey: auth.skey
}
});
// Remove all official account and brand account
self.memberList = response.data.MemberList.filter(e => helper.isContact(e) && !helper.isOfficial(e) && !helper.isBrand(e)).concat(me);
self.memberList.map(e => {
e.MemberList = [];
return self.resolveUser(auth, e);
});
self.loading = false;
self.filtered.result = self.group(self.memberList);
return (window.list = self.memberList);
}
resolveUser(auth, user) {
if (helper.isOfficial(user)
&& !helper.isFileHelper(user)) {
// Skip the official account
return;
}
if (helper.isBrand(user)
&& !helper.isFileHelper(user)) {
// Skip the brand account, eg: JD.COM
return;
}
if (helper.isChatRoomRemoved(user)
&& !helper.isFileHelper(user)) {
// Chat room has removed
return;
}
if (helper.isChatRoom(user.UserName)) {
let placeholder = user.MemberList.map(e => e.NickName).join(',');
if (user.NickName) {
user.Signature = placeholder;
} else {
user.NickName = placeholder;
user.Signature = placeholder;
}
}
user.NickName = normalize(user.NickName);
user.RemarkName = normalize(user.RemarkName);
user.Signature = normalize(user.Signature);
user.HeadImgUrl = `${axios.defaults.baseURL}${user.HeadImgUrl.substr(1)}`;
user.MemberList.map(e => {
e.NickName = normalize(e.NickName);
e.RemarkName = normalize(e.RemarkName);
e.HeadImgUrl = `${axios.defaults.baseURL}cgi-bin/mmwebwx-bin/webwxgeticon?username=${e.UserName}&chatroomid=${user.EncryChatRoomId}&skey=${auth.skey}&seq=0`;
});
return user;
}
// Batch get the contacts
async batch(list) {
var auth = await storage.get('auth');
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxbatchgetcontact?type=ex&r=${+new Date()}`, {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
Count: list.length,
List: list.map(e => ({
UserName: e,
ChatRoomId: ''
})),
});
if (response.data.BaseResponse.Ret === 0) {
var shouldUpdate = false;
response.data.ContactList.map(e => {
var index = self.memberList.findIndex(user => user.UserName === e.UserName);
var user = self.resolveUser(auth, e);
if (!user) return;
shouldUpdate = true;
if (index !== -1) {
self.memberList[index] = user;
} else {
// This contact is not in your contact list, eg: Temprary chat room
self.memberList.push(user);
}
});
if (shouldUpdate) {
// Update contact in menu
ipcRenderer.send('menu-update', {
contacts: JSON.stringify(self.memberList.filter(e => helper.isContact(e))),
cookies: await helper.getCookie(),
});
}
} else {
throw new Error(`Failed to get user: ${list}`);
}
return response.data.ContactList;
}
@action filter(text = '', showall = false) {
text = pinyin.letter(text.toLocaleLowerCase());
var list = self.memberList.filter(e => {
var res = pinyin.letter(e.NickName).toLowerCase().indexOf(text) > -1;
if (e.RemarkName) {
res = res || pinyin.letter(e.RemarkName).toLowerCase().indexOf(text) > -1;
}
return res;
});
if (!self.showGroup) {
list = list.filter(e => {
return !(e.ContactFlag === 3 && e.SnsFlag === 0);
});
}
self.filtered = {
query: text,
result: list.length ? self.group(list, showall) : [],
};
}
@action toggleGroup(showGroup) {
self.showGroup = showGroup;
}
@action async deleteUser(id) {
self.memberList = self.memberList.filter(e => e.UserName !== id);
// Update contact in menu
ipcRenderer.send('menu-update', {
contacts: JSON.stringify(self.memberList.filter(e => helper.isContact(e))),
cookies: await helper.getCookie(),
});
}
@action async updateUser(user) {
var auth = await storage.get('auth');
var list = self.memberList;
var index = list.findIndex(e => e.UserName === user.UserName);
var chating = chat.user;
// Fix chat room miss user avatar
user.EncryChatRoomId = list[index]['EncryChatRoomId'];
user = self.resolveUser(auth, user);
// Prevent avatar cache
user.HeadImgUrl = user.HeadImgUrl.replace(/\?\d{13}$/, '') + `?${+new Date()}`;
if (index !== -1) {
if (chating
&& user.UserName === chating.UserName) {
Object.assign(chating, user);
}
list[index] = user;
self.memberList.replace(list);
}
}
}
const self = new Contacts();
export default self;

60
src/js/stores/forward.js Normal file
View File

@@ -0,0 +1,60 @@
import { observable, action } from 'mobx';
import pinyin from 'han';
import contacts from './contacts';
import session from './session';
import chat from './chat';
class Forward {
@observable show = false;
@observable message = {};
@observable list = [];
@observable query = '';
@action async toggle(show = self.show, message = {}) {
self.show = show;
self.message = message;
if (show === false) {
self.query = '';
self.list.replace([]);
}
}
@action search(text = '') {
var list;
self.query = text;
if (text) {
list = contacts.memberList.filter(e => {
if (e.UserName === session.user.User.UserName) {
return false;
}
return pinyin.letter(e.NickName).toLowerCase().indexOf(pinyin.letter(text.toLocaleLowerCase())) > -1;
});
self.list.replace(list);
return;
}
self.list.replace([]);
}
@action async send(userid) {
var message = self.message;
var user = await contacts.getUser(userid);
message = Object.assign(message, {
content: message.Content,
type: message.MsgType,
});
chat.sendMessage(user, message, true);
}
}
const self = new Forward();
export default self;

34
src/js/stores/index.js Normal file
View File

@@ -0,0 +1,34 @@
import session from './session';
import chat from './chat';
import addfriend from './addfriend';
import addmember from './addmember';
import members from './members';
import newchat from './newchat';
import forward from './forward';
import userinfo from './userinfo';
import contacts from './contacts';
import search from './search';
import batchsend from './batchsend';
import settings from './settings';
import snackbar from './snackbar';
import confirmImagePaste from './confirmImagePaste';
const stores = {
session,
chat,
addfriend,
addmember,
newchat,
userinfo,
contacts,
search,
batchsend,
settings,
members,
forward,
snackbar,
confirmImagePaste,
};
export default stores;

63
src/js/stores/members.js Normal file
View File

@@ -0,0 +1,63 @@
import { observable, action } from 'mobx';
import pinyin from 'han';
import helper from 'utils/helper';
class Members {
@observable show = false;
@observable user = {
MemberList: [],
};
@observable list = [];
@observable filtered = [];
@observable query = '';
@action async toggle(show = self.show, user = self.user) {
var list = [];
self.show = show;
self.user = user;
if (show === false) {
self.query = '';
self.filtered.replace([]);
return;
}
self.list.replace(user.MemberList);
Promise.all(
user.MemberList.map(async e => {
var pallet = e.pallet;
if (!pallet) {
e.pallet = await helper.getPallet(e.HeadImgUrl);
}
list.push(e);
})
).then(() => {
self.list.replace(list);
});
}
@action search(text = '') {
var list;
self.query = text;
if (text) {
list = self.list.filter(e => {
return pinyin.letter(e.NickName).toLowerCase().indexOf(pinyin.letter(text.toLocaleLowerCase())) > -1;
});
self.filtered.replace(list);
return;
}
self.filtered.replace([]);
}
}
const self = new Members();
export default self;

63
src/js/stores/newchat.js Normal file
View File

@@ -0,0 +1,63 @@
import { observable, action } from 'mobx';
import axios from 'axios';
import pinyin from 'han';
import contacts from './contacts';
import storage from 'utils/storage';
import helper from 'utils/helper';
class NewChat {
@observable show = false;
@observable query = '';
@observable list = [];
@action toggle(show = !self.show) {
self.show = show;
}
@action search(text) {
text = pinyin.letter(text.toLocaleLowerCase());
var list = contacts.memberList.filter(e => {
var res = pinyin.letter(e.NickName).toLowerCase().indexOf(text) > -1;
if (e.RemarkName) {
res = res || pinyin.letter(e.RemarkName).toLowerCase().indexOf(text) > -1;
}
return helper.isContact(e) && res;
});
self.query = text;
self.list.replace(list);
}
@action reset() {
self.query = '';
self.list.replace([]);
}
@action async createChatRoom(userids) {
var auth = await storage.get('auth');
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxcreatechatroom?r=${+new Date()}`, {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
MemberCount: userids.length,
MemberList: userids.map(e => ({ UserName: e }))
});
if (+response.data.BaseResponse.Ret === 0) {
// Load the new contact infomation
let user = await contacts.getUser(response.data.ChatRoomName);
return user;
}
return false;
}
}
const self = new NewChat();
export default self;

106
src/js/stores/search.js Normal file
View File

@@ -0,0 +1,106 @@
import { observable, action } from 'mobx';
import pinyin from 'han';
import contacts from './contacts';
import storage from 'utils/storage';
import helper from 'utils/helper';
class Search {
@observable history = [];
@observable result = {
query: '',
friend: [],
groups: [],
};
@observable searching = false;
@action filter(text = '') {
var list = contacts.memberList;
var groups = [];
var friend = [];
text = pinyin.letter(text.toLocaleLowerCase());
list = contacts.memberList.filter(e => {
var res = pinyin.letter(e.NickName).toLowerCase().indexOf(text) > -1;
if (e.RemarkName) {
res = res || pinyin.letter(e.RemarkName).toLowerCase().indexOf(text) > -1;
}
return res;
});
list.map(e => {
if (helper.isChatRoom(e.UserName)) {
return groups.push(e);
}
friend.push(e);
});
if (text) {
self.result = {
query: text,
friend,
groups,
};
} else {
self.result = {
query: text,
friend: [],
groups: [],
};
}
self.searching = true;
return self.result;
}
@action clearHistory() {
self.history = [];
storage.remove('history', []);
}
@action async addHistory(user) {
var list = [user, ...self.history.filter(e => e.UserName !== user.UserName)];
await storage.set('history', list);
await self.getHistory();
}
@action reset() {
self.result = {
query: '',
friend: [],
groups: [],
};
self.toggle(false);
}
@action async getHistory() {
var list = await storage.get('history');
var history = [];
Array.from(list).map(e => {
var user = contacts.memberList.find(user => user.UserName === e.UserName);
if (user) {
history.push(user);
}
});
await storage.set('history', history);
self.history.replace(history);
return history;
}
@action toggle(searching = !self.searching) {
self.searching = searching;
}
}
const self = new Search();
export default self;

347
src/js/stores/session.js Normal file
View File

@@ -0,0 +1,347 @@
/* eslint-disable no-eval */
import axios from 'axios';
import { observable, action } from 'mobx';
import helper from 'utils/helper';
import storage from 'utils/storage';
import { normalize } from 'utils/emoji';
import chat from './chat';
import contacts from './contacts';
const CancelToken = axios.CancelToken;
const headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36',
'client-version': '2.0.0',
extspam: 'Go8FCIkFEokFCggwMDAwMDAwMRAGGvAESySibk50w5Wb3uTl2c2h64jVVrV7gNs06GFlWplHQbY/5FfiO++1yH4ykCyNPWKXmco+wfQzK5R98D3so7rJ5LmGFvBLjGceleySrc3SOf2Pc1gVehzJgODeS0lDL3/I/0S2SSE98YgKleq6Uqx6ndTy9yaL9qFxJL7eiA/R3SEfTaW1SBoSITIu+EEkXff+Pv8NHOk7N57rcGk1w0ZzRrQDkXTOXFN2iHYIzAAZPIOY45Lsh+A4slpgnDiaOvRtlQYCt97nmPLuTipOJ8Qc5pM7ZsOsAPPrCQL7nK0I7aPrFDF0q4ziUUKettzW8MrAaiVfmbD1/VkmLNVqqZVvBCtRblXb5FHmtS8FxnqCzYP4WFvz3T0TcrOqwLX1M/DQvcHaGGw0B0y4bZMs7lVScGBFxMj3vbFi2SRKbKhaitxHfYHAOAa0X7/MSS0RNAjdwoyGHeOepXOKY+h3iHeqCvgOH6LOifdHf/1aaZNwSkGotYnYScW8Yx63LnSwba7+hESrtPa/huRmB9KWvMCKbDThL/nne14hnL277EDCSocPu3rOSYjuB9gKSOdVmWsj9Dxb/iZIe+S6AiG29Esm+/eUacSba0k8wn5HhHg9d4tIcixrxveflc8vi2/wNQGVFNsGO6tB5WF0xf/plngOvQ1/ivGV/C1Qpdhzznh0ExAVJ6dwzNg7qIEBaw+BzTJTUuRcPk92Sn6QDn2Pu3mpONaEumacjW4w6ipPnPw+g2TfywJjeEcpSZaP4Q3YV5HG8D6UjWA4GSkBKculWpdCMadx0usMomsSS/74QgpYqcPkmamB4nVv1JxczYITIqItIKjD35IGKAUwAA==',
referer: 'https://wx.qq.com/?&lang=zh_CN&target=t',
};
class Session {
@observable loading = true;
@observable auth;
@observable code;
@observable avatar;
@observable user;
syncKey;
genSyncKey(list) {
return (self.syncKey = list.map(e => `${e.Key}_${e.Val}`).join('|'));
}
@action async getCode() {
var response = await axios.get('https://login.wx.qq.com/jslogin', {
params: {
appid: 'wx782c26e4c19acffb',
fun: 'new',
redirect_uri: 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
lang: 'zh_CN'
},
headers: headers,
});
// var response = await axios.get('https://login.wx.qq.com/jslogin?appid=wx782c26e4c19acffb&redirect_uri=https%3A%2F%2Fwx.qq.com%2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxnewloginpage&fun=new&lang=en_US&_=' + +new Date());
var code = response.data.match(/[A-Za-z_\-\d]{10}==/)[0];
self.code = code;
self.check();
return code;
}
@action async check() {
// Already logined
if (self.auth) return;
var response = await axios.get('https://login.wx.qq.com/cgi-bin/mmwebwx-bin/login', {
params: {
loginicon: true,
uuid: self.code,
tip: 1,
r: ~new Date(),
_: +new Date(),
},
headers: headers
});
eval(response.data);
switch (window.code) {
case 200:
let authAddress = window.redirect_uri;
// Set your weChat network route, otherwise you will got a code '1102'
axios.defaults.baseURL = authAddress.match(/^https:\/\/(.*?)\//)[0];
delete window.redirect_uri;
delete window.code;
delete window.userAvatar;
// Login success, create session
let response = await axios.get(authAddress, {
params: {
fun: 'new',
version: 'v2',
},
headers: headers
});
let auth = {};
try {
auth = {
baseURL: axios.defaults.baseURL,
skey: response.data.match(/<skey>(.*?)<\/skey>/)[1],
passTicket: response.data.match(/<pass_ticket>(.*?)<\/pass_ticket>/)[1],
wxsid: response.data.match(/<wxsid>(.*?)<\/wxsid>/)[1],
wxuin: response.data.match(/<wxuin>(.*?)<\/wxuin>/)[1],
};
} catch (ex) {
window.alert('Your login may be compromised. For account security, you cannot log in to Web WeChat. You can try mobile WeChat or Windows WeChat.');
window.location.reload();
}
self.auth = auth;
await storage.set('auth', auth);
await self.initUser();
self.keepalive().catch(ex => self.logout());
break;
case 201:
// Confirm to login
self.avatar = window.userAvatar;
self.check();
break;
case 400:
// QR Code has expired
window.location.reload();
return;
default:
// Continue call server and waite
self.check();
}
}
@action async initUser() {
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxinit?r=${-new Date()}&pass_ticket=${self.auth.passTicket}`, {
BaseRequest: {
Sid: self.auth.wxsid,
Uin: self.auth.wxuin,
Skey: self.auth.skey,
}
});
await axios.post(`/cgi-bin/mmwebwx-bin/webwxstatusnotify?lang=en_US&pass_ticket=${self.auth.passTicket}`, {
BaseRequest: {
Sid: self.auth.wxsid,
Uin: self.auth.wxuin,
Skey: self.auth.skey,
},
ClientMsgId: +new Date(),
Code: 3,
FromUserName: response.data.User.UserName,
ToUserName: response.data.User.UserName,
});
self.user = response.data;
self.user.ContactList.map(e => {
e.HeadImgUrl = `${axios.defaults.baseURL}${e.HeadImgUrl.substr(1)}`;
});
await contacts.getContats();
await chat.loadChats(self.user.ChatSet);
return self.user;
}
async getNewMessage() {
var auth = self.auth;
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxsync?sid=${auth.wxsid}&skey=${auth.skey}&lang=en_US&pass_ticket=${auth.passTicket}`, {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
SyncKey: self.user.SyncKey,
rr: ~new Date(),
});
var mods = [];
// Refresh the sync keys
self.user.SyncKey = response.data.SyncCheckKey;
self.genSyncKey(response.data.SyncCheckKey.List);
// Get the new friend, or chat room has change
response.data.ModContactList.map(e => {
var hasUser = contacts.memberList.find(user => user.UserName === e.UserName);
if (hasUser) {
// Just update the user
contacts.updateUser(e);
} else {
// If user not exists put it in batch list
mods.push(e.UserName);
}
});
// Delete user
response.data.DelContactList.map((e) => {
contacts.deleteUser(e.UserName);
chat.removeChat(e);
});
if (mods.length) {
await contacts.batch(mods, true);
}
response.data.AddMsgList.map(e => {
var from = e.FromUserName;
var to = e.ToUserName;
var fromYourPhone = from === self.user.User.UserName && from !== to;
// When message has been readed on your phone, will receive this message
if (e.MsgType === 51) {
return chat.markedRead(fromYourPhone ? from : to);
}
e.Content = normalize(e.Content);
// Sync message from your phone
if (fromYourPhone) {
// Message is sync from your phone
chat.addMessage(e, true);
return;
}
if (from.startsWith('@')) {
chat.addMessage(e);
}
});
return response.data;
}
// A callback for cancel the sync request
cancelCheck = window.Function;
checkTimeout(weakup) {
// Kill the zombie request or duplicate request
self.cancelCheck();
clearTimeout(self.checkTimeout.timer);
if (helper.isSuspend() || weakup) {
return;
}
self.checkTimeout.timer = setTimeout(() => {
self.cancelCheck();
}, 30 * 1000);
}
async keepalive() {
var auth = self.auth;
var response = await axios.post(`/cgi-bin/mmwebwx-bin/webwxsync?sid=${auth.wxsid}&skey=${auth.skey}&lang=en_US&pass_ticket=${auth.passTicket}`, {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
SyncKey: self.user.SyncKey,
rr: ~new Date(),
});
var host = axios.defaults.baseURL.replace('//', '//webpush.');
var loop = async() => {
// Start detect timeout
self.checkTimeout();
var response = await axios.get(`${host}cgi-bin/mmwebwx-bin/synccheck`, {
cancelToken: new CancelToken(exe => {
// An executor function receives a cancel function as a parameter
this.cancelCheck = exe;
}),
params: {
r: +new Date(),
sid: auth.wxsid,
uin: auth.wxuin,
skey: auth.skey,
synckey: self.syncKey,
}
}).catch(ex => {
if (axios.isCancel(ex)) {
loop();
} else {
self.logout();
}
});
if (!response) {
// Request has been canceled
return;
}
eval(response.data);
if (+window.synccheck.retcode === 0) {
// 2, Has new message
// 6, New friend
// 4, Conversation refresh ?
// 7, Exit or enter
let selector = +window.synccheck.selector;
if (selector !== 0) {
await self.getNewMessage();
}
// Do next sync keep your wechat alive
return loop();
} else {
self.logout();
}
};
// Load the rencets chats
response.data.AddMsgList.map(
async e => {
await chat.loadChats(e.StatusNotifyUserName);
}
);
self.loading = false;
self.genSyncKey(response.data.SyncCheckKey.List);
return loop();
}
@action async hasLogin() {
var auth = await storage.get('auth');
axios.defaults.baseURL = auth.baseURL;
self.auth = auth && Object.keys(auth).length ? auth : void 0;
if (self.auth) {
await self.initUser().catch(ex => self.logout());
self.keepalive().catch(ex => self.logout());
}
return auth;
}
@action async logout() {
var auth = self.auth;
try {
await axios.post(`/cgi-bin/mmwebwx-bin/webwxlogout?skey=${auth.skey}&redirect=0&type=1`, {
sid: auth.sid,
uin: auth.uid,
});
} finally {
self.exit();
}
}
async exit() {
await storage.remove('auth');
window.location.reload();
}
}
const self = new Session();
export default self;

138
src/js/stores/settings.js Normal file
View File

@@ -0,0 +1,138 @@
import { observable, action } from 'mobx';
import { remote, ipcRenderer } from 'electron';
import storage from 'utils/storage';
import helper from 'utils/helper';
class Settings {
@observable alwaysOnTop = false;
@observable showOnTray = false;
@observable showNotification = true;
@observable confirmImagePaste = true;
@observable startup = false;
@observable blockRecall = false;
@observable rememberConversation = false;
@observable showRedIcon = true;
@observable downloads = '';
@action setAlwaysOnTop(alwaysOnTop) {
self.alwaysOnTop = alwaysOnTop;
self.save();
}
@action setShowRedIcon(showRedIcon) {
self.showRedIcon = showRedIcon;
self.save();
}
@action setRememberConversation(rememberConversation) {
self.rememberConversation = rememberConversation;
self.save();
}
@action setBlockRecall(blockRecall) {
self.blockRecall = blockRecall;
self.save();
}
@action setShowOnTray(showOnTray) {
self.showOnTray = showOnTray;
self.save();
}
@action setConfirmImagePaste(confirmImagePaste) {
self.confirmImagePaste = confirmImagePaste;
self.save();
}
@action setShowNotification(showNotification) {
self.showNotification = showNotification;
self.save();
}
@action setStartup(startup) {
self.startup = startup;
self.save();
}
@action setDownloads(downloads) {
self.downloads = downloads.path;
self.save();
}
@action async init() {
var settings = await storage.get('settings');
var { alwaysOnTop, showOnTray, showNotification, blockRecall, rememberConversation, showRedIcon, startup, downloads } = self;
if (settings && Object.keys(settings).length) {
// Use !! force convert to a bool value
self.alwaysOnTop = !!settings.alwaysOnTop;
self.showOnTray = !!settings.showOnTray;
self.showNotification = !!settings.showNotification;
self.confirmImagePaste = !!settings.confirmImagePaste;
self.startup = !!settings.startup;
self.blockRecall = !!settings.blockRecall;
self.rememberConversation = !!settings.rememberConversation;
self.showRedIcon = !!settings.showRedIcon;
self.downloads = settings.downloads;
} else {
await storage.set('settings', {
alwaysOnTop,
showOnTray,
showNotification,
startup,
downloads,
blockRecall,
rememberConversation,
showRedIcon,
});
}
// Alway show the tray icon on windows
if (!helper.isOsx) {
self.showOnTray = true;
}
if (!self.downloads
|| typeof self.downloads !== 'string') {
self.downloads = remote.app.getPath('downloads');
}
self.save();
return settings;
}
save() {
var { alwaysOnTop, showOnTray, showNotification, confirmImagePaste, blockRecall, rememberConversation, showRedIcon, startup, downloads } = self;
storage.set('settings', {
alwaysOnTop,
showOnTray,
showNotification,
confirmImagePaste,
startup,
downloads,
blockRecall,
rememberConversation,
showRedIcon,
});
ipcRenderer.send('settings-apply', {
settings: {
alwaysOnTop,
showOnTray,
showNotification,
confirmImagePaste,
startup,
downloads,
blockRecall,
rememberConversation,
showRedIcon,
}
});
}
}
const self = new Settings();
export default self;

30
src/js/stores/snackbar.js Normal file
View File

@@ -0,0 +1,30 @@
import { observable, action } from 'mobx';
class Snackbar {
@observable show = false;
@observable text = '';
timer;
@action toggle(show = self.show, text = self.text) {
self.show = show;
self.text = text;
if (show) {
clearTimeout(self.timer);
self.timer = setTimeout(() => {
self.toggle(false);
}, 3000);
} else {
clearTimeout(self.timer);
}
}
@action showMessage(text = '') {
self.toggle(true, text);
}
}
const self = new Snackbar();
export default self;

77
src/js/stores/userinfo.js Normal file
View File

@@ -0,0 +1,77 @@
import { observable, action } from 'mobx';
import axios from 'axios';
import session from './session';
import helper from 'utils/helper';
import storage from 'utils/storage';
class UserInfo {
@observable show = false;
@observable remove = false;
@observable user = {};
@observable pallet = [];
@action async toggle(show = self.show, user = self.user, remove = false) {
if (user.UserName === session.user.User.UserName) {
remove = false;
}
self.remove = remove;
self.show = show;
self.user = user;
// Try to get from cache
var pallet = user.pallet;
if (show) {
if (pallet) {
self.pallet = user.pallet;
} else {
pallet = await helper.getPallet(user.HeadImgUrl);
// Cache the pallet
self.user.pallet = pallet;
self.pallet = pallet;
}
}
}
@action updateUser(user) {
self.user = user;
}
@action async setRemarkName(name, id) {
var auth = await storage.get('auth');
var response = await axios.post('/cgi-bin/mmwebwx-bin/webwxoplog', {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
CmdId: 2,
RemarkName: name.trim(),
UserName: id,
});
return +response.data.BaseResponse.Ret === 0;
}
@action async removeMember(roomId, userid) {
var auth = await storage.get('auth');
var response = await axios.post('/cgi-bin/mmwebwx-bin/webwxupdatechatroom?fun=delmember', {
BaseRequest: {
Sid: auth.wxsid,
Uin: auth.wxuin,
Skey: auth.skey,
},
ChatRoomName: roomId,
DelMemberList: userid,
});
return +response.data.BaseResponse.Ret === 0;
}
}
const self = new UserInfo();
export default self;

259
src/js/utils/albumcolors.js Normal file
View File

@@ -0,0 +1,259 @@
/* eslint-disable */
// https://github.com/chengyin/albumcolors
(function() {
var colorChooser, AlbumImage, AlbumColors;
/* colorChooser
* A series of function that is used to pick up 3 colors among 10 dominating colors
*/
colorChooser = {
colorStringToRGBArray: function(colorString) {
var n, arr = colorString.split(',');
for (n = 0; n < arr.length; n++) {
colorString[n] = parseInt(colorString[n], 10);
}
return colorString;
},
colorDistance: function(colorA, colorB) {
var colorARGB = colorA,
colorBRGB = colorB,
distance = 0,
n;
for (n = 0; n < colorARGB.length; n++) {
distance += (colorARGB[n] - colorBRGB[n]) * (colorARGB[n] - colorBRGB[n]);
}
return Math.sqrt(distance);
},
getDistances: function(colors) {
var distances = [],
c1, c2;
for (c1 = 0; c1 < colors.length; c1++) {
distances[c1] = [];
for (c2 = 0; c2 < colors.length; c2++) {
distances[c1][c2] = colorChooser.colorDistance(colors[c1], colors[c2]);
}
}
return distances;
},
chooseThreeColors: function(colors) {
var color1 = colors[0],
colorDistances = colorChooser.getDistances(colors),
color2Index = 0,
color3Index = 1, c, c2, color2, color3;
for (c = 0; c < colors.length; c++) {
if (colorDistances[0][c] > colorDistances[0][color2Index]) {
color2Index = c;
}
}
color2 = colors[color2Index];
color3Index = color2Index + 1;
if (color3Index >= colors.length) {
color3Index = 1;
}
for (c = 1; c < colors.length; c++) {
if (c !== color2Index && colorDistances[0][c] > colorDistances[0][color3Index]) {
color3Index = c;
}
}
color3 = colors[color3Index];
return [color1, color2, color3];
}
};
/*
* A Class for the to wrap image,
* used for counting raw color pixels
*/
AlbumImage = function(url) {
this.url = url;
};
AlbumImage.prototype.fetch = function(callback) {
var that = this;
this.image = new Image();
this.image.onload = function() {
if (callback) {
callback(this);
}
};
this.image.src = this.url;
};
AlbumImage.prototype.getCanvas = function() {
if (this.canvas) {
return this.canvas;
}
var canvas, context;
canvas = document.createElement('canvas');
canvas.width = this.image.width;
canvas.height = this.image.height;
context = canvas.getContext('2d');
context.drawImage(this.image, 0, 0);
return (this.canvas = canvas);
};
AlbumImage.prototype.getPixelArray = function() {
return this.getCanvas().getContext('2d').getImageData(0, 0, this.image.width, this.image.height).data;
};
AlbumImage.prototype.getColors = function() {
if (this.colors) {
return this.colors;
}
var p, colors = [],
pixelArray = this.getPixelArray();
for (p = 0; p < pixelArray.length; p += 4) {
colors.push([pixelArray[p], pixelArray[p + 1], pixelArray[p + 2]]);
}
return (this.colors = colors);
};
/*
* AlbumColors
* Generate pallete among dominating colors
*/
AlbumColors = function(imageUrl) {
this.imageUrl = imageUrl;
this.image = new AlbumImage(imageUrl);
};
AlbumColors.prototype.getColors = function(callback) {
var that = this;
this.image.fetch(function() {
that.colors = that.extractMainColors(10);
if (callback) {
callback(colorChooser.chooseThreeColors(that.colors));
}
});
};
AlbumColors.prototype.generateRGBString = function(color) {
return color.join(',');
};
AlbumColors.prototype.getBucket = function(color) {
// Throw a color into one color bucket
var bucket = [],
c;
for (c = 0; c < color.length; c++) {
// Naive
bucket[c] = Math.round(color[c] / 64) * 64;
}
return bucket;
};
AlbumColors.prototype.getColorsByBucket = function() {
if (this.colorsByBucket) {
return this.colorsByBucket;
}
var colors, c, color, bucket, colorsByBucket, rgbString;
colors = this.image.getColors();
colorsByBucket = {};
for (c = 0; c < colors.length; c++) {
color = colors[c];
bucket = this.getBucket(color);
rgbString = this.generateRGBString(bucket);
colorsByBucket[rgbString] = colorsByBucket[rgbString] || [];
if (colorsByBucket[rgbString]) {
colorsByBucket[rgbString].push(color);
}
}
return (this.colorsByBucket = colorsByBucket);
};
AlbumColors.prototype.getAverageColor = function(colors) {
var c, r = 0,
g = 0,
b = 0;
for (c = 0; c < colors.length; c++) {
r += colors[c][0];
g += colors[c][1];
b += colors[c][2];
}
r = parseInt(r / colors.length, 10);
g = parseInt(g / colors.length, 10);
b = parseInt(b / colors.length, 10);
return [r, g, b];
};
AlbumColors.prototype.getColorBuckets = function() {
if (this.colorBuckets) {
return this.colorBuckets;
}
var colorsByBucket = this.getColorsByBucket(),
bucket, buckets = [];
for (bucket in colorsByBucket) {
if (colorsByBucket.hasOwnProperty(bucket)) {
buckets.push(bucket);
}
}
return (this.colorBuckets = buckets);
};
AlbumColors.prototype.extractMainColors = function(count) {
if (!this.mainColors) {
var colorsByBucket = this.getColorsByBucket(),
colorBuckets = this.getColorBuckets().slice(0),
b, mainColors = [];
colorBuckets.sort(function(colorBucketA, colorBucketB) {
return colorsByBucket[colorBucketB].length - colorsByBucket[colorBucketA].length;
});
for (b = 0; b < colorBuckets.length; b++) {
mainColors.push(this.getAverageColor(colorsByBucket[colorBuckets[b]]));
}
this.mainColors = mainColors;
}
return this.mainColors.slice(0, count);
};
AlbumColors.AlbumImage = AlbumImage;
window.AlbumColors = AlbumColors;
}());

13
src/js/utils/blacklist.js Normal file
View File

@@ -0,0 +1,13 @@
export default function blacklist(src, ...args) {
var copy = {};
var ignore = Array.from(args);
for (var key in src) {
if (ignore.indexOf(key) === -1) {
copy[key] = src[key];
}
}
return copy;
}

400
src/js/utils/emoji.js Normal file
View File

@@ -0,0 +1,400 @@
const EmojiList = ['笑脸', '生病', '破涕为笑', '吐舌', '脸红', '恐惧', '失望', '无语', '嘿哈', '捂脸', '奸笑', '机智', '皱眉', '耶', '鬼魂', '合十', '强壮', '庆祝', '礼物', '红包', '鸡', '开心', '大笑', '热情', '眨眼', '色', '接吻', '亲吻', '露齿笑', '满意', '戏弄', '得意', '汗', '低落', '呸', '焦虑', '担心', '震惊', '悔恨', '眼泪', '哭', '晕', '心烦', '生气', '睡觉', '恶魔', '外星人', '心', '心碎', '丘比特', '闪烁', '星星', '叹号', '问号', '睡着', '水滴', '音乐', '火', '便便', '弱', '拳头', '胜利', '上', '下', '右', '左', '第一', '吻', '热恋', '男孩', '女孩', '女士', '男士', '天使', '骷髅', '红唇', '太阳', '下雨', '多云', '雪人', '月亮', '闪电', '海浪', '猫', '小狗', '老鼠', '仓鼠', '兔子', '狗', '青蛙', '老虎', '考拉', '熊', '猪', '牛', '野猪', '猴子', '马', '蛇', '鸽子', '鸡', '企鹅', '毛虫', '章鱼', '鱼', '鲸鱼', '海豚', '玫瑰', '花', '棕榈树', '仙人掌', '礼盒', '南瓜灯', '圣诞老人', '圣诞树', '礼物', '铃', '气球', 'CD', '相机', '录像机', '电脑', '电视', '电话', '解锁', '锁', '钥匙', '成交', '灯泡', '邮箱', '浴缸', '钱', '炸弹', '手枪', '药丸', '橄榄球', '篮球', '足球', '棒球', '高尔夫', '奖杯', '入侵者', '唱歌', '吉他', '比基尼', '皇冠', '雨伞', '手提包', '口红', '戒指', '钻石', '咖啡', '啤酒', '干杯', '鸡尾酒', '汉堡', '薯条', '意面', '寿司', '面条', '煎蛋', '冰激凌', '蛋糕', '苹果', '飞机', '火箭', '自行车', '高铁', '警告', '旗', '男人', '女人', 'O', 'X', '版权', '注册商标', '商标'];
const QQFaceMap = {
'微笑': '0',
'撇嘴': '1',
'色': '2',
'发呆': '3',
'得意': '4',
'流泪': '5',
'害羞': '6',
'闭嘴': '7',
'睡': '8',
'大哭': '9',
'尴尬': '10',
'发怒': '11',
'调皮': '12',
'呲牙': '13',
'惊讶': '14',
'难过': '15',
'酷': '16',
'冷汗': '17',
'抓狂': '18',
'吐': '19',
'偷笑': '20',
'可爱': '21',
'愉快': '21',
'白眼': '22',
'傲慢': '23',
'饥饿': '24',
'困': '25',
'惊恐': '26',
'流汗': '27',
'憨笑': '28',
'悠闲': '29',
'大兵': '29',
'奋斗': '30',
'咒骂': '31',
'疑问': '32',
'嘘': '33',
'晕': '34',
'疯了': '35',
'折磨': '35',
'衰': '36',
'骷髅': '37',
'敲打': '38',
'再见': '39',
'擦汗': '40',
'抠鼻': '41',
'鼓掌': '42',
'糗大了': '43',
'坏笑': '44',
'左哼哼': '45',
'右哼哼': '46',
'哈欠': '47',
'鄙视': '48',
'委屈': '49',
'快哭了': '50',
'阴险': '51',
'亲亲': '52',
'吓': '53',
'可怜': '54',
'菜刀': '55',
'西瓜': '56',
'啤酒': '57',
'篮球': '58',
'乒乓': '59',
'咖啡': '60',
'饭': '61',
'猪头': '62',
'玫瑰': '63',
'凋谢': '64',
'嘴唇': '65',
'示爱': '65',
'爱心': '66',
'心碎': '67',
'蛋糕': '68',
'闪电': '69',
'炸弹': '70',
'刀': '71',
'足球': '72',
'瓢虫': '73',
'便便': '74',
'月亮': '75',
'太阳': '76',
'礼物': '77',
'拥抱': '78',
'强': '79',
'弱': '80',
'握手': '81',
'胜利': '82',
'抱拳': '83',
'勾引': '84',
'拳头': '85',
'差劲': '86',
'爱你': '87',
NO: '88',
OK: '89',
'爱情': '90',
'飞吻': '91',
'跳跳': '92',
'发抖': '93',
'怄火': '94',
'转圈': '95',
'磕头': '96',
'回头': '97',
'跳绳': '98',
'投降': '99',
'激动': '100',
'乱舞': '101',
'献吻': '102',
'左太极': '103',
'右太极': '104',
'嘿哈': '105',
'捂脸': '106',
'奸笑': '107',
'机智': '108',
'皱眉': '109',
'耶': '110',
'鸡': '111',
'红包': '112',
Smile: '0',
Grimace: '1',
Drool: '2',
Scowl: '3',
Chill: '4',
CoolGuy: '4',
Sob: '5',
Shy: '6',
Shutup: '7',
Silent: '7',
Sleep: '8',
Cry: '9',
Awkward: '10',
Pout: '11',
Angry: '11',
Wink: '12',
Tongue: '12',
Grin: '13',
Surprised: '14',
Surprise: '14',
Frown: '15',
Cool: '16',
Ruthless: '16',
Tension: '17',
Blush: '17',
Scream: '18',
Crazy: '18',
Puke: '19',
Chuckle: '20',
Joyful: '21',
Slight: '22',
Smug: '23',
Hungry: '24',
Drowsy: '25',
Panic: '26',
Sweat: '27',
Laugh: '28',
Loafer: '29',
Commando: '29',
Strive: '30',
Determined: '30',
Scold: '31',
Doubt: '32',
Shocked: '32',
Shhh: '33',
Dizzy: '34',
Tormented: '35',
BadLuck: '36',
Toasted: '36',
Skull: '37',
Hammer: '38',
Wave: '39',
Relief: '40',
Speechless: '40',
DigNose: '41',
NosePick: '41',
Clap: '42',
Shame: '43',
Trick: '44',
'BahL': '45',
'BahR': '46',
Yawn: '47',
Lookdown: '48',
'Pooh-pooh': '48',
Wronged: '49',
Shrunken: '49',
Puling: '50',
TearingUp: '50',
Sly: '51',
Kiss: '52',
'Uh-oh': '53',
Wrath: '53',
Whimper: '54',
Cleaver: '55',
Melon: '56',
Watermelon: '56',
Beer: '57',
Basketball: '58',
PingPong: '59',
Coffee: '60',
Rice: '61',
Pig: '62',
Rose: '63',
Wilt: '64',
Lip: '65',
Lips: '65',
Heart: '66',
BrokenHeart: '67',
Cake: '68',
Lightning: '69',
Bomb: '70',
Dagger: '71',
Soccer: '72',
Ladybug: '73',
Poop: '74',
Moon: '75',
Sun: '76',
Gift: '77',
Hug: '78',
Strong: '79',
ThumbsUp: '79',
Weak: '80',
ThumbsDown: '80',
Shake: '81',
Victory: '82',
Peace: '82',
Admire: '83',
Fight: '83',
Salute: '83',
Beckon: '84',
Fist: '85',
Pinky: '86',
Love: '2',
RockOn: '87',
No: '88',
'Nuh-uh': '88',
InLove: '90',
Blowkiss: '91',
Waddle: '92',
Tremble: '93',
'Aaagh!': '94',
Twirl: '95',
Kotow: '96',
Lookback: '97',
Dramatic: '97',
Jump: '98',
JumpRope: '98',
'Give-in': '99',
Surrender: '99',
Hooray: '100',
HeyHey: '101',
Meditate: '101',
Smooch: '102',
'TaiJi L': '103',
'TaiChi L': '103',
'TaiJi R': '104',
'TaiChi R': '104',
Hey: '105',
Facepalm: '106',
Smirk: '107',
Smart: '108',
Concerned: '109',
'Yeah!': '110',
Chicken: '111',
Packet: '112',
'發呆': '3',
'流淚': '5',
'閉嘴': '7',
'尷尬': '10',
'發怒': '11',
'調皮': '12',
'驚訝': '14',
'難過': '15',
'饑餓': '24',
'累': '25',
'驚恐': '26',
'悠閑': '29',
'奮鬥': '30',
'咒罵': '31',
'疑問': '32',
'噓': '33',
'暈': '34',
'瘋了': '35',
'骷髏頭': '37',
'再見': '39',
'摳鼻': '41',
'羞辱': '43',
'壞笑': '44',
'鄙視': '48',
'陰險': '51',
'親親': '52',
'嚇': '53',
'可憐': '54',
'籃球': '58',
'飯': '61',
'豬頭': '62',
'枯萎': '64',
'愛心': '66',
'閃電': '69',
'炸彈': '70',
'甲蟲': '73',
'太陽': '76',
'禮物': '77',
'擁抱': '78',
'強': '79',
'勝利': '82',
'拳頭': '85',
'差勁': '86',
'愛你': '88',
'愛情': '90',
'飛吻': '91',
'發抖': '93',
'噴火': '94',
'轉圈': '95',
'磕頭': '96',
'回頭': '97',
'跳繩': '98',
'激動': '100',
'亂舞': '101',
'獻吻': '102',
'左太極': '103',
'右太極': '104',
'吼嘿': '105',
'掩面': '106',
'機智': '108',
'皺眉': '109',
'歐耶': '110',
'雞': '111',
'紅包': '112',
};
const emoji = [];
Object.keys(QQFaceMap).slice(0, 105).map(e => {
var id = QQFaceMap[e];
emoji.push({
key: e,
value: QQFaceMap[e],
className: `qqemoji qqemoji${id}`
});
});
function getEmojiClassName(name) {
name = name.substring(1, name.length - 1);
var keys = Object.keys(QQFaceMap);
var key = keys.find(e => e === name);
if (key) {
let id = QQFaceMap[key];
let emojiCN = keys.find(e => QQFaceMap[e] === id);
if (+id < 200) {
return `qqemoji qqemoji${id}`;
}
if (EmojiList.includes(emojiCN)) {
return `emoji emoji${id}`;
}
}
return false;
}
function parser(text) {
var decodeText = text;
(text.match(/\[[\w\s\u4E00-\u9FCC\u3400-\u4DB5\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29\ud840-\ud868\udc00-\udfff\ud869[\udc00-\uded6\udf00-\udfff\ud86a-\ud86c\udc00-\udfff\ud86d[\udc00-\udf34\udf40-\udfff\ud86e\udc00-\udc1d]+\]/g) || []).map(e => {
var className = getEmojiClassName(e);
if (!className) {
// Invalid emoji
return;
}
text = decodeText = text.split(`${e}`).join(`<a class="${className}"></a>`);
});
return normalize(decodeText);
}
function normalize(text = '') {
var matchs = text.match(/<span class="emoji emoji[0-9a-fA-F]+"><\/span>/g) || [];
var decodeText = text;
try {
matchs.map(e => {
// Decode utf16 to emoji
var emojiCode = e.match(/emoji([0-9a-fA-F]+)/)[1].substr(0, 5);
var emoji = String.fromCodePoint(parseInt(emojiCode, 16));
text = decodeText = text.split(e).join(emoji);
});
} catch (ex) {
console.error('Failed decode %s: %o', text, ex);
}
return decodeText;
}
export { emoji, parser, normalize };

10
src/js/utils/event.js Normal file
View File

@@ -0,0 +1,10 @@
export function on(el, events, fn) {
(el && events && fn)
&& events.split().forEach(e => el.addEventListener(e, fn, false));
}
export function off(el, events, fn) {
(el && events && fn)
&& events.split().forEach(e => el.removeEventListener(e, fn, false));
}

311
src/js/utils/helper.js Normal file
View File

@@ -0,0 +1,311 @@
import { remote, ipcRenderer } from 'electron';
import axios from 'axios';
import MD5 from 'browser-md5-file';
import session from '../stores/session';
const CHATROOM_NOTIFY_CLOSE = 0;
const CONTACTFLAG_NOTIFYCLOSECONTACT = 512;
const MM_USERATTRVERIFYFALG_BIZ_BRAND = 8;
const CONTACTFLAG_TOPCONTACT = 2048;
const CONTACTFLAG_CONTACT = 1;
const helper = {
isContact: (user) => {
if (helper.isFileHelper(user)) return true;
return user.ContactFlag & CONTACTFLAG_CONTACT
|| (session.user && user.UserName === session.user.User.UserName);
},
isChatRoom: (userid) => {
return userid && userid.startsWith('@@');
},
isChatRoomOwner: (user) => {
return helper.isChatRoom(user.UserName) && user.IsOwner;
},
isChatRoomRemoved: (user) => {
return helper.isChatRoom(user.UserName) && user.ContactFlag === 0;
},
isMuted: (user) => {
return helper.isChatRoom(user.UserName) ? user.Statues === CHATROOM_NOTIFY_CLOSE : user.ContactFlag & CONTACTFLAG_NOTIFYCLOSECONTACT;
},
isOfficial: (user) => {
return !(user.VerifyFlag !== 24 && user.VerifyFlag !== 8 && user.UserName.startsWith('@'));
},
isFileHelper: (user) => user.UserName === 'filehelper',
isTop: (user) => {
if (user.isTop !== void 0) {
return user.isTop;
}
return user.ContactFlag & CONTACTFLAG_TOPCONTACT;
},
isBrand: (user) => {
return user.VerifyFlag & MM_USERATTRVERIFYFALG_BIZ_BRAND;
},
parseKV: (text) => {
var string = text.replace(/&lt;/g, '<').replace(/&gt;/g, '>');
var matchs = string.match(/(\w+)="([^\s]+)"/g);
let res = {};
matchs.map(e => {
var kv = e.replace(/"/g, '').split('=');
res[kv[0]] = kv[1];
});
return res;
},
parseXml: (text, tagName) => {
var parser = new window.DOMParser();
var xml = parser.parseFromString(text.replace(/&lt;/g, '<').replace(/&gt;/g, '>'), 'text/xml');
var value = {};
tagName = Array.isArray(tagName) ? tagName : [tagName];
tagName.map(e => {
value[e] = xml.getElementsByTagName(e)[0].childNodes[0].nodeValue;
});
return { xml, value };
},
unique: (arr) => {
var mappings = {};
var res = [];
arr.map(e => {
mappings[e] = true;
});
for (var key in mappings) {
if (mappings[key] === true) {
res.push(key);
}
}
return res;
},
getMessageContent: (message) => {
var isChatRoom = helper.isChatRoom(message.FromUserName);
var content = message.Content;
if (isChatRoom && !message.isme) {
content = message.Content.split(':<br/>')[1];
}
switch (message.MsgType) {
case 1:
if (message.location) return '[Location]';
// Text message
return content.replace(/<br\/>/g, '');
case 3:
// Image
return '[Image]';
case 34:
// Image
return '[Voice]';
case 42:
// Contact Card
return '[Contact Card]';
case 43:
// Video
return '[Video]';
case 47:
case 49 + 8:
// Emoji
return '[Emoji]';
case 49 + 17:
return '🚀 &nbsp; Location sharing, Please check your phone.';
case 49 + 6:
return `🚚 &nbsp; ${message.file.name}`;
case 49 + 2000:
// Transfer
return `Money +${message.transfer.money} 💰💰💰`;
}
},
getCookie: async(name) => {
var value = {
name,
};
var cookies = remote.getCurrentWindow().webContents.session.cookies;
if (!name) {
return new Promise((resolve, reject) => {
cookies.get({ url: axios.defaults.baseURL }, (error, cookies) => {
let string = '';
if (error) {
return resolve('');
}
for (var i = cookies.length; --i >= 0;) {
let item = cookies[i];
string += `${item.name}=${item.value} ;`;
}
resolve(string);
});
});
}
return new Promise((resolve, reject) => {
cookies.get(value, (err, cookies) => {
if (err) {
reject(err);
} else {
resolve(cookies[0].value);
}
});
});
},
humanSize: (size) => {
var value = (size / 1024).toFixed(1);
if (size > (1024 << 10)) {
value = (value / 1024).toFixed(1);
return `${value} M`;
} else {
return `${value} KB`;
}
},
getFiletypeIcon: (extension) => {
var filename = 'unknow';
extension = (extension || '').toLowerCase().replace(/^\./, '');
switch (true) {
case ['mp3', 'flac', 'aac', 'm4a', 'wma'].includes(extension):
filename = 'audio';
break;
case ['mp4', 'mkv', 'avi', 'flv'].includes(extension):
filename = 'audio';
break;
case ['zip', 'rar', 'tar', 'tar.gz'].includes(extension):
filename = 'archive';
break;
case ['doc', 'docx'].includes(extension):
filename = 'word';
break;
case ['xls', 'xlsx'].includes(extension):
filename = 'excel';
break;
case ['ai', 'apk', 'exe', 'ipa', 'pdf', 'ppt', 'psd'].includes(extension):
filename = extension;
break;
}
return `${filename}.png`;
},
getPallet: (image) => {
return new Promise((resolve, reject) => {
new window.AlbumColors(image).getColors((colors, err) => {
if (err) {
resolve([
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
]);
} else {
resolve(colors);
}
});
});
},
decodeHTML: (text = '') => {
return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
},
isImage: (ext) => {
return ['bmp', 'gif', 'jpeg', 'jpg', 'png'].includes(ext);
},
// 3 types supported: pic, video, doc
getMediaType: (ext = '') => {
ext = ext.toLowerCase();
switch (true) {
case helper.isImage(ext):
return 'pic';
case ['mp4'].includes(ext):
return 'video';
default:
return 'doc';
}
},
getDataURL: (src) => {
var image = new window.Image();
return new Promise((resolve, reject) => {
image.src = src;
image.onload = () => {
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0, image.width, image.height);
resolve(canvas.toDataURL('image/png'));
};
image.onerror = () => {
resolve('');
};
});
},
isOsx: window.process.platform === 'darwin',
isSuspend: () => {
return ipcRenderer.sendSync('is-suspend');
},
md5: (file) => {
const md = new MD5();
return new Promise((resolve, reject) => {
md.md5(file, (err, md5) => { resolve(err ? false : md5); }, progress => {});
});
}
// md5: (file) => {
// return new Promise((resolve, reject) => {
// MD5(file, (err, md5) => {
// resolve(err ? false : md5);
// });
// });
// }
};
export default helper;

40
src/js/utils/storage.js Normal file
View File

@@ -0,0 +1,40 @@
import storage from 'electron-json-storage';
export default {
get: (key) => {
return new Promise((resolve, reject) => {
storage.get(key, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
},
set: (key, data) => {
return new Promise((resolve, reject) => {
storage.set(key, data, err => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
},
remove: (key) => {
return new Promise((resolve, reject) => {
storage.remove(key, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
};