summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/channel.go9
-rw-r--r--api/post.go1
-rw-r--r--api/team.go3
-rw-r--r--config/config.json8
-rw-r--r--doc/install/Administration.md2
-rw-r--r--doc/install/Troubleshooting.md2
-rw-r--r--docker/dev/config_docker.json6
-rw-r--r--docker/local/config_docker.json6
-rw-r--r--model/preference.go3
-rw-r--r--store/sql_user_store.go12
-rw-r--r--utils/mail.go3
-rw-r--r--web/react/components/about_build_modal.jsx4
-rw-r--r--web/react/components/audio_video_preview.jsx114
-rw-r--r--web/react/components/file_attachment.jsx4
-rw-r--r--web/react/components/file_info_preview.jsx31
-rw-r--r--web/react/components/file_preview.jsx7
-rw-r--r--web/react/components/post_body.jsx125
-rw-r--r--web/react/components/view_image.jsx104
-rw-r--r--web/react/components/youtube_video.jsx175
-rw-r--r--web/react/stores/channel_store.jsx2
-rw-r--r--web/react/utils/utils.jsx29
-rw-r--r--web/web.go12
22 files changed, 426 insertions, 236 deletions
diff --git a/api/channel.go b/api/channel.go
index f17594c0a..b85de3071 100644
--- a/api/channel.go
+++ b/api/channel.go
@@ -679,6 +679,15 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
Srv.Store.Channel().UpdateLastViewedAt(id, c.Session.UserId)
+ preference := model.Preference{
+ UserId: c.Session.UserId,
+ Category: model.PREFERENCE_CATEGORY_LAST,
+ Name: model.PREFERENCE_NAME_LAST_CHANNEL,
+ Value: id,
+ }
+
+ Srv.Store.Preference().Save(&model.Preferences{preference})
+
message := model.NewMessage(c.Session.TeamId, id, c.Session.UserId, model.ACTION_CHANNEL_VIEWED)
message.Add("channel_id", id)
diff --git a/api/post.go b/api/post.go
index 6c1d4bbd1..958479427 100644
--- a/api/post.go
+++ b/api/post.go
@@ -650,6 +650,7 @@ func sendNotificationsAndForget(c *Context, post *model.Post, team *model.Team,
httpClient := http.Client{}
request, _ := http.NewRequest("POST", *utils.Cfg.EmailSettings.PushNotificationServer+"/api/v1/send_push", strings.NewReader(msg.ToJson()))
+ l4g.Debug("Sending push notification to " + msg.DeviceId + " with msg of '" + msg.Message + "'")
if _, err := httpClient.Do(request); err != nil {
l4g.Error("Failed to send push notificationid=%v, err=%v", id, err)
}
diff --git a/api/team.go b/api/team.go
index dd9bd0bac..fbcb301a9 100644
--- a/api/team.go
+++ b/api/team.go
@@ -9,6 +9,7 @@ import (
"fmt"
"github.com/gorilla/mux"
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"net/http"
"net/url"
@@ -480,7 +481,7 @@ func inviteMembers(c *Context, w http.ResponseWriter, r *http.Request) {
var invNum int64 = 0
for i, invite := range invites.Invites {
- if result := <-Srv.Store.User().GetByEmail(c.Session.TeamId, invite["email"]); result.Err == nil || result.Err.Message != "We couldn't find the existing account" {
+ if result := <-Srv.Store.User().GetByEmail(c.Session.TeamId, invite["email"]); result.Err == nil || result.Err.Message != store.MISSING_ACCOUNT_ERROR {
invNum = int64(i)
c.Err = model.NewAppError("invite_members", "This person is already on your team", strconv.FormatInt(invNum, 10))
return
diff --git a/config/config.json b/config/config.json
index ac9f5db95..c43db1e50 100644
--- a/config/config.json
+++ b/config/config.json
@@ -58,7 +58,11 @@
"AmazonS3AccessKeyId": "",
"AmazonS3SecretAccessKey": "",
"AmazonS3Bucket": "",
- "AmazonS3Region": ""
+ "AmazonS3Region": "",
+ "AmazonS3Endpoint": "",
+ "AmazonS3BucketEndpoint": "",
+ "AmazonS3LocationConstraint": false,
+ "AmazonS3LowercaseBucket": false
},
"EmailSettings": {
"EnableSignUpWithEmail": true,
@@ -104,4 +108,4 @@
"TokenEndpoint": "",
"UserApiEndpoint": ""
}
-}
+} \ No newline at end of file
diff --git a/doc/install/Administration.md b/doc/install/Administration.md
index c51022da1..53f413d04 100644
--- a/doc/install/Administration.md
+++ b/doc/install/Administration.md
@@ -100,7 +100,7 @@ To upgrade GitLab Mattermost from the 0.7.1-beta release of Mattermost in GitLab
- Please check that each step of [the procedure for upgrading Mattermost in GitLab 8.0 to GitLab 8.1 was completed](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md#upgrading-mattermost-in-gitlab-80-to-gitlab-81-with-omnibus). Then check upgrades to successive major versions were completed using the procedure in the [Upgrade Guide](https://github.com/mattermost/platform/blob/master/doc/install/Upgrade-Guide.md#upgrading-mattermost-to-next-major-release).
-###### `We couldn't find the existing account`
+###### `We couldn't find the existing account ...`
- This error appears when a user attempts to sign in using a single-sign-on option with an account that was not created using that single-sign-on option. For example, if a user creates Account A using email sign-up, then attempts to sign-in using GitLab SSO, the error appears since Account A was not created using GitLab SSO.
- **Solution:**
- If you're switching from email auth to GitLab SSO, and you're getting this issue on an admin account, consider deactivating your email-based account, then creating a new account with System Admin privileges using GitLab SSO. Specifically:
diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md
index deae7717d..05cac2f48 100644
--- a/doc/install/Troubleshooting.md
+++ b/doc/install/Troubleshooting.md
@@ -52,7 +52,7 @@ The following is a list of common error messages and solutions:
1. Check that your SSL settings for the SSO provider match the `http://` or `https://` choice selected in `config.json` under `GitLabSettings`
2. Follow steps 1 to 3 of the manual [GitLab SSO configuration procedure](https://github.com/mattermost/platform/blob/master/doc/integrations/Single-Sign-On/Gitlab.md) to confirm your `Secret` and `Id` settings in `config.json` match your GitLab settings, and if they don't, manually update `config.json` to the correct settings and see if this clears the issue.
-###### `We couldn't find the existing account`
+###### `We couldn't find the existing account ...`
- This error appears when a user attempts to sign in using a single-sign-on option with an account that was not created using that single-sign-on option. For example, if a user creates Account A using email sign-up, then attempts to sign-in using GitLab SSO, the error appears since Account A was not created using GitLab SSO.
- **Solution:**
- If you're switching from email auth to GitLab SSO, and you're getting this issue on an admin account, consider deactivating your email-based account, then creating a new account with System Admin privileges using GitLab SSO. Specifically:
diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json
index 255961e37..1aa2ee843 100644
--- a/docker/dev/config_docker.json
+++ b/docker/dev/config_docker.json
@@ -58,7 +58,11 @@
"AmazonS3AccessKeyId": "",
"AmazonS3SecretAccessKey": "",
"AmazonS3Bucket": "",
- "AmazonS3Region": ""
+ "AmazonS3Region": "",
+ "AmazonS3Endpoint": "",
+ "AmazonS3BucketEndpoint": "",
+ "AmazonS3LocationConstraint": false,
+ "AmazonS3LowercaseBucket": false
},
"EmailSettings": {
"EnableSignUpWithEmail": true,
diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json
index 255961e37..1aa2ee843 100644
--- a/docker/local/config_docker.json
+++ b/docker/local/config_docker.json
@@ -58,7 +58,11 @@
"AmazonS3AccessKeyId": "",
"AmazonS3SecretAccessKey": "",
"AmazonS3Bucket": "",
- "AmazonS3Region": ""
+ "AmazonS3Region": "",
+ "AmazonS3Endpoint": "",
+ "AmazonS3BucketEndpoint": "",
+ "AmazonS3LocationConstraint": false,
+ "AmazonS3LowercaseBucket": false
},
"EmailSettings": {
"EnableSignUpWithEmail": true,
diff --git a/model/preference.go b/model/preference.go
index a3230959c..e3ad23ed4 100644
--- a/model/preference.go
+++ b/model/preference.go
@@ -13,6 +13,9 @@ const (
PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW = "direct_channel_show"
PREFERENCE_CATEGORY_TUTORIAL_STEPS = "tutorial_step"
PREFERENCE_CATEGORY_ADVANCED_SETTINGS = "advanced_settings"
+
+ PREFERENCE_CATEGORY_LAST = "last"
+ PREFERENCE_NAME_LAST_CHANNEL = "channel"
)
type Preference struct {
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 88c4f954b..32332ad92 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -10,6 +10,10 @@ import (
"strings"
)
+const (
+ MISSING_ACCOUNT_ERROR = "We couldn't find an existing account matching your email address for this team. This team may require an invite from the team owner to join."
+)
+
type SqlUserStore struct {
*SqlStore
}
@@ -330,7 +334,7 @@ func (us SqlUserStore) Get(id string) StoreChannel {
if obj, err := us.GetReplica().Get(model.User{}, id); err != nil {
result.Err = model.NewAppError("SqlUserStore.Get", "We encountered an error finding the account", "user_id="+id+", "+err.Error())
} else if obj == nil {
- result.Err = model.NewAppError("SqlUserStore.Get", "We couldn't find the existing account", "user_id="+id)
+ result.Err = model.NewAppError("SqlUserStore.Get", MISSING_ACCOUNT_ERROR, "user_id="+id)
} else {
result.Data = obj.(*model.User)
}
@@ -435,7 +439,7 @@ func (us SqlUserStore) GetByEmail(teamId string, email string) StoreChannel {
user := model.User{}
if err := us.GetReplica().SelectOne(&user, "SELECT * FROM Users WHERE TeamId = :TeamId AND Email = :Email", map[string]interface{}{"TeamId": teamId, "Email": email}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetByEmail", "We couldn't find the existing account", "teamId="+teamId+", email="+email+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetByEmail", MISSING_ACCOUNT_ERROR, "teamId="+teamId+", email="+email+", "+err.Error())
}
result.Data = &user
@@ -457,7 +461,7 @@ func (us SqlUserStore) GetByAuth(teamId string, authData string, authService str
user := model.User{}
if err := us.GetReplica().SelectOne(&user, "SELECT * FROM Users WHERE TeamId = :TeamId AND AuthData = :AuthData AND AuthService = :AuthService", map[string]interface{}{"TeamId": teamId, "AuthData": authData, "AuthService": authService}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetByAuth", "We couldn't find the existing account", "teamId="+teamId+", authData="+authData+", authService="+authService+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetByAuth", "We couldn't find an existing account matching your authentication type for this team. This team may require an invite from the team owner to join.", "teamId="+teamId+", authData="+authData+", authService="+authService+", "+err.Error())
}
result.Data = &user
@@ -479,7 +483,7 @@ func (us SqlUserStore) GetByUsername(teamId string, username string) StoreChanne
user := model.User{}
if err := us.GetReplica().SelectOne(&user, "SELECT * FROM Users WHERE TeamId = :TeamId AND Username = :Username", map[string]interface{}{"TeamId": teamId, "Username": username}); err != nil {
- result.Err = model.NewAppError("SqlUserStore.GetByUsername", "We couldn't find the existing account", "teamId="+teamId+", username="+username+", "+err.Error())
+ result.Err = model.NewAppError("SqlUserStore.GetByUsername", "We couldn't find an existing account matching your username for this team. This team may require an invite from the team owner to join.", "teamId="+teamId+", username="+username+", "+err.Error())
}
result.Data = &user
diff --git a/utils/mail.go b/utils/mail.go
index 07a79eeb2..6625060de 100644
--- a/utils/mail.go
+++ b/utils/mail.go
@@ -98,11 +98,12 @@ func SendMail(to, subject, body string) *model.AppError {
}
func SendMailUsingConfig(to, subject, body string, config *model.Config) *model.AppError {
-
if !config.EmailSettings.SendEmailNotifications || len(config.EmailSettings.SMTPServer) == 0 {
return nil
}
+ l4g.Debug("sending mail to " + to + " with subject of '" + subject + "'")
+
fromMail := mail.Address{config.EmailSettings.FeedbackName, config.EmailSettings.FeedbackEmail}
toMail := mail.Address{"", to}
diff --git a/web/react/components/about_build_modal.jsx b/web/react/components/about_build_modal.jsx
index f71e1c9ab..3143bec22 100644
--- a/web/react/components/about_build_modal.jsx
+++ b/web/react/components/about_build_modal.jsx
@@ -37,10 +37,6 @@ export default class AboutBuildModal extends React.Component {
<div className='col-sm-3 info__label'>{'Build Hash:'}</div>
<div className='col-sm-9'>{config.BuildHash}</div>
</div>
- <div className='row'>
- <div className='col-sm-3 info__label'>{'Enterprise Ready:'}</div>
- <div className='col-sm-9'>{config.BuildEnterpriseReady}</div>
- </div>
</Modal.Body>
<Modal.Footer>
<button
diff --git a/web/react/components/audio_video_preview.jsx b/web/react/components/audio_video_preview.jsx
new file mode 100644
index 000000000..7d00fbdaa
--- /dev/null
+++ b/web/react/components/audio_video_preview.jsx
@@ -0,0 +1,114 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Constants from '../utils/constants.jsx';
+import FileInfoPreview from './file_info_preview.jsx';
+import * as Utils from '../utils/utils.jsx';
+
+export default class AudioVideoPreview extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleFileInfoChanged = this.handleFileInfoChanged.bind(this);
+ this.handleLoadError = this.handleLoadError.bind(this);
+
+ this.stop = this.stop.bind(this);
+
+ this.state = {
+ canPlay: true
+ };
+ }
+
+ componentWillMount() {
+ this.handleFileInfoChanged(this.props.fileInfo);
+ }
+
+ componentDidMount() {
+ if (this.refs.source) {
+ $(ReactDOM.findDOMNode(this.refs.source)).one('error', this.handleLoadError);
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.props.fileUrl !== nextProps.fileUrl) {
+ this.handleFileInfoChanged(nextProps.fileInfo);
+ }
+ }
+
+ handleFileInfoChanged(fileInfo) {
+ let video = ReactDOM.findDOMNode(this.refs.video);
+ if (!video) {
+ video = document.createElement('video');
+ }
+
+ const canPlayType = video.canPlayType(fileInfo.mime_type);
+
+ this.setState({
+ canPlay: canPlayType === 'probably' || canPlayType === 'maybe'
+ });
+ }
+
+ componentDidUpdate() {
+ if (this.refs.source) {
+ $(ReactDOM.findDOMNode(this.refs.source)).one('error', this.handleLoadError);
+ }
+ }
+
+ handleLoadError() {
+ this.setState({
+ canPlay: false
+ });
+ }
+
+ stop() {
+ if (this.refs.video) {
+ const video = ReactDOM.findDOMNode(this.refs.video);
+ video.pause();
+ video.currentTime = 0;
+ }
+ }
+
+ render() {
+ if (!this.state.canPlay) {
+ return (
+ <FileInfoPreview
+ filename={this.props.filename}
+ fileUrl={this.props.fileUrl}
+ fileInfo={this.props.fileInfo}
+ />
+ );
+ }
+
+ let width = Constants.WEB_VIDEO_WIDTH;
+ let height = Constants.WEB_VIDEO_HEIGHT;
+ if (Utils.isMobile()) {
+ width = Constants.MOBILE_VIDEO_WIDTH;
+ height = Constants.MOBILE_VIDEO_HEIGHT;
+ }
+
+ // add a key to the video to prevent React from using an old video source while a new one is loading
+ return (
+ <video
+ key={this.props.filename}
+ ref='video'
+ style={{maxHeight: this.props.maxHeight}}
+ data-setup='{}'
+ controls='controls'
+ width={width}
+ height={height}
+ >
+ <source
+ ref='source'
+ src={this.props.fileUrl}
+ />
+ </video>
+ );
+ }
+}
+
+AudioVideoPreview.propTypes = {
+ filename: React.PropTypes.string.isRequired,
+ fileUrl: React.PropTypes.string.isRequired,
+ fileInfo: React.PropTypes.object.isRequired,
+ maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired
+};
diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx
index 2474b3d8a..c10269680 100644
--- a/web/react/components/file_attachment.jsx
+++ b/web/react/components/file_attachment.jsx
@@ -125,10 +125,6 @@ export default class FileAttachment extends React.Component {
getFileInfoFromName(name) {
var fileInfo = utils.splitFileLocation(name);
- // This is a temporary patch to fix issue with old files using absolute paths
- if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) {
- fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1];
- }
fileInfo.path = utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path;
return fileInfo;
diff --git a/web/react/components/file_info_preview.jsx b/web/react/components/file_info_preview.jsx
new file mode 100644
index 000000000..4b76cd162
--- /dev/null
+++ b/web/react/components/file_info_preview.jsx
@@ -0,0 +1,31 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import * as Utils from '../utils/utils.jsx';
+
+export default function FileInfoPreview({filename, fileUrl, fileInfo}) {
+ // non-image files include a section providing details about the file
+ let infoString = 'File type ' + fileInfo.extension.toUpperCase();
+ if (fileInfo.size > 0) {
+ infoString += ', Size ' + Utils.fileSizeToString(fileInfo.size);
+ }
+
+ const name = decodeURIComponent(Utils.getFileName(filename));
+
+ return (
+ <div className='file-details__container'>
+ <a
+ className={'file-details__preview'}
+ href={fileUrl}
+ target='_blank'
+ >
+ <span className='file-details__preview-helper' />
+ <img src={Utils.getPreviewImagePath(filename)}/>
+ </a>
+ <div className='file-details'>
+ <div className='file-details__name'>{name}</div>
+ <div className='file-details__info'>{infoString}</div>
+ </div>
+ </div>
+ );
+}
diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx
index d625a811e..265d3f367 100644
--- a/web/react/components/file_preview.jsx
+++ b/web/react/components/file_preview.jsx
@@ -35,12 +35,7 @@ export default class FilePreview extends React.Component {
var ext = filenameSplit[filenameSplit.length - 1];
var type = Utils.getFileType(ext);
- // This is a temporary patch to fix issue with old files using absolute paths
-
- if (filename.indexOf('/api/v1/files/get') !== -1) {
- filename = filename.split('/api/v1/files/get')[1];
- }
- filename = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename + '?' + Utils.getSessionIndex();
+ filename = Utils.getFileUrl(filename);
if (type === 'image') {
previews.push(
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index dcbe56399..b1657f0eb 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -10,6 +10,7 @@ const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES;
import * as TextFormatting from '../utils/text_formatting.jsx';
import twemoji from 'twemoji';
import PostBodyAdditionalContent from './post_body_additional_content.jsx';
+import YoutubeVideo from './youtube_video.jsx';
import providers from './providers.json';
@@ -17,7 +18,6 @@ export default class PostBody extends React.Component {
constructor(props) {
super(props);
- this.receivedYoutubeData = false;
this.isImgLoading = false;
this.handleUserChange = this.handleUserChange.bind(this);
@@ -25,7 +25,6 @@ export default class PostBody extends React.Component {
this.createEmbed = this.createEmbed.bind(this);
this.createImageEmbed = this.createImageEmbed.bind(this);
this.loadImg = this.loadImg.bind(this);
- this.createYoutubeEmbed = this.createYoutubeEmbed.bind(this);
const linkData = Utils.extractLinks(this.props.post.message);
const profiles = UserStore.getProfiles();
@@ -120,10 +119,13 @@ export default class PostBody extends React.Component {
}
}
- const embed = this.createYoutubeEmbed(link);
-
- if (embed != null) {
- return embed;
+ if (YoutubeVideo.isYoutubeLink(link)) {
+ return (
+ <YoutubeVideo
+ channelId={post.channel_id}
+ link={link}
+ />
+ );
}
for (let i = 0; i < Constants.IMAGE_TYPES.length; i++) {
@@ -184,117 +186,6 @@ export default class PostBody extends React.Component {
);
}
- handleYoutubeTime(link) {
- const timeRegex = /[\\?&]t=([0-9hms]+)/;
-
- const time = link.match(timeRegex);
- if (!time || !time[1]) {
- return '';
- }
-
- const hours = time[1].match(/([0-9]+)h/);
- const minutes = time[1].match(/([0-9]+)m/);
- const seconds = time[1].match(/([0-9]+)s/);
-
- let ticks = 0;
-
- if (hours && hours[1]) {
- ticks += parseInt(hours[1], 10) * 3600;
- }
-
- if (minutes && minutes[1]) {
- ticks += parseInt(minutes[1], 10) * 60;
- }
-
- if (seconds && seconds[1]) {
- ticks += parseInt(seconds[1], 10);
- }
-
- return '&start=' + ticks.toString();
- }
-
- createYoutubeEmbed(link) {
- const ytRegex = /(?:http|https):\/\/(?:www\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(\/u\/\w\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^\/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#\&\?]*)/;
-
- const match = link.trim().match(ytRegex);
- if (!match || match[2].length !== 11) {
- return null;
- }
-
- const youtubeId = match[2];
- const time = this.handleYoutubeTime(link);
-
- function onClick(e) {
- var div = $(e.target).closest('.video-thumbnail__container')[0];
- var iframe = document.createElement('iframe');
- iframe.setAttribute('src',
- 'https://www.youtube.com/embed/' +
- div.id +
- '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' +
- time);
- iframe.setAttribute('width', '480px');
- iframe.setAttribute('height', '360px');
- iframe.setAttribute('type', 'text/html');
- iframe.setAttribute('frameborder', '0');
- iframe.setAttribute('allowfullscreen', 'allowfullscreen');
-
- div.parentNode.replaceChild(iframe, div);
- }
-
- function success(data) {
- if (!data.items.length || !data.items[0].snippet) {
- return null;
- }
- var metadata = data.items[0].snippet;
- this.receivedYoutubeData = true;
- this.setState({youtubeTitle: metadata.title});
- }
-
- if (global.window.mm_config.GoogleDeveloperKey && !this.receivedYoutubeData) {
- $.ajax({
- async: true,
- url: 'https://www.googleapis.com/youtube/v3/videos',
- type: 'GET',
- data: {part: 'snippet', id: youtubeId, key: global.window.mm_config.GoogleDeveloperKey},
- success: success.bind(this)
- });
- }
-
- let header = 'Youtube';
- if (this.state.youtubeTitle) {
- header = header + ' - ';
- }
-
- return (
- <div>
- <h4>
- <span className='video-type'>{header}</span>
- <span className='video-title'><a href={link}>{this.state.youtubeTitle}</a></span>
- </h4>
- <div
- className='video-div embed-responsive-item'
- id={youtubeId}
- onClick={onClick}
- >
- <div className='embed-responsive embed-responsive-4by3 video-div__placeholder'>
- <div
- id={youtubeId}
- className='video-thumbnail__container'
- >
- <img
- className='video-thumbnail'
- src={'https://i.ytimg.com/vi/' + youtubeId + '/hqdefault.jpg'}
- />
- <div className='block'>
- <span className='play-button'><span/></span>
- </div>
- </div>
- </div>
- </div>
- </div>
- );
- }
-
render() {
const post = this.props.post;
const filenames = this.props.post.filenames;
diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx
index 196a44bd0..31ec91248 100644
--- a/web/react/components/view_image.jsx
+++ b/web/react/components/view_image.jsx
@@ -4,7 +4,9 @@
import * as AsyncClient from '../utils/async_client.jsx';
import * as Client from '../utils/client.jsx';
import * as Utils from '../utils/utils.jsx';
+import AudioVideoPreview from './audio_video_preview.jsx';
import Constants from '../utils/constants.jsx';
+import FileInfoPreview from './file_info_preview.jsx';
import FileStore from '../stores/file_store.jsx';
import ViewImagePopoverBar from './view_image_popover_bar.jsx';
const Modal = ReactBootstrap.Modal;
@@ -27,7 +29,6 @@ export default class ViewImageModal extends React.Component {
this.onFileStoreChange = this.onFileStoreChange.bind(this);
this.getPublicLink = this.getPublicLink.bind(this);
- this.getPreviewImagePath = this.getPreviewImagePath.bind(this);
this.onMouseEnterImage = this.onMouseEnterImage.bind(this);
this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this);
@@ -83,9 +84,7 @@ export default class ViewImageModal extends React.Component {
$(window).off('keyup', this.handleKeyPress);
if (this.refs.video) {
- var video = ReactDOM.findDOMNode(this.refs.video);
- video.pause();
- video.currentTime = 0;
+ this.refs.video.stop();
}
FileStore.removeChangeListener(this.onFileStoreChange);
@@ -152,7 +151,7 @@ export default class ViewImageModal extends React.Component {
if (fileType === 'image') {
let previewUrl;
if (fileInfo.has_image_preview) {
- previewUrl = fileInfo.getPreviewImagePath(filename);
+ previewUrl = Utils.getPreviewImagePath(filename);
} else {
// some images (eg animated gifs) just show the file itself and not a preview
previewUrl = Utils.getFileUrl(filename);
@@ -198,25 +197,6 @@ export default class ViewImageModal extends React.Component {
);
}
- getPreviewImagePath(filename) {
- // Returns the path to a preview image that can be used to represent a file.
- var fileInfo = Utils.splitFileLocation(filename);
- var fileType = Utils.getFileType(fileInfo.ext);
-
- if (fileType === 'image') {
- // This is a temporary patch to fix issue with old files using absolute paths
- if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) {
- fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1];
- }
- fileInfo.path = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path;
-
- return fileInfo.path + '_preview.jpg?' + Utils.getSessionIndex();
- }
-
- // only images have proper previews, so just use a placeholder icon for non-images
- return Utils.getPreviewImagePathForFileType(fileType);
- }
-
onMouseEnterImage() {
this.setState({showFooter: true});
}
@@ -237,72 +217,33 @@ export default class ViewImageModal extends React.Component {
if (this.state.loaded[this.state.imgId]) {
// this.state.fileInfo is for the current image and we shoudl have it before we load the image
const fileInfo = this.state.fileInfo;
-
- const extension = Utils.splitFileLocation(filename).ext;
- const fileType = Utils.getFileType(extension);
+ const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
- let previewUrl;
- if (fileInfo.has_preview_image) {
- previewUrl = this.getPreviewImagePath(filename);
- } else {
- previewUrl = fileUrl;
- }
-
content = (
<ImagePreview
+ filename={filename}
fileUrl={fileUrl}
- previewUrl={previewUrl}
+ fileInfo={fileInfo}
maxHeight={this.state.imgHeight}
/>
);
} else if (fileType === 'video' || fileType === 'audio') {
- let width = Constants.WEB_VIDEO_WIDTH;
- let height = Constants.WEB_VIDEO_HEIGHT;
- if (Utils.isMobile()) {
- width = Constants.MOBILE_VIDEO_WIDTH;
- height = Constants.MOBILE_VIDEO_HEIGHT;
- }
-
content = (
- <video
- style={{maxHeight: this.state.imgHeight}}
- ref='video'
- data-setup='{}'
- controls='controls'
- width={width}
- height={height}
- >
- <source src={Utils.getWindowLocationOrigin() + '/api/v1/files/get' + filename + '?' + Utils.getSessionIndex()} />
- </video>
+ <AudioVideoPreview
+ filename={filename}
+ fileUrl={fileUrl}
+ fileInfo={this.state.fileInfo}
+ maxHeight={this.state.imgHeight}
+ />
);
} else {
- // non-image files include a section providing details about the file
- let infoString = 'File type ' + fileInfo.extension.toUpperCase();
- if (fileInfo.size > 0) {
- infoString += ', Size ' + Utils.fileSizeToString(fileInfo.size);
- }
-
- const name = decodeURIComponent(Utils.getFileName(filename));
-
content = (
- <div className='file-details__container'>
- <a
- className={'file-details__preview'}
- href={fileUrl}
- target='_blank'
- >
- <span className='file-details__preview-helper' />
- <img
- ref='image'
- src={this.getPreviewImagePath(filename)}
- />
- </a>
- <div className='file-details'>
- <div className='file-details__name'>{name}</div>
- <div className='file-details__info'>{infoString}</div>
- </div>
- </div>
+ <FileInfoPreview
+ filename={filename}
+ fileUrl={fileUrl}
+ fileInfo={fileInfo}
+ />
);
}
} else {
@@ -424,7 +365,14 @@ function LoadingImagePreview({progress}) {
);
}
-function ImagePreview({maxHeight, fileUrl, previewUrl}) {
+function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
+ let previewUrl;
+ if (fileInfo.has_preview_image) {
+ previewUrl = Utils.getPreviewImagePath(filename);
+ } else {
+ previewUrl = fileUrl;
+ }
+
return (
<a
href={fileUrl}
diff --git a/web/react/components/youtube_video.jsx b/web/react/components/youtube_video.jsx
new file mode 100644
index 000000000..bf3c43840
--- /dev/null
+++ b/web/react/components/youtube_video.jsx
@@ -0,0 +1,175 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import ChannelStore from '../stores/channel_store.jsx';
+
+const ytRegex = /(?:http|https):\/\/(?:www\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(\/u\/\w\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^\/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#\&\?]*)/;
+
+export default class YoutubeVideo extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.updateStateFromProps = this.updateStateFromProps.bind(this);
+ this.handleReceivedMetadata = this.handleReceivedMetadata.bind(this);
+
+ this.play = this.play.bind(this);
+ this.stop = this.stop.bind(this);
+ this.stopOnChannelChange = this.stopOnChannelChange.bind(this);
+
+ this.state = {
+ playing: false,
+ title: ''
+ };
+ }
+
+ componentWillMount() {
+ this.updateStateFromProps(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.updateStateFromProps(nextProps);
+ }
+
+ updateStateFromProps(props) {
+ const link = props.link;
+
+ const match = link.trim().match(ytRegex);
+ if (!match || match[2].length !== 11) {
+ return;
+ }
+
+ this.setState({
+ videoId: match[2],
+ time: this.handleYoutubeTime(link)
+ });
+ }
+
+ handleYoutubeTime(link) {
+ const timeRegex = /[\\?&]t=([0-9hms]+)/;
+
+ const time = link.match(timeRegex);
+ if (!time || !time[1]) {
+ return '';
+ }
+
+ const hours = time[1].match(/([0-9]+)h/);
+ const minutes = time[1].match(/([0-9]+)m/);
+ const seconds = time[1].match(/([0-9]+)s/);
+
+ let ticks = 0;
+
+ if (hours && hours[1]) {
+ ticks += parseInt(hours[1], 10) * 3600;
+ }
+
+ if (minutes && minutes[1]) {
+ ticks += parseInt(minutes[1], 10) * 60;
+ }
+
+ if (seconds && seconds[1]) {
+ ticks += parseInt(seconds[1], 10);
+ }
+
+ return '&start=' + ticks.toString();
+ }
+
+ componentDidMount() {
+ if (global.window.mm_config.GoogleDeveloperKey) {
+ $.ajax({
+ async: true,
+ url: 'https://www.googleapis.com/youtube/v3/videos',
+ type: 'GET',
+ data: {part: 'snippet', id: this.state.videoId, key: global.window.mm_config.GoogleDeveloperKey},
+ success: this.handleReceivedMetadata
+ });
+ }
+ }
+
+ handleReceivedMetadata(data) {
+ if (!data.items.length || !data.items[0].snippet) {
+ return null;
+ }
+ var metadata = data.items[0].snippet;
+ this.setState({
+ receivedYoutubeData: true,
+ title: metadata.title
+ });
+ }
+
+ play() {
+ this.setState({playing: true});
+
+ if (ChannelStore.getCurrentId() === this.props.channelId) {
+ ChannelStore.addChangeListener(this.stopOnChannelChange);
+ }
+ }
+
+ stop() {
+ this.setState({playing: false});
+ }
+
+ stopOnChannelChange() {
+ if (ChannelStore.getCurrentId() !== this.props.channelId) {
+ this.stop();
+ }
+ }
+
+ render() {
+ let header = 'Youtube';
+ if (this.state.title) {
+ header = header + ' - ';
+ }
+
+ let content;
+ if (this.state.playing) {
+ content = (
+ <iframe
+ src={'https://www.youtube.com/embed/' + this.state.videoId + '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' + this.state.time}
+ width='480px'
+ height='360px'
+ type='text/html'
+ frameBorder='0'
+ allowFullScreen='allowfullscreen'
+ />
+ );
+ } else {
+ content = (
+ <div className='embed-responsive embed-responsive-4by3 video-div__placeholder'>
+ <div className='video-thumbnail__container'>
+ <img
+ className='video-thumbnail'
+ src={'https://i.ytimg.com/vi/' + this.state.videoId + '/hqdefault.jpg'}
+ />
+ <div className='block'>
+ <span className='play-button'><span/></span>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div>
+ <h4>
+ <span className='video-type'>{header}</span>
+ <span className='video-title'><a href={this.props.link}>{this.state.title}</a></span>
+ </h4>
+ <div
+ className='video-div embed-responsive-item'
+ onClick={this.play}
+ >
+ {content}
+ </div>
+ </div>
+ );
+ }
+
+ static isYoutubeLink(link) {
+ return link.trim().match(ytRegex);
+ }
+}
+
+YoutubeVideo.propTypes = {
+ channelId: React.PropTypes.string.isRequired,
+ link: React.PropTypes.string.isRequired
+};
diff --git a/web/react/stores/channel_store.jsx b/web/react/stores/channel_store.jsx
index afc960fcf..93d996e0b 100644
--- a/web/react/stores/channel_store.jsx
+++ b/web/react/stores/channel_store.jsx
@@ -18,7 +18,7 @@ class ChannelStoreClass extends EventEmitter {
constructor(props) {
super(props);
- this.setMaxListeners(11);
+ this.setMaxListeners(15);
this.emitChange = this.emitChange.bind(this);
this.addChangeListener = this.addChangeListener.bind(this);
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index a808c9be3..33aae7d1e 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -15,9 +15,9 @@ import * as client from './client.jsx';
import Autolinker from 'autolinker';
export function isEmail(email) {
- //var regex = /^([a-zA-Z0-9_.+-])+\@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,4})+$/;
- var regex = /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i;
- return regex.test(email);
+ // writing a regex to match all valid email addresses is really, really hard (see http://stackoverflow.com/a/201378)
+ // so we just do a simple check and rely on a verification email to tell if it's a real address
+ return email.indexOf('@') !== -1;
}
export function cleanUpUrlable(input) {
@@ -527,6 +527,19 @@ export function splitFileLocation(fileLocation) {
return {ext: ext, name: filename, path: filePath};
}
+export function getPreviewImagePath(filename) {
+ // Returns the path to a preview image that can be used to represent a file.
+ const fileInfo = splitFileLocation(filename);
+ const fileType = getFileType(fileInfo.ext);
+
+ if (fileType === 'image') {
+ return getFileUrl(fileInfo.path + '_preview.jpg');
+ }
+
+ // only images have proper previews, so just use a placeholder icon for non-images
+ return getPreviewImagePathForFileType(fileType);
+}
+
export function toTitleCase(str) {
function doTitleCase(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
@@ -1050,15 +1063,7 @@ export function fileSizeToString(bytes) {
// Converts a filename (like those attached to Post objects) to a url that can be used to retrieve attachments from the server.
export function getFileUrl(filename) {
- var url = filename;
-
- // This is a temporary patch to fix issue with old files using absolute paths
- if (url.indexOf('/api/v1/files/get') !== -1) {
- url = filename.split('/api/v1/files/get')[1];
- }
- url = getWindowLocationOrigin() + '/api/v1/files/get' + url + '?' + getSessionIndex();
-
- return url;
+ return getWindowLocationOrigin() + '/api/v1/files/get' + filename + '?' + getSessionIndex();
}
// Gets the name of a file (including extension) from a given url or file path.
diff --git a/web/web.go b/web/web.go
index 7e8234138..bf1208adc 100644
--- a/web/web.go
+++ b/web/web.go
@@ -238,7 +238,14 @@ func login(c *api.Context, w http.ResponseWriter, r *http.Request) {
_, session := api.FindMultiSessionForTeamId(r, team.Id)
if session != nil {
w.Header().Set(model.HEADER_TOKEN, session.Token)
- http.Redirect(w, r, c.GetSiteURL()+"/"+team.Name+"/channels/town-square", http.StatusTemporaryRedirect)
+ lastViewChannelName := "town-square"
+ if lastViewResult := <-api.Srv.Store.Preference().Get(session.UserId, model.PREFERENCE_CATEGORY_LAST, model.PREFERENCE_NAME_LAST_CHANNEL); lastViewResult.Err == nil {
+ if lastViewChannelResult := <-api.Srv.Store.Channel().Get(lastViewResult.Data.(model.Preference).Value); lastViewChannelResult.Err == nil {
+ lastViewChannelName = lastViewChannelResult.Data.(*model.Channel).Name
+ }
+ }
+
+ http.Redirect(w, r, c.GetSiteURL()+"/"+team.Name+"/channels/"+lastViewChannelName, http.StatusTemporaryRedirect)
return
}
@@ -1017,7 +1024,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var parsedRequest *model.IncomingWebhookRequest
- if r.Header.Get("Content-Type") == "application/json" {
+ contentType := r.Header.Get("Content-Type")
+ if strings.Split(contentType, "; ")[0] == "application/json" {
parsedRequest = model.IncomingWebhookRequestFromJson(r.Body)
} else {
parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload")))