summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorAsaad Mahmood <Unknowngi@live.com>2015-10-27 20:15:33 +0500
committerAsaad Mahmood <Unknowngi@live.com>2015-10-27 20:15:33 +0500
commit179c4ea684af8d2a021cc6f0042bc4408f39b0bb (patch)
tree7789805249e5c962f1175f301beb74272dee30c0 /web
parent68b02ffb9eeb7a414feae8ed89b4011ccecf96bc (diff)
parent50eb3d9fe46d6364b6f12201edfe0a401be3ccdd (diff)
downloadchat-179c4ea684af8d2a021cc6f0042bc4408f39b0bb.tar.gz
chat-179c4ea684af8d2a021cc6f0042bc4408f39b0bb.tar.bz2
chat-179c4ea684af8d2a021cc6f0042bc4408f39b0bb.zip
Merge branch 'master' of https://github.com/mattermost/platform into ui-improvements
Diffstat (limited to 'web')
-rw-r--r--web/react/components/channel_notifications.jsx24
-rw-r--r--web/react/components/create_comment.jsx2
-rw-r--r--web/react/components/create_post.jsx127
-rw-r--r--web/react/components/error_bar.jsx12
-rw-r--r--web/react/components/mention_list.jsx7
-rw-r--r--web/react/components/more_direct_channels.jsx64
-rw-r--r--web/react/components/msg_typing.jsx55
-rw-r--r--web/react/components/popover_list_members.jsx145
-rw-r--r--web/react/components/search_autocomplete.jsx249
-rw-r--r--web/react/components/search_bar.jsx54
-rw-r--r--web/react/components/sidebar.jsx64
-rw-r--r--web/react/components/team_signup_send_invites_page.jsx5
-rw-r--r--web/react/components/team_signup_url_page.jsx6
-rw-r--r--web/react/components/team_signup_welcome_page.jsx18
-rw-r--r--web/react/components/user_settings/code_theme_chooser.jsx55
-rw-r--r--web/react/components/user_settings/user_settings_appearance.jsx23
-rw-r--r--web/react/components/user_settings/user_settings_general.jsx2
-rw-r--r--web/react/package.json1
-rw-r--r--web/react/stores/socket_store.jsx4
-rw-r--r--web/react/stores/user_store.jsx14
-rw-r--r--web/react/utils/async_client.jsx4
-rw-r--r--web/react/utils/client.jsx2
-rw-r--r--web/react/utils/constants.jsx37
-rw-r--r--web/react/utils/markdown.jsx74
-rw-r--r--web/react/utils/text_formatting.jsx74
-rw-r--r--web/react/utils/utils.jsx69
-rw-r--r--web/sass-files/sass/partials/_popover.scss33
-rw-r--r--web/sass-files/sass/partials/_post.scss16
-rw-r--r--web/sass-files/sass/partials/_search.scss40
l---------web/static/css/highlight1
-rw-r--r--web/static/images/themes/code_themes/github.pngbin0 -> 9648 bytes
-rw-r--r--web/static/images/themes/code_themes/monokai.pngbin0 -> 9303 bytes
-rw-r--r--web/static/images/themes/code_themes/solarized_dark.pngbin0 -> 8172 bytes
-rw-r--r--web/static/images/themes/code_themes/solarized_light.pngbin0 -> 8860 bytes
-rw-r--r--web/templates/head.html1
-rw-r--r--web/web.go14
36 files changed, 1060 insertions, 236 deletions
diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx
index 6151d4bdd..43700bf36 100644
--- a/web/react/components/channel_notifications.jsx
+++ b/web/react/components/channel_notifications.jsx
@@ -136,16 +136,15 @@ export default class ChannelNotifications extends React.Component {
var inputs = [];
inputs.push(
- <div>
+ <div key='channel-notification-level-radio'>
<div className='radio'>
<label>
<input
type='radio'
checked={notifyActive[0]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'default')}
- >
+ />
{`Global default (${globalNotifyLevelName})`}
- </input>
</label>
<br/>
</div>
@@ -155,9 +154,8 @@ export default class ChannelNotifications extends React.Component {
type='radio'
checked={notifyActive[1]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'all')}
- >
+ />
{'For all activity'}
- </input>
</label>
<br/>
</div>
@@ -167,9 +165,8 @@ export default class ChannelNotifications extends React.Component {
type='radio'
checked={notifyActive[2]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'mention')}
- >
+ />
{'Only for mentions'}
- </input>
</label>
<br/>
</div>
@@ -179,9 +176,8 @@ export default class ChannelNotifications extends React.Component {
type='radio'
checked={notifyActive[3]}
onChange={this.handleUpdateNotifyLevel.bind(this, 'none')}
- >
+ />
{'Never'}
- </input>
</label>
</div>
</div>
@@ -274,16 +270,15 @@ export default class ChannelNotifications extends React.Component {
if (this.state.activeSection === 'markUnreadLevel') {
const inputs = [(
- <div>
+ <div key='channel-notification-unread-radio'>
<div className='radio'>
<label>
<input
type='radio'
checked={this.state.markUnreadLevel === 'all'}
onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'all')}
- >
+ />
{'For all unread messages'}
- </input>
</label>
<br />
</div>
@@ -293,9 +288,8 @@ export default class ChannelNotifications extends React.Component {
type='radio'
checked={this.state.markUnreadLevel === 'mention'}
onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'mention')}
- >
+ />
{'Only for mentions'}
- </input>
</label>
<br />
</div>
@@ -370,7 +364,7 @@ export default class ChannelNotifications extends React.Component {
data-dismiss='modal'
>
<span aria-hidden='true'>&times;</span>
- <span className='sr-only'>Close</span>
+ <span className='sr-only'>{'Close'}</span>
</button>
<h4 className='modal-title'>Notification Preferences for <span className='name'>{this.state.title}</span></h4>
</div>
diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx
index 435c7d542..18936e808 100644
--- a/web/react/components/create_comment.jsx
+++ b/web/react/components/create_comment.jsx
@@ -147,7 +147,7 @@ export default class CreateComment extends React.Component {
}
const t = Date.now();
- if ((t - this.lastTime) > 5000) {
+ if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) {
SocketStore.sendMessage({channel_id: this.props.channelId, action: 'typing', props: {parent_id: this.props.rootId}});
this.lastTime = t;
}
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index 8b5fc4162..32ee31efe 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -38,6 +38,7 @@ export default class CreatePost extends React.Component {
this.getFileCount = this.getFileCount.bind(this);
this.handleArrowUp = this.handleArrowUp.bind(this);
this.handleResize = this.handleResize.bind(this);
+ this.sendMessage = this.sendMessage.bind(this);
PostStore.clearDraftUploads();
@@ -122,6 +123,11 @@ export default class CreatePost extends React.Component {
post.message,
false,
(data) => {
+ if (data.response === 'not implemented') {
+ this.sendMessage(post);
+ return;
+ }
+
PostStore.storeDraft(data.channel_id, null);
this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
@@ -130,63 +136,70 @@ export default class CreatePost extends React.Component {
}
},
(err) => {
- const state = {};
- state.serverError = err.message;
- state.submitting = false;
- this.setState(state);
- }
- );
- } else {
- post.channel_id = this.state.channelId;
- post.filenames = this.state.previews;
-
- const time = Utils.getTimestamp();
- const userId = UserStore.getCurrentId();
- post.pending_post_id = `${userId}:${time}`;
- post.user_id = userId;
- post.create_at = time;
- post.root_id = this.state.rootId;
- post.parent_id = this.state.parentId;
-
- const channel = ChannelStore.get(this.state.channelId);
-
- PostStore.storePendingPost(post);
- PostStore.storeDraft(channel.id, null);
- this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
-
- Client.createPost(post, channel,
- (data) => {
- AsyncClient.getPosts();
-
- const member = ChannelStore.getMember(channel.id);
- member.msg_count = channel.total_msg_count;
- member.last_viewed_at = Date.now();
- ChannelStore.setChannelMember(member);
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECIEVED_POST,
- post: data
- });
- },
- (err) => {
- const state = {};
-
- if (err.message === 'Invalid RootId parameter') {
- if ($('#post_deleted').length > 0) {
- $('#post_deleted').modal('show');
- }
- PostStore.removePendingPost(post.pending_post_id);
+ if (err.sendMessage) {
+ this.sendMessage(post);
} else {
- post.state = Constants.POST_FAILED;
- PostStore.updatePendingPost(post);
+ const state = {};
+ state.serverError = err.message;
+ state.submitting = false;
+ this.setState(state);
}
-
- state.submitting = false;
- this.setState(state);
}
);
+ } else {
+ this.sendMessage(post);
}
}
+ sendMessage(post) {
+ post.channel_id = this.state.channelId;
+ post.filenames = this.state.previews;
+
+ const time = Utils.getTimestamp();
+ const userId = UserStore.getCurrentId();
+ post.pending_post_id = `${userId}:${time}`;
+ post.user_id = userId;
+ post.create_at = time;
+ post.root_id = this.state.rootId;
+ post.parent_id = this.state.parentId;
+
+ const channel = ChannelStore.get(this.state.channelId);
+
+ PostStore.storePendingPost(post);
+ PostStore.storeDraft(channel.id, null);
+ this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
+
+ Client.createPost(post, channel,
+ (data) => {
+ AsyncClient.getPosts();
+
+ const member = ChannelStore.getMember(channel.id);
+ member.msg_count = channel.total_msg_count;
+ member.last_viewed_at = Date.now();
+ ChannelStore.setChannelMember(member);
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECIEVED_POST,
+ post: data
+ });
+ },
+ (err) => {
+ const state = {};
+
+ if (err.message === 'Invalid RootId parameter') {
+ if ($('#post_deleted').length > 0) {
+ $('#post_deleted').modal('show');
+ }
+ PostStore.removePendingPost(post.pending_post_id);
+ } else {
+ post.state = Constants.POST_FAILED;
+ PostStore.updatePendingPost(post);
+ }
+
+ state.submitting = false;
+ this.setState(state);
+ }
+ );
+ }
postMsgKeyPress(e) {
if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) {
e.preventDefault();
@@ -195,7 +208,7 @@ export default class CreatePost extends React.Component {
}
const t = Date.now();
- if ((t - this.lastTime) > 5000) {
+ if ((t - this.lastTime) > Constants.UPDATE_TYPING_MS) {
SocketStore.sendMessage({channel_id: this.state.channelId, action: 'typing', props: {parent_id: ''}, state: {}});
this.lastTime = t;
}
@@ -240,8 +253,14 @@ export default class CreatePost extends React.Component {
this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
}
handleUploadError(err, clientId) {
+ let message = err;
+ if (message && typeof message !== 'string') {
+ // err is an AppError from the server
+ message = err.message;
+ }
+
if (clientId === -1) {
- this.setState({serverError: err});
+ this.setState({serverError: message});
} else {
const draft = PostStore.getDraft(this.state.channelId);
@@ -252,7 +271,7 @@ export default class CreatePost extends React.Component {
PostStore.storeDraft(this.state.channelId, draft);
- this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: err});
+ this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: message});
}
}
handleTextDrop(text) {
diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx
index 6311d9460..f098384aa 100644
--- a/web/react/components/error_bar.jsx
+++ b/web/react/components/error_bar.jsx
@@ -9,12 +9,8 @@ export default class ErrorBar extends React.Component {
this.onErrorChange = this.onErrorChange.bind(this);
this.handleClose = this.handleClose.bind(this);
- this.prevTimer = null;
this.state = ErrorStore.getLastError();
- if (this.isValidError(this.state)) {
- this.prevTimer = setTimeout(this.handleClose, 10000);
- }
}
isValidError(s) {
@@ -56,16 +52,8 @@ export default class ErrorBar extends React.Component {
onErrorChange() {
var newState = ErrorStore.getLastError();
- if (this.prevTimer != null) {
- clearInterval(this.prevTimer);
- this.prevTimer = null;
- }
-
if (newState) {
this.setState(newState);
- if (!this.isConnectionError(newState)) {
- this.prevTimer = setTimeout(this.handleClose, 10000);
- }
} else {
this.setState({message: null});
}
diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx
index 8c1da942d..60cae55d6 100644
--- a/web/react/components/mention_list.jsx
+++ b/web/react/components/mention_list.jsx
@@ -217,12 +217,17 @@ export default class MentionList extends React.Component {
if (this.state.selectedMention === index) {
isFocused = 'mentions-focus';
}
+
+ if (!users[i].secondary_text) {
+ users[i].secondary_text = Utils.getFullName(users[i]);
+ }
+
mentions[index] = (
<Mention
key={'mention_key_' + index}
ref={'mention' + index}
username={users[i].username}
- secondary_text={Utils.getFullName(users[i])}
+ secondary_text={users[i].secondary_text}
id={users[i].id}
listId={index}
isFocused={isFocused}
diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx
index 743e30854..b0232fc08 100644
--- a/web/react/components/more_direct_channels.jsx
+++ b/web/react/components/more_direct_channels.jsx
@@ -1,13 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-const AsyncClient = require('../utils/async_client.jsx');
-const ChannelStore = require('../stores/channel_store.jsx');
-const Constants = require('../utils/constants.jsx');
-const Client = require('../utils/client.jsx');
const Modal = ReactBootstrap.Modal;
-const PreferenceStore = require('../stores/preference_store.jsx');
-const TeamStore = require('../stores/team_store.jsx');
const UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx');
@@ -70,52 +64,24 @@ export default class MoreDirectChannels extends React.Component {
}
handleShowDirectChannel(teammate, e) {
+ e.preventDefault();
+
if (this.state.loadingDMChannel !== -1) {
return;
}
- e.preventDefault();
-
- const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id);
- let channel = ChannelStore.getByName(channelName);
-
- const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true');
- AsyncClient.savePreferences([preference]);
-
- if (channel) {
- Utils.switchChannel(channel);
-
- this.handleHide();
- } else {
- this.setState({loadingDMChannel: teammate.id});
-
- channel = {
- name: channelName,
- last_post_at: 0,
- total_msg_count: 0,
- type: 'D',
- display_name: teammate.username,
- teammate_id: teammate.id,
- status: UserStore.getStatus(teammate.id)
- };
-
- Client.createDirectChannel(
- channel,
- teammate.id,
- (data) => {
- this.setState({loadingDMChannel: -1});
-
- AsyncClient.getChannel(data.id);
- Utils.switchChannel(data);
-
- this.handleHide();
- },
- () => {
- this.setState({loadingDMChannel: -1});
- window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
- }
- );
- }
+ this.setState({loadingDMChannel: teammate.id});
+ Utils.openDirectChannelToUser(
+ teammate,
+ (channel) => {
+ Utils.switchChannel(channel);
+ this.setState({loadingDMChannel: -1});
+ this.handleHide();
+ },
+ () => {
+ this.setState({loadingDMChannel: -1});
+ }
+ );
}
handleUserChange() {
@@ -169,7 +135,7 @@ export default class MoreDirectChannels extends React.Component {
}
return (
- <tr>
+ <tr key={'direct-channel-row-user' + user.id}>
<td
key={user.id}
className='direct-channel'
diff --git a/web/react/components/msg_typing.jsx b/web/react/components/msg_typing.jsx
index 1bd23c55c..ccf8a2445 100644
--- a/web/react/components/msg_typing.jsx
+++ b/web/react/components/msg_typing.jsx
@@ -11,11 +11,11 @@ export default class MsgTyping extends React.Component {
constructor(props) {
super(props);
- this.timer = null;
- this.lastTime = 0;
-
this.onChange = this.onChange.bind(this);
+ this.updateTypingText = this.updateTypingText.bind(this);
+ this.componentWillReceiveProps = this.componentWillReceiveProps.bind(this);
+ this.typingUsers = {};
this.state = {
text: ''
};
@@ -27,7 +27,7 @@ export default class MsgTyping extends React.Component {
componentWillReceiveProps(newProps) {
if (this.props.channelId !== newProps.channelId) {
- this.setState({text: ''});
+ this.updateTypingText();
}
}
@@ -36,28 +36,51 @@ export default class MsgTyping extends React.Component {
}
onChange(msg) {
+ let username = 'Someone';
if (msg.action === SocketEvents.TYPING &&
this.props.channelId === msg.channel_id &&
this.props.parentId === msg.props.parent_id) {
- this.lastTime = new Date().getTime();
-
- var username = 'Someone';
if (UserStore.hasProfile(msg.user_id)) {
username = UserStore.getProfile(msg.user_id).username;
}
- this.setState({text: username + ' is typing...'});
-
- if (!this.timer) {
- this.timer = setInterval(function myTimer() {
- if ((new Date().getTime() - this.lastTime) > 8000) {
- this.setState({text: ''});
- }
- }.bind(this), 3000);
+ if (this.typingUsers[username]) {
+ clearTimeout(this.typingUsers[username]);
}
+
+ this.typingUsers[username] = setTimeout(function myTimer(user) {
+ delete this.typingUsers[user];
+ this.updateTypingText();
+ }.bind(this, username), Constants.UPDATE_TYPING_MS);
+
+ this.updateTypingText();
} else if (msg.action === SocketEvents.POSTED && msg.channel_id === this.props.channelId) {
- this.setState({text: ''});
+ if (UserStore.hasProfile(msg.user_id)) {
+ username = UserStore.getProfile(msg.user_id).username;
+ }
+ clearTimeout(this.typingUsers[username]);
+ delete this.typingUsers[username];
+ this.updateTypingText();
+ }
+ }
+
+ updateTypingText() {
+ const users = Object.keys(this.typingUsers);
+ let text = '';
+ switch (users.length) {
+ case 0:
+ text = '';
+ break;
+ case 1:
+ text = users[0] + ' is typing...';
+ break;
+ default:
+ const last = users.pop();
+ text = users.join(', ') + ' and ' + last + ' are typing...';
+ break;
}
+
+ this.setState({text});
}
render() {
diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx
index 155e88600..9cffa2400 100644
--- a/web/react/components/popover_list_members.jsx
+++ b/web/react/components/popover_list_members.jsx
@@ -3,9 +3,23 @@
var UserStore = require('../stores/user_store.jsx');
var Popover = ReactBootstrap.Popover;
-var OverlayTrigger = ReactBootstrap.OverlayTrigger;
+var Overlay = ReactBootstrap.Overlay;
+const Utils = require('../utils/utils.jsx');
+
+const ChannelStore = require('../stores/channel_store.jsx');
export default class PopoverListMembers extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
+ this.closePopover = this.closePopover.bind(this);
+ }
+
+ componentWillMount() {
+ this.setState({showPopover: false});
+ }
+
componentDidMount() {
const originalLeave = $.fn.popover.Constructor.prototype.leave;
$.fn.popover.Constructor.prototype.leave = function onLeave(obj) {
@@ -27,12 +41,36 @@ export default class PopoverListMembers extends React.Component {
}
};
}
+
+ handleShowDirectChannel(teammate, e) {
+ e.preventDefault();
+
+ Utils.openDirectChannelToUser(
+ teammate,
+ (channel, channelAlreadyExisted) => {
+ Utils.switchChannel(channel);
+ if (channelAlreadyExisted) {
+ this.closePopover();
+ }
+ },
+ () => {
+ this.closePopover();
+ }
+ );
+ }
+
+ closePopover() {
+ this.setState({showPopover: false});
+ }
+
render() {
let popoverHtml = [];
let count = 0;
let countText = '-';
const members = this.props.members;
const teamMembers = UserStore.getProfilesUsernameMap();
+ const currentUserId = UserStore.getCurrentId();
+ const ch = ChannelStore.getCurrent();
if (members && teamMembers) {
members.sort((a, b) => {
@@ -40,13 +78,74 @@ export default class PopoverListMembers extends React.Component {
});
members.forEach((m, i) => {
+ const details = [];
+
+ const fullName = Utils.getFullName(m);
+ if (fullName) {
+ details.push(
+ <span
+ key={`${m.id}__full-name`}
+ className='full-name'
+ >
+ {fullName}
+ </span>
+ );
+ }
+
+ if (m.nickname) {
+ const separator = fullName ? ' - ' : '';
+ details.push(
+ <span
+ key={`${m.nickname}__nickname`}
+ >
+ {separator + m.nickname}
+ </span>
+ );
+ }
+
+ let button = '';
+ if (currentUserId !== m.id && ch.type !== 'D') {
+ button = (
+ <button
+ type='button'
+ className='btn btn-primary btn-message'
+ onClick={(e) => this.handleShowDirectChannel(m, e)}
+ >
+ {'Message'}
+ </button>
+ );
+ }
+
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push(
<div
className='text--nowrap'
key={'popover-member-' + i}
>
- {m.username}
+
+ <img
+ className='profile-img pull-left'
+ width='38'
+ height='38'
+ src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`}
+ />
+ <div className='pull-left'>
+ <div
+ className='more-name'
+ >
+ {m.username}
+ </div>
+ <div
+ className='more-description'
+ >
+ {details}
+ </div>
+ </div>
+ <div
+ className='pull-right profile-action'
+ >
+ {button}
+ </div>
</div>
);
count++;
@@ -61,29 +160,37 @@ export default class PopoverListMembers extends React.Component {
}
return (
- <OverlayTrigger
- trigger='click'
- placement='bottom'
- rootClose={true}
- overlay={
+ <div>
+ <div
+ id='member_popover'
+ ref='member_popover_target'
+ onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})}
+ >
+ <div>
+ {countText}
+ <span
+ className='fa fa-user'
+ aria-hidden='true'
+ />
+ </div>
+ </div>
+ <Overlay
+ rootClose={true}
+ onHide={this.closePopover}
+ show={this.state.showPopover}
+ target={() => this.state.popoverTarget}
+ placement='bottom'
+ >
<Popover
title='Members'
id='member-list-popover'
>
- {popoverHtml}
+ <div>
+ {popoverHtml}
+ </div>
</Popover>
- }
- >
- <div id='member_popover'>
- <div>
- {countText}
- <span
- className='fa fa-user'
- aria-hidden='true'
- />
- </div>
+ </Overlay>
</div>
- </OverlayTrigger>
);
}
}
diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx
new file mode 100644
index 000000000..03c7b894c
--- /dev/null
+++ b/web/react/components/search_autocomplete.jsx
@@ -0,0 +1,249 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const ChannelStore = require('../stores/channel_store.jsx');
+const KeyCodes = require('../utils/constants.jsx').KeyCodes;
+const UserStore = require('../stores/user_store.jsx');
+const Utils = require('../utils/utils.jsx');
+
+const patterns = new Map([
+ ['channels', /\b(?:in|channel):\s*(\S*)$/i],
+ ['users', /\bfrom:\s*(\S*)$/i]
+]);
+
+export default class SearchAutocomplete extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleClick = this.handleClick.bind(this);
+ this.handleDocumentClick = this.handleDocumentClick.bind(this);
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+
+ this.completeWord = this.completeWord.bind(this);
+ this.updateSuggestions = this.updateSuggestions.bind(this);
+
+ this.state = {
+ show: false,
+ mode: '',
+ filter: '',
+ selection: 0,
+ suggestions: new Map()
+ };
+ }
+
+ componentDidMount() {
+ $(document).on('click', this.handleDocumentClick);
+ }
+
+ componentWillUnmount() {
+ $(document).off('click', this.handleDocumentClick);
+ }
+
+ handleClick(value) {
+ this.completeWord(value);
+ }
+
+ handleDocumentClick(e) {
+ const container = $(ReactDOM.findDOMNode(this.refs.container));
+
+ if (!(container.is(e.target) || container.has(e.target).length > 0)) {
+ this.setState({
+ show: false
+ });
+ }
+ }
+
+ handleInputChange(textbox, text) {
+ const caret = Utils.getCaretPosition(textbox);
+ const preText = text.substring(0, caret);
+
+ let mode = '';
+ let filter = '';
+ for (const [modeForPattern, pattern] of patterns) {
+ const result = pattern.exec(preText);
+
+ if (result) {
+ mode = modeForPattern;
+ filter = result[1];
+ break;
+ }
+ }
+
+ if (mode !== this.state.mode || filter !== this.state.filter) {
+ this.updateSuggestions(mode, filter);
+ }
+
+ this.setState({
+ mode,
+ filter,
+ show: mode || filter
+ });
+ }
+
+ handleKeyDown(e) {
+ if (!this.state.show || this.state.suggestions.length === 0) {
+ return;
+ }
+
+ if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) {
+ e.preventDefault();
+
+ let selection = this.state.selection;
+
+ if (e.which === KeyCodes.UP) {
+ selection -= 1;
+ } else {
+ selection += 1;
+ }
+
+ if (selection >= 0 && selection < this.state.suggestions.length) {
+ this.setState({
+ selection
+ });
+ }
+ } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) {
+ e.preventDefault();
+
+ this.completeSelectedWord();
+ }
+ }
+
+ completeSelectedWord() {
+ if (this.state.mode === 'channels') {
+ this.completeWord(this.state.suggestions[this.state.selection].name);
+ } else if (this.state.mode === 'users') {
+ this.completeWord(this.state.suggestions[this.state.selection].username);
+ }
+ }
+
+ completeWord(value) {
+ // add a space so that anything else typed doesn't interfere with the search flag
+ this.props.completeWord(this.state.filter, value + ' ');
+
+ this.setState({
+ show: false,
+ mode: '',
+ filter: '',
+ selection: 0
+ });
+ }
+
+ updateSuggestions(mode, filter) {
+ let suggestions = [];
+
+ if (mode === 'channels') {
+ let channels = ChannelStore.getAll();
+
+ if (filter) {
+ channels = channels.filter((channel) => channel.name.startsWith(filter));
+ }
+
+ channels.sort((a, b) => a.name.localeCompare(b.name));
+
+ suggestions = channels;
+ } else if (mode === 'users') {
+ let users = UserStore.getActiveOnlyProfileList();
+
+ if (filter) {
+ users = users.filter((user) => user.username.startsWith(filter));
+ }
+
+ users.sort((a, b) => a.username.localeCompare(b.username));
+
+ suggestions = users;
+ }
+
+ let selection = this.state.selection;
+
+ // keep the same user/channel selected if it's still visible as a suggestion
+ if (selection > 0 && this.state.suggestions.length > 0) {
+ // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects
+ const currentSelectionId = this.state.suggestions[selection].id;
+ let found = false;
+
+ for (let i = 0; i < suggestions.length; i++) {
+ if (suggestions[i].id === currentSelectionId) {
+ selection = i;
+ found = true;
+
+ break;
+ }
+ }
+
+ if (!found) {
+ selection = 0;
+ }
+ } else {
+ selection = 0;
+ }
+
+ this.setState({
+ suggestions,
+ selection
+ });
+ }
+
+ render() {
+ if (!this.state.show || this.state.suggestions.length === 0) {
+ return null;
+ }
+
+ let suggestions = [];
+
+ if (this.state.mode === 'channels') {
+ suggestions = this.state.suggestions.map((channel, index) => {
+ let className = 'search-autocomplete__channel';
+ if (this.state.selection === index) {
+ className += ' selected';
+ }
+
+ return (
+ <div
+ key={channel.name}
+ ref={channel.name}
+ onClick={this.handleClick.bind(this, channel.name)}
+ className={className}
+ >
+ {channel.name}
+ </div>
+ );
+ });
+ } else if (this.state.mode === 'users') {
+ suggestions = this.state.suggestions.map((user, index) => {
+ let className = 'search-autocomplete__user';
+ if (this.state.selection === index) {
+ className += ' selected';
+ }
+
+ return (
+ <div
+ key={user.username}
+ ref={user.username}
+ onClick={this.handleClick.bind(this, user.username)}
+ className={className}
+ >
+ <img
+ className='profile-img'
+ src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
+ />
+ {user.username}
+ </div>
+ );
+ });
+ }
+
+ return (
+ <div
+ ref='container'
+ className='search-autocomplete'
+ >
+ {suggestions}
+ </div>
+ );
+ }
+}
+
+SearchAutocomplete.propTypes = {
+ completeWord: React.PropTypes.func.isRequired
+};
diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx
index 7540b41a4..0da43e8cd 100644
--- a/web/react/components/search_bar.jsx
+++ b/web/react/components/search_bar.jsx
@@ -9,6 +9,7 @@ var utils = require('../utils/utils.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
var Popover = ReactBootstrap.Popover;
+var SearchAutocomplete = require('./search_autocomplete.jsx');
export default class SearchBar extends React.Component {
constructor() {
@@ -16,11 +17,13 @@ export default class SearchBar extends React.Component {
this.mounted = false;
this.onListenerChange = this.onListenerChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleUserFocus = this.handleUserFocus.bind(this);
this.handleUserBlur = this.handleUserBlur.bind(this);
this.performSearch = this.performSearch.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
+ this.completeWord = this.completeWord.bind(this);
const state = this.getSearchTermStateFromStores();
state.focused = false;
@@ -74,11 +77,18 @@ export default class SearchBar extends React.Component {
results: null
});
}
+ handleKeyDown(e) {
+ if (this.refs.autocomplete) {
+ this.refs.autocomplete.handleKeyDown(e);
+ }
+ }
handleUserInput(e) {
var term = e.target.value;
PostStore.storeSearchTerm(term);
PostStore.emitSearchTermChange(false);
this.setState({searchTerm: term});
+
+ this.refs.autocomplete.handleInputChange(e.target, term);
}
handleMouseInput(e) {
e.preventDefault();
@@ -95,9 +105,16 @@ export default class SearchBar extends React.Component {
performSearch(terms, isMentionSearch) {
if (terms.length) {
this.setState({isSearching: true});
+
+ // append * if not present
+ let searchTerms = terms;
+ if (searchTerms.search(/\*\s*$/) === -1) {
+ searchTerms = searchTerms + '*';
+ }
+
client.search(
- terms,
- function success(data) {
+ searchTerms,
+ (data) => {
this.setState({isSearching: false});
if (utils.isMobile()) {
ReactDOM.findDOMNode(this.refs.search).value = '';
@@ -108,11 +125,11 @@ export default class SearchBar extends React.Component {
results: data,
is_mention_search: isMentionSearch
});
- }.bind(this),
- function error(err) {
+ },
+ (err) => {
this.setState({isSearching: false});
AsyncClient.dispatchError(err, 'search');
- }.bind(this)
+ }
);
}
}
@@ -120,6 +137,24 @@ export default class SearchBar extends React.Component {
e.preventDefault();
this.performSearch(this.state.searchTerm.trim());
}
+
+ completeWord(partialWord, word) {
+ const textbox = ReactDOM.findDOMNode(this.refs.search);
+ let text = textbox.value;
+
+ const caret = utils.getCaretPosition(textbox);
+ const preText = text.substring(0, caret - partialWord.length);
+ const postText = text.substring(caret);
+ text = preText + word + postText;
+
+ textbox.value = text;
+ utils.setCaretPosition(textbox, preText.length + word.length);
+
+ PostStore.storeSearchTerm(text);
+ PostStore.emitSearchTermChange(false);
+ this.setState({searchTerm: text});
+ }
+
render() {
var isSearching = null;
if (this.state.isSearching) {
@@ -143,12 +178,13 @@ export default class SearchBar extends React.Component {
className='search__clear'
onClick={this.clearFocus}
>
- Cancel
+ {'Cancel'}
</span>
<form
role='form'
className='search__form relative-div'
onSubmit={this.handleSubmit}
+ style={{overflow: 'visible'}}
>
<span className='glyphicon glyphicon-search sidebar__search-icon' />
<input
@@ -160,10 +196,16 @@ export default class SearchBar extends React.Component {
onFocus={this.handleUserFocus}
onBlur={this.handleUserBlur}
onChange={this.handleUserInput}
+ onKeyDown={this.handleKeyDown}
onMouseUp={this.handleMouseInput}
/>
{isSearching}
+ <SearchAutocomplete
+ ref='autocomplete'
+ completeWord={this.completeWord}
+ />
<Popover
+ id='searchbar-help-popup'
placement='bottom'
className={helpClass}
>
diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx
index ed2c84057..5cb6d168b 100644
--- a/web/react/components/sidebar.jsx
+++ b/web/react/components/sidebar.jsx
@@ -40,6 +40,9 @@ export default class Sidebar extends React.Component {
this.hideMoreDirectChannelsModal = this.hideMoreDirectChannelsModal.bind(this);
this.createChannelElement = this.createChannelElement.bind(this);
+ this.updateTitle = this.updateTitle.bind(this);
+ this.setUnreadCountPerChannel = this.setUnreadCountPerChannel.bind(this);
+ this.getUnreadCount = this.getUnreadCount.bind(this);
this.isLeaving = new Map();
@@ -48,8 +51,45 @@ export default class Sidebar extends React.Component {
state.showDirectChannelsModal = false;
state.loadingDMChannel = -1;
state.windowWidth = Utils.windowWidth();
-
this.state = state;
+
+ this.unreadCountPerChannel = {};
+ this.setUnreadCountPerChannel();
+ }
+ setUnreadCountPerChannel() {
+ const channels = ChannelStore.getAll();
+ const members = ChannelStore.getAllMembers();
+ const channelUnreadCounts = {};
+
+ channels.forEach((ch) => {
+ const chMember = members[ch.id];
+ let chMentionCount = chMember.mention_count;
+ let chUnreadCount = ch.total_msg_count - chMember.msg_count - chMentionCount;
+
+ if (ch.type === 'D') {
+ chMentionCount = chUnreadCount;
+ chUnreadCount = 0;
+ }
+
+ channelUnreadCounts[ch.id] = {msgs: chUnreadCount, mentions: chMentionCount};
+ });
+
+ this.unreadCountPerChannel = channelUnreadCounts;
+ }
+ getUnreadCount(channelId) {
+ let mentions = 0;
+ let msgs = 0;
+
+ if (channelId) {
+ return this.unreadCountPerChannel[channelId] ? this.unreadCountPerChannel[channelId] : {msgs, mentions};
+ }
+
+ Object.keys(this.unreadCountPerChannel).forEach((chId) => {
+ msgs += this.unreadCountPerChannel[chId].msgs;
+ mentions += this.unreadCountPerChannel[chId].mentions;
+ });
+
+ return {msgs, mentions};
}
getStateFromStores() {
const members = ChannelStore.getAllMembers();
@@ -192,7 +232,10 @@ export default class Sidebar extends React.Component {
currentChannelName = Utils.getDirectTeammate(channel.id).username;
}
- document.title = currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName;
+ const unread = this.getUnreadCount();
+ const mentionTitle = unread.mentions > 0 ? '(' + unread.mentions + ') ' : '';
+ const unreadTitle = unread.msgs > 0 ? '* ' : '';
+ document.title = mentionTitle + unreadTitle + currentChannelName + ' - ' + this.props.teamDisplayName + ' ' + currentSiteName;
}
}
onScroll() {
@@ -273,6 +316,7 @@ export default class Sidebar extends React.Component {
var members = this.state.members;
var activeId = this.state.activeId;
var channelMember = members[channel.id];
+ var unreadCount = this.getUnreadCount(channel.id);
var msgCount;
var linkClass = '';
@@ -284,7 +328,7 @@ export default class Sidebar extends React.Component {
var unread = false;
if (channelMember) {
- msgCount = channel.total_msg_count - channelMember.msg_count;
+ msgCount = unreadCount.msgs + unreadCount.mentions;
unread = (msgCount > 0 && channelMember.notify_props.mark_unread !== 'mention') || channelMember.mention_count > 0;
}
@@ -301,16 +345,8 @@ export default class Sidebar extends React.Component {
var badge = null;
if (channelMember) {
- if (channel.type === 'D') {
- // direct message channels show badges for any number of unread posts
- msgCount = channel.total_msg_count - channelMember.msg_count;
- if (msgCount > 0) {
- badge = <span className='badge pull-right small'>{msgCount}</span>;
- this.badgesActive = true;
- }
- } else if (channelMember.mention_count > 0) {
- // public and private channels only show badges for mentions
- badge = <span className='badge pull-right small'>{channelMember.mention_count}</span>;
+ if (unreadCount.mentions) {
+ badge = <span className='badge pull-right small'>{unreadCount.mentions}</span>;
this.badgesActive = true;
}
} else if (this.state.loadingDMChannel === index && channel.type === 'D') {
@@ -434,6 +470,8 @@ export default class Sidebar extends React.Component {
render() {
this.badgesActive = false;
+ this.setUnreadCountPerChannel();
+
// keep track of the first and last unread channels so we can use them to set the unread indicators
this.firstUnreadChannel = null;
this.lastUnreadChannel = null;
diff --git a/web/react/components/team_signup_send_invites_page.jsx b/web/react/components/team_signup_send_invites_page.jsx
index b42343da0..7b4db8fae 100644
--- a/web/react/components/team_signup_send_invites_page.jsx
+++ b/web/react/components/team_signup_send_invites_page.jsx
@@ -15,11 +15,6 @@ export default class TeamSignupSendInvitesPage extends React.Component {
this.state = {
emailEnabled: global.window.mm_config.SendEmailNotifications === 'true'
};
-
- if (!this.state.emailEnabled) {
- this.props.state.wizard = 'username';
- this.props.updateParent(this.props.state);
- }
}
submitBack(e) {
e.preventDefault();
diff --git a/web/react/components/team_signup_url_page.jsx b/web/react/components/team_signup_url_page.jsx
index 6d333aed6..02d5cab8e 100644
--- a/web/react/components/team_signup_url_page.jsx
+++ b/web/react/components/team_signup_url_page.jsx
@@ -54,7 +54,11 @@ export default class TeamSignupUrlPage extends React.Component {
if (data) {
this.setState({nameError: 'This URL is unavailable. Please try another.'});
} else {
- this.props.state.wizard = 'send_invites';
+ if (global.window.mm_config.SendEmailNotifications === 'true') {
+ this.props.state.wizard = 'send_invites';
+ } else {
+ this.props.state.wizard = 'username';
+ }
this.props.state.team.type = 'O';
this.props.state.team.name = name;
diff --git a/web/react/components/team_signup_welcome_page.jsx b/web/react/components/team_signup_welcome_page.jsx
index ee325fcaf..9448413ce 100644
--- a/web/react/components/team_signup_welcome_page.jsx
+++ b/web/react/components/team_signup_welcome_page.jsx
@@ -104,21 +104,19 @@ export default class TeamSignupWelcomePage extends React.Component {
return (
<div>
- <p>
- <img
- className='signup-team-logo'
- src='/static/images/logo.png'
- />
- <h3 className='sub-heading'>Welcome to:</h3>
- <h1 className='margin--top-none'>{global.window.mm_config.SiteName}</h1>
- </p>
+ <img
+ className='signup-team-logo'
+ src='/static/images/logo.png'
+ />
+ <h3 className='sub-heading'>Welcome to:</h3>
+ <h1 className='margin--top-none'>{global.window.mm_config.SiteName}</h1>
<p className='margin--less'>Let's set up your new team</p>
- <p>
+ <div>
Please confirm your email address:<br />
<div className='inner__content'>
<div className='block--gray'>{this.props.state.team.email}</div>
</div>
- </p>
+ </div>
<p className='margin--extra color--light'>
Your account will administer the new team site. <br />
You can add other administrators later.
diff --git a/web/react/components/user_settings/code_theme_chooser.jsx b/web/react/components/user_settings/code_theme_chooser.jsx
new file mode 100644
index 000000000..eef4b24ba
--- /dev/null
+++ b/web/react/components/user_settings/code_theme_chooser.jsx
@@ -0,0 +1,55 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var Constants = require('../../utils/constants.jsx');
+
+export default class CodeThemeChooser extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {};
+ }
+ render() {
+ const theme = this.props.theme;
+
+ const premadeThemes = [];
+ for (const k in Constants.CODE_THEMES) {
+ if (Constants.CODE_THEMES.hasOwnProperty(k)) {
+ let activeClass = '';
+ if (k === theme.codeTheme) {
+ activeClass = 'active';
+ }
+
+ premadeThemes.push(
+ <div
+ className='col-xs-6 col-sm-3 premade-themes'
+ key={'premade-theme-key' + k}
+ >
+ <div
+ className={activeClass}
+ onClick={() => this.props.updateTheme(k)}
+ >
+ <label>
+ <img
+ className='img-responsive'
+ src={'/static/images/themes/code_themes/' + k + '.png'}
+ />
+ <div className='theme-label'>{Constants.CODE_THEMES[k]}</div>
+ </label>
+ </div>
+ </div>
+ );
+ }
+ }
+
+ return (
+ <div className='row'>
+ {premadeThemes}
+ </div>
+ );
+ }
+}
+
+CodeThemeChooser.propTypes = {
+ theme: React.PropTypes.object.isRequired,
+ updateTheme: React.PropTypes.func.isRequired
+};
diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx
index 8c62a189d..e94894a1d 100644
--- a/web/react/components/user_settings/user_settings_appearance.jsx
+++ b/web/react/components/user_settings/user_settings_appearance.jsx
@@ -7,6 +7,7 @@ var Utils = require('../../utils/utils.jsx');
const CustomThemeChooser = require('./custom_theme_chooser.jsx');
const PremadeThemeChooser = require('./premade_theme_chooser.jsx');
+const CodeThemeChooser = require('./code_theme_chooser.jsx');
const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx');
const Constants = require('../../utils/constants.jsx');
const ActionTypes = Constants.ActionTypes;
@@ -18,12 +19,14 @@ export default class UserSettingsAppearance extends React.Component {
this.onChange = this.onChange.bind(this);
this.submitTheme = this.submitTheme.bind(this);
this.updateTheme = this.updateTheme.bind(this);
+ this.updateCodeTheme = this.updateCodeTheme.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleImportModal = this.handleImportModal.bind(this);
this.state = this.getStateFromStores();
this.originalTheme = this.state.theme;
+ this.originalCodeTheme = this.state.theme.codeTheme;
}
componentDidMount() {
UserStore.addChangeListener(this.onChange);
@@ -58,6 +61,10 @@ export default class UserSettingsAppearance extends React.Component {
type = 'custom';
}
+ if (!theme.codeTheme) {
+ theme.codeTheme = Constants.DEFAULT_CODE_THEME;
+ }
+
return {theme, type};
}
onChange() {
@@ -93,6 +100,13 @@ export default class UserSettingsAppearance extends React.Component {
);
}
updateTheme(theme) {
+ theme.codeTheme = this.state.theme.codeTheme;
+ this.setState({theme});
+ Utils.applyTheme(theme);
+ }
+ updateCodeTheme(codeTheme) {
+ var theme = this.state.theme;
+ theme.codeTheme = codeTheme;
this.setState({theme});
Utils.applyTheme(theme);
}
@@ -102,6 +116,7 @@ export default class UserSettingsAppearance extends React.Component {
handleClose() {
const state = this.getStateFromStores();
state.serverError = null;
+ state.theme.codeTheme = this.originalCodeTheme;
Utils.applyTheme(state.theme);
@@ -170,7 +185,13 @@ export default class UserSettingsAppearance extends React.Component {
</div>
{custom}
<hr />
- {serverError}
+ <strong className='radio'>{'Code Theme'}</strong>
+ <CodeThemeChooser
+ theme={this.state.theme}
+ updateTheme={this.updateCodeTheme}
+ />
+ <hr />
+ {serverError}
<a
className='btn btn-sm btn-primary'
href='#'
diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx
index 70e559c30..1c8ce3c79 100644
--- a/web/react/components/user_settings/user_settings_general.jsx
+++ b/web/react/components/user_settings/user_settings_general.jsx
@@ -171,7 +171,7 @@ export default class UserSettingsGeneralTab extends React.Component {
}.bind(this),
function imageUploadFailure(err) {
var state = this.setupInitialState(this.props);
- state.serverError = err;
+ state.serverError = err.message;
this.setState(state);
}.bind(this)
);
diff --git a/web/react/package.json b/web/react/package.json
index e6a662375..9af6f5880 100644
--- a/web/react/package.json
+++ b/web/react/package.json
@@ -6,6 +6,7 @@
"autolinker": "0.18.1",
"babel-runtime": "5.8.24",
"flux": "2.1.1",
+ "highlight.js": "^8.9.1",
"keymirror": "0.1.1",
"marked": "0.3.5",
"object-assign": "3.0.0",
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
index 9410c1e9c..4d69a6716 100644
--- a/web/react/stores/socket_store.jsx
+++ b/web/react/stores/socket_store.jsx
@@ -86,7 +86,7 @@ class SocketStoreClass extends EventEmitter {
this.failCount = this.failCount + 1;
- ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'});
+ ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'});
ErrorStore.emitChange();
};
@@ -160,7 +160,7 @@ function handleNewPostEvent(msg) {
if (window.isActive) {
AsyncClient.updateLastViewedAt(true);
}
- } else {
+ } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) {
AsyncClient.getChannel(msg.channel_id);
}
diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx
index 575e6d51e..ce80c5ec9 100644
--- a/web/react/stores/user_store.jsx
+++ b/web/react/stores/user_store.jsx
@@ -48,6 +48,7 @@ class UserStoreClass extends EventEmitter {
this.getProfilesUsernameMap = this.getProfilesUsernameMap.bind(this);
this.getProfiles = this.getProfiles.bind(this);
this.getActiveOnlyProfiles = this.getActiveOnlyProfiles.bind(this);
+ this.getActiveOnlyProfileList = this.getActiveOnlyProfileList.bind(this);
this.saveProfile = this.saveProfile.bind(this);
this.setSessions = this.setSessions.bind(this);
this.getSessions = this.getSessions.bind(this);
@@ -215,6 +216,19 @@ class UserStoreClass extends EventEmitter {
return active;
}
+ getActiveOnlyProfileList() {
+ const profileMap = this.getActiveOnlyProfiles();
+ const profiles = [];
+
+ for (const id in profileMap) {
+ if (profileMap.hasOwnProperty(id)) {
+ profiles.push(profileMap[id]);
+ }
+ }
+
+ return profiles;
+ }
+
saveProfile(profile) {
var ps = this.getProfiles();
ps[profile.id] = profile;
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index b1bc71d54..75dd35e3f 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -132,7 +132,7 @@ export function getChannel(id) {
callTracker['getChannel' + id] = utils.getTimestamp();
client.getChannel(id,
- function getChannelSuccess(data, textStatus, xhr) {
+ (data, textStatus, xhr) => {
callTracker['getChannel' + id] = 0;
if (xhr.status === 304 || !data) {
@@ -145,7 +145,7 @@ export function getChannel(id) {
member: data.member
});
},
- function getChannelFailure(err) {
+ (err) => {
callTracker['getChannel' + id] = 0;
dispatchError(err, 'getChannel');
}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index bc73f3c64..a93257dd2 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -34,7 +34,7 @@ function handleError(methodName, xhr, status, err) {
if (oldError && oldError.connErrorCount) {
errorCount += oldError.connErrorCount;
- connectError = 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.';
+ connectError = 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.';
}
e = {message: connectError, connErrorCount: errorCount};
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 7d2626fc1..0e89b9470 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -98,6 +98,7 @@ module.exports = {
POST_LOADING: 'loading',
POST_FAILED: 'failed',
POST_DELETED: 'deleted',
+ POST_TYPE_JOIN_LEAVE: 'join_leave',
RESERVED_TEAM_NAMES: [
'www',
'web',
@@ -132,6 +133,7 @@ module.exports = {
OFFLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path fill='#cccccc' d='M6.002,7.143C5.645,7.363,5.167,7.52,4.502,7.52c-2.493,0-2.5-2.02-2.5-2.02S1.029,5.607,0.775,6.004C0.41,6.577,0.15,7.716,0.049,8.545c-0.025,0.145-0.057,0.537-0.05,0.598c0.162,1.295,2.237,2.321,4.375,2.357c0.043,0.001,0.085,0.001,0.127,0.001c0.043,0,0.084,0,0.127-0.001c1.879-0.023,3.793-0.879,4.263-2h-2.89L6.002,7.143L6.002,7.143z M4.501,5.488c1.372,0,2.483-1.117,2.483-2.494c0-1.378-1.111-2.495-2.483-2.495c-1.371,0-2.481,1.117-2.481,2.495C2.02,4.371,3.13,5.488,4.501,5.488z M7.002,6.5v2h5v-2H7.002z'/></g></g></svg>",
MENU_ICON: "<svg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='4px' height='16px' viewBox='0 0 8 32' enable-background='new 0 0 8 32' xml:space='preserve'> <g> <circle cx='4' cy='4.062' r='4'/> <circle cx='4' cy='16' r='4'/> <circle cx='4' cy='28' r='4'/> </g> </svg>",
COMMENT_ICON: "<svg version='1.1' id='Layer_2' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px'width='15px' height='15px' viewBox='1 1.5 15 15' enable-background='new 1 1.5 15 15' xml:space='preserve'> <g> <g> <path fill='#211B1B' d='M14,1.5H3c-1.104,0-2,0.896-2,2v8c0,1.104,0.896,2,2,2h1.628l1.884,3l1.866-3H14c1.104,0,2-0.896,2-2v-8 C16,2.396,15.104,1.5,14,1.5z M15,11.5c0,0.553-0.447,1-1,1H8l-1.493,2l-1.504-1.991L5,12.5H3c-0.552,0-1-0.447-1-1v-8 c0-0.552,0.448-1,1-1h11c0.553,0,1,0.448,1,1V11.5z'/> </g> </g> </svg>",
+ UPDATE_TYPING_MS: 5000,
THEMES: {
default: {
type: 'Mattermost',
@@ -300,6 +302,13 @@ module.exports = {
uiName: 'Mention Highlight Link'
}
],
+ CODE_THEMES: {
+ github: 'GitHub',
+ solarized_light: 'Solarized light',
+ monokai: 'Monokai',
+ solarized_dark: 'Solarized Dark'
+ },
+ DEFAULT_CODE_THEME: 'github',
Preferences: {
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
CATEGORY_DISPLAY_SETTINGS: 'display_settings'
@@ -311,6 +320,32 @@ module.exports = {
RIGHT: 39,
BACKSPACE: 8,
ENTER: 13,
- ESCAPE: 27
+ ESCAPE: 27,
+ SPACE: 32
+ },
+ HighlightedLanguages: {
+ diff: 'Diff',
+ apache: 'Apache',
+ makefile: 'Makefile',
+ http: 'HTTP',
+ json: 'JSON',
+ markdown: 'Markdown',
+ javascript: 'JavaScript',
+ css: 'CSS',
+ nginx: 'nginx',
+ objectivec: 'Objective-C',
+ python: 'Python',
+ xml: 'XML',
+ perl: 'Perl',
+ bash: 'Bash',
+ php: 'PHP',
+ coffeescript: 'CoffeeScript',
+ cs: 'C#',
+ cpp: 'C++',
+ sql: 'SQL',
+ go: 'Go',
+ ruby: 'Ruby',
+ java: 'Java',
+ ini: 'ini'
}
};
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index 7a4e70054..ad11a95ac 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -6,6 +6,34 @@ const Utils = require('./utils.jsx');
const marked = require('marked');
+const highlightJs = require('highlight.js/lib/highlight.js');
+const highlightJsDiff = require('highlight.js/lib/languages/diff.js');
+const highlightJsApache = require('highlight.js/lib/languages/apache.js');
+const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js');
+const highlightJsHttp = require('highlight.js/lib/languages/http.js');
+const highlightJsJson = require('highlight.js/lib/languages/json.js');
+const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js');
+const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js');
+const highlightJsCss = require('highlight.js/lib/languages/css.js');
+const highlightJsNginx = require('highlight.js/lib/languages/nginx.js');
+const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js');
+const highlightJsPython = require('highlight.js/lib/languages/python.js');
+const highlightJsXml = require('highlight.js/lib/languages/xml.js');
+const highlightJsPerl = require('highlight.js/lib/languages/perl.js');
+const highlightJsBash = require('highlight.js/lib/languages/bash.js');
+const highlightJsPhp = require('highlight.js/lib/languages/php.js');
+const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js');
+const highlightJsCs = require('highlight.js/lib/languages/cs.js');
+const highlightJsCpp = require('highlight.js/lib/languages/cpp.js');
+const highlightJsSql = require('highlight.js/lib/languages/sql.js');
+const highlightJsGo = require('highlight.js/lib/languages/go.js');
+const highlightJsRuby = require('highlight.js/lib/languages/ruby.js');
+const highlightJsJava = require('highlight.js/lib/languages/java.js');
+const highlightJsIni = require('highlight.js/lib/languages/ini.js');
+
+const Constants = require('../utils/constants.jsx');
+const HighlightedLanguages = Constants.HighlightedLanguages;
+
export class MattermostMarkdownRenderer extends marked.Renderer {
constructor(options, formattingOptions = {}) {
super(options);
@@ -15,6 +43,43 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
this.text = this.text.bind(this);
this.formattingOptions = formattingOptions;
+
+ highlightJs.registerLanguage('diff', highlightJsDiff);
+ highlightJs.registerLanguage('apache', highlightJsApache);
+ highlightJs.registerLanguage('makefile', highlightJsMakefile);
+ highlightJs.registerLanguage('http', highlightJsHttp);
+ highlightJs.registerLanguage('json', highlightJsJson);
+ highlightJs.registerLanguage('markdown', highlightJsMarkdown);
+ highlightJs.registerLanguage('javascript', highlightJsJavascript);
+ highlightJs.registerLanguage('css', highlightJsCss);
+ highlightJs.registerLanguage('nginx', highlightJsNginx);
+ highlightJs.registerLanguage('objectivec', highlightJsObjectivec);
+ highlightJs.registerLanguage('python', highlightJsPython);
+ highlightJs.registerLanguage('xml', highlightJsXml);
+ highlightJs.registerLanguage('perl', highlightJsPerl);
+ highlightJs.registerLanguage('bash', highlightJsBash);
+ highlightJs.registerLanguage('php', highlightJsPhp);
+ highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript);
+ highlightJs.registerLanguage('cs', highlightJsCs);
+ highlightJs.registerLanguage('cpp', highlightJsCpp);
+ highlightJs.registerLanguage('sql', highlightJsSql);
+ highlightJs.registerLanguage('go', highlightJsGo);
+ highlightJs.registerLanguage('ruby', highlightJsRuby);
+ highlightJs.registerLanguage('java', highlightJsJava);
+ highlightJs.registerLanguage('ini', highlightJsIni);
+ }
+
+ code(code, language) {
+ if (!language || highlightJs.listLanguages().indexOf(language) < 0) {
+ let parsed = super.code(code, language);
+ return '<code class="hljs">' + $(parsed).text() + '</code>';
+ }
+
+ let parsed = highlightJs.highlight(language, code);
+ return '<div class="post-body--code">' +
+ '<span class="post-body--code__language">' + HighlightedLanguages[language] + '</span>' +
+ '<code style="white-space: pre;" class="hljs">' + parsed.value + '</code>' +
+ '</div>';
}
br() {
@@ -56,8 +121,11 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
paragraph(text) {
let outText = text;
+ // required so markdown does not strip '_' from @user_names
+ outText = TextFormatting.doFormatMentions(text);
+
if (!('emoticons' in this.options) || this.options.emoticon) {
- outText = TextFormatting.doFormatEmoticons(text);
+ outText = TextFormatting.doFormatEmoticons(outText);
}
if (this.formattingOptions.singleline) {
@@ -71,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
}
- text(text) {
- return TextFormatting.doFormatText(text, this.formattingOptions);
+ text(txt) {
+ return TextFormatting.doFormatText(txt, this.formattingOptions);
}
}
diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx
index 5c2e68f1e..9f1a5a53f 100644
--- a/web/react/utils/text_formatting.jsx
+++ b/web/react/utils/text_formatting.jsx
@@ -47,8 +47,8 @@ export function doFormatText(text, options) {
const tokens = new Map();
// replace important words and phrases with tokens
- output = autolinkUrls(output, tokens);
output = autolinkAtMentions(output, tokens);
+ output = autolinkUrls(output, tokens);
output = autolinkHashtags(output, tokens);
if (!('emoticons' in options) || options.emoticon) {
@@ -78,6 +78,13 @@ export function doFormatEmoticons(text) {
return output;
}
+export function doFormatMentions(text) {
+ const tokens = new Map();
+ let output = autolinkAtMentions(text, tokens);
+ output = replaceTokens(output, tokens);
+ return output;
+}
+
export function sanitizeHtml(text) {
let output = text;
@@ -91,6 +98,7 @@ export function sanitizeHtml(text) {
return output;
}
+// Convert URLs into tokens
function autolinkUrls(text, tokens) {
function replaceUrlWithToken(autolinker, match) {
const linkText = match.getMatchedText();
@@ -132,26 +140,61 @@ function autolinkUrls(text, tokens) {
}
function autolinkAtMentions(text, tokens) {
- let output = text;
+ // Return true if provided character is punctuation
+ function isPunctuation(character) {
+ const re = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]/g;
+ return re.test(character);
+ }
+
+ // Test if provided text needs to be highlighted, special mention or current user
+ function mentionExists(u) {
+ return (Constants.SPECIAL_MENTIONS.indexOf(u) !== -1 || UserStore.getProfileByUsername(u));
+ }
+
+ function addToken(username, mention, extraText) {
+ const index = tokens.size;
+ const alias = `MM_ATMENTION${index}`;
+
+ tokens.set(alias, {
+ value: `<a class='mention-link' href='#' data-mention='${username}'>${mention}</a>`,
+ originalText: mention,
+ extraText
+ });
+ return alias;
+ }
function replaceAtMentionWithToken(fullMatch, prefix, mention, username) {
- const usernameLower = username.toLowerCase();
- if (Constants.SPECIAL_MENTIONS.indexOf(usernameLower) !== -1 || UserStore.getProfileByUsername(usernameLower)) {
- const index = tokens.size;
- const alias = `MM_ATMENTION${index}`;
-
- tokens.set(alias, {
- value: `<a class='mention-link' href='#' data-mention='${usernameLower}'>${mention}</a>`,
- originalText: mention
- });
+ let usernameLower = username.toLowerCase();
+ if (mentionExists(usernameLower)) {
+ // Exact match
+ const alias = addToken(usernameLower, mention, '');
return prefix + alias;
}
+ // Not an exact match, attempt to truncate any punctuation to see if we can find a user
+ const originalUsername = usernameLower;
+
+ for (let c = usernameLower.length; c > 0; c--) {
+ if (isPunctuation(usernameLower[c - 1])) {
+ usernameLower = usernameLower.substring(0, c - 1);
+
+ if (mentionExists(usernameLower)) {
+ const extraText = originalUsername.substr(c - 1);
+ const alias = addToken(usernameLower, '@' + usernameLower, extraText);
+ return prefix + alias;
+ }
+ } else {
+ // If the last character is not punctuation, no point in going any further
+ break;
+ }
+ }
+
return fullMatch;
}
- output = output.replace(/(^|\s)(@([a-z0-9.\-_]*[a-z0-9]))/gi, replaceAtMentionWithToken);
+ let output = text;
+ output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken);
return output;
}
@@ -169,10 +212,9 @@ function highlightCurrentMentions(text, tokens) {
const newAlias = `MM_SELFMENTION${index}`;
newTokens.set(newAlias, {
- value: `<span class='mention-highlight'>${alias}</span>`,
+ value: `<span class='mention-highlight'>${alias}</span>` + token.extraText,
originalText: token.originalText
});
-
output = output.replace(alias, newAlias);
}
}
@@ -246,7 +288,7 @@ function highlightSearchTerm(text, tokens, searchTerm) {
var newTokens = new Map();
for (const [alias, token] of tokens) {
- if (token.originalText === searchTerm) {
+ if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) {
const index = tokens.size + newTokens.size;
const newAlias = `MM_SEARCHTERM${index}`;
@@ -276,7 +318,7 @@ function highlightSearchTerm(text, tokens, searchTerm) {
return prefix + alias;
}
- return output.replace(new RegExp(`(^|\\W)(${searchTerm})\\b`, 'gi'), replaceSearchTermWithToken);
+ return output.replace(new RegExp(`()(${searchTerm})`, 'gi'), replaceSearchTermWithToken);
}
function replaceTokens(text, tokens) {
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 67a9d6983..fadab27a7 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -8,6 +8,7 @@ var PreferenceStore = require('../stores/preference_store.jsx');
var TeamStore = require('../stores/team_store.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
+var Client = require('./client.jsx');
var AsyncClient = require('./async_client.jsx');
var client = require('./client.jsx');
var Autolinker = require('autolinker');
@@ -20,6 +21,7 @@ export function isEmail(email) {
export function cleanUpUrlable(input) {
var cleaned = input.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-');
+ cleaned = cleaned.replace(/-{2,}/, '-');
cleaned = cleaned.replace(/^\-+/, '');
cleaned = cleaned.replace(/\-+$/, '');
return cleaned;
@@ -402,6 +404,11 @@ export function toTitleCase(str) {
}
export function applyTheme(theme) {
+ if (!theme.codeTheme) {
+ theme.codeTheme = Constants.DEFAULT_CODE_THEME;
+ }
+ updateCodeTheme(theme.codeTheme);
+
if (theme.sidebarBg) {
changeCss('.sidebar--left, .settings-modal .settings-table .settings-links, .sidebar--menu', 'background:' + theme.sidebarBg, 1);
}
@@ -588,6 +595,27 @@ export function rgb2hex(rgbIn) {
return '#' + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]);
}
+export function updateCodeTheme(theme) {
+ const path = '/static/css/highlight/' + theme + '.css';
+ const $link = $('link.code_theme');
+ if (path !== $link.attr('href')) {
+ changeCss('code.hljs', 'visibility: hidden');
+ var xmlHTTP = new XMLHttpRequest();
+ xmlHTTP.open('GET', path, true);
+ xmlHTTP.onload = function onLoad() {
+ $link.attr('href', path);
+ if (isBrowserFirefox()) {
+ $link.one('load', () => {
+ changeCss('code.hljs', 'visibility: visible');
+ });
+ } else {
+ changeCss('code.hljs', 'visibility: visible');
+ }
+ };
+ xmlHTTP.send();
+ }
+}
+
export function placeCaretAtEnd(el) {
el.focus();
if (typeof window.getSelection != 'undefined' && typeof document.createRange != 'undefined') {
@@ -982,3 +1010,44 @@ export function windowWidth() {
export function windowHeight() {
return $(window).height();
}
+
+export function openDirectChannelToUser(user, successCb, errorCb) {
+ const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id);
+ let channel = ChannelStore.getByName(channelName);
+
+ const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
+ AsyncClient.savePreferences([preference]);
+
+ if (channel) {
+ if ($.isFunction(successCb)) {
+ successCb(channel, true);
+ }
+ } else {
+ channel = {
+ name: channelName,
+ last_post_at: 0,
+ total_msg_count: 0,
+ type: 'D',
+ display_name: user.username,
+ teammate_id: user.id,
+ status: UserStore.getStatus(user.id)
+ };
+
+ Client.createDirectChannel(
+ channel,
+ user.id,
+ (data) => {
+ AsyncClient.getChannel(data.id);
+ if ($.isFunction(successCb)) {
+ successCb(data, false);
+ }
+ },
+ () => {
+ window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
+ if ($.isFunction(errorCb)) {
+ errorCb();
+ }
+ }
+ );
+ }
+}
diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss
index 484e63c7c..4a2ad2748 100644
--- a/web/sass-files/sass/partials/_popover.scss
+++ b/web/sass-files/sass/partials/_popover.scss
@@ -61,3 +61,36 @@
@include opacity(1);
}
}
+
+#member-list-popover {
+ max-width: initial;
+ .popover-content > div {
+ max-height: 350px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ > div {
+ border-bottom: 1px solid rgba(51,51,51,0.1);
+ padding: 8px 8px 8px 15px;
+ width: 100%;
+ box-sizing: content-box;
+ @include clearfix;
+ .profile-img {
+ border-radius: 50px;
+ margin-right: 8px;
+ }
+ .more-name {
+ font-weight: 600;
+ font-size: 0.95em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .more-description {
+ @include opacity(0.7);
+ }
+ .profile-action {
+ margin-left: 8px;
+ margin-right: 18px;
+ }
+ }
+ }
+}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index f5fc1631f..3fac1fed9 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -466,6 +466,22 @@ body.ios {
white-space: nowrap;
cursor: pointer;
}
+ .post-body--code {
+ font-size: .97em;
+ position:relative;
+ .post-body--code__language {
+ position: absolute;
+ right: 0;
+ background: #fff;
+ cursor: default;
+ padding: 0.3em 0.5em 0.1em;
+ border-bottom-left-radius: 4px;
+ @include opacity(.3);
+ }
+ code {
+ white-space: pre;
+ }
+ }
}
.create-reply-form-wrap {
width: 100%;
diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss
index 2f15a445f..ce3563885 100644
--- a/web/sass-files/sass/partials/_search.scss
+++ b/web/sass-files/sass/partials/_search.scss
@@ -109,3 +109,43 @@
.search-highlight {
background-color: #FFF2BB;
}
+
+.search-autocomplete {
+ background-color: #fff;
+ border: $border-gray;
+ line-height: 36px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ position: absolute;
+ text-align: left;
+ width: 100%;
+ z-index: 100;
+ @extend %popover-box-shadow;
+}
+
+.search-autocomplete__channel {
+ cursor: pointer;
+ height: 36px;
+ padding: 0px 6px;
+
+ &.selected {
+ background-color:rgba(51, 51, 51, 0.15);
+ }
+}
+
+.search-autocomplete__user {
+ cursor: pointer;
+ height: 36px;
+ padding: 0px;
+
+ .profile-img {
+ height: 32px;
+ margin-right: 6px;
+ width: 32px;
+ @include border-radius(16px);
+ }
+
+ &.selected {
+ background-color:rgba(51, 51, 51, 0.15);
+ }
+}
diff --git a/web/static/css/highlight b/web/static/css/highlight
new file mode 120000
index 000000000..c774cf397
--- /dev/null
+++ b/web/static/css/highlight
@@ -0,0 +1 @@
+../../react/node_modules/highlight.js/styles/ \ No newline at end of file
diff --git a/web/static/images/themes/code_themes/github.png b/web/static/images/themes/code_themes/github.png
new file mode 100644
index 000000000..d0538d6c0
--- /dev/null
+++ b/web/static/images/themes/code_themes/github.png
Binary files differ
diff --git a/web/static/images/themes/code_themes/monokai.png b/web/static/images/themes/code_themes/monokai.png
new file mode 100644
index 000000000..8f92d2a18
--- /dev/null
+++ b/web/static/images/themes/code_themes/monokai.png
Binary files differ
diff --git a/web/static/images/themes/code_themes/solarized_dark.png b/web/static/images/themes/code_themes/solarized_dark.png
new file mode 100644
index 000000000..76055c678
--- /dev/null
+++ b/web/static/images/themes/code_themes/solarized_dark.png
Binary files differ
diff --git a/web/static/images/themes/code_themes/solarized_light.png b/web/static/images/themes/code_themes/solarized_light.png
new file mode 100644
index 000000000..b9595c22d
--- /dev/null
+++ b/web/static/images/themes/code_themes/solarized_light.png
Binary files differ
diff --git a/web/templates/head.html b/web/templates/head.html
index 041831ed7..fdc371af4 100644
--- a/web/templates/head.html
+++ b/web/templates/head.html
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="/static/css/bootstrap-colorpicker.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
<link rel="stylesheet" href="/static/css/google-fonts.css">
+ <link rel="stylesheet" class="code_theme" href="">
<link id="favicon" rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon">
diff --git a/web/web.go b/web/web.go
index 5f290ec99..bffe4858e 100644
--- a/web/web.go
+++ b/web/web.go
@@ -969,20 +969,20 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
- var props map[string]string
+ var parsedRequest *model.IncomingWebhookRequest
if r.Header.Get("Content-Type") == "application/json" {
- props = model.MapFromJson(r.Body)
+ parsedRequest = model.IncomingWebhookRequestFromJson(r.Body)
} else {
- props = model.MapFromJson(strings.NewReader(r.FormValue("payload")))
+ parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload")))
}
- text := props["text"]
+ text := parsedRequest.Text
if len(text) == 0 {
c.Err = model.NewAppError("incomingWebhook", "No text specified", "")
return
}
- channelName := props["channel"]
+ channelName := parsedRequest.ChannelName
var hook *model.IncomingWebhook
if result := <-hchan; result.Err != nil {
@@ -1012,8 +1012,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
cchan = api.Srv.Store.Channel().Get(hook.ChannelId)
}
- overrideUsername := props["username"]
- overrideIconUrl := props["icon_url"]
+ overrideUsername := parsedRequest.Username
+ overrideIconUrl := parsedRequest.IconURL
if result := <-cchan; result.Err != nil {
c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message)