diff options
-rw-r--r-- | api/channel.go | 9 | ||||
-rw-r--r-- | api/post.go | 1 | ||||
-rw-r--r-- | api/team.go | 3 | ||||
-rw-r--r-- | config/config.json | 8 | ||||
-rw-r--r-- | doc/install/Administration.md | 2 | ||||
-rw-r--r-- | doc/install/Troubleshooting.md | 2 | ||||
-rw-r--r-- | docker/dev/config_docker.json | 6 | ||||
-rw-r--r-- | docker/local/config_docker.json | 6 | ||||
-rw-r--r-- | model/preference.go | 3 | ||||
-rw-r--r-- | store/sql_user_store.go | 12 | ||||
-rw-r--r-- | utils/mail.go | 3 | ||||
-rw-r--r-- | web/react/components/about_build_modal.jsx | 4 | ||||
-rw-r--r-- | web/react/components/audio_video_preview.jsx | 114 | ||||
-rw-r--r-- | web/react/components/file_attachment.jsx | 4 | ||||
-rw-r--r-- | web/react/components/file_info_preview.jsx | 31 | ||||
-rw-r--r-- | web/react/components/file_preview.jsx | 7 | ||||
-rw-r--r-- | web/react/components/post_body.jsx | 125 | ||||
-rw-r--r-- | web/react/components/view_image.jsx | 104 | ||||
-rw-r--r-- | web/react/components/youtube_video.jsx | 175 | ||||
-rw-r--r-- | web/react/stores/channel_store.jsx | 2 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 29 | ||||
-rw-r--r-- | web/web.go | 12 |
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"))) |