diff options
Diffstat (limited to 'app')
34 files changed, 593 insertions, 314 deletions
diff --git a/app/assets/images/mastodon.jpg b/app/assets/images/mastodon.jpg new file mode 100644 index 000000000..f22a252a6 --- /dev/null +++ b/app/assets/images/mastodon.jpg Binary files differdiff --git a/app/assets/images/mastodon_small.jpg b/app/assets/images/mastodon_small.jpg new file mode 100644 index 000000000..cb8cdc992 --- /dev/null +++ b/app/assets/images/mastodon_small.jpg Binary files differdiff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js index 31a96fd2d..f131a267a 100644 --- a/app/assets/javascripts/application_public.js +++ b/app/assets/javascripts/application_public.js @@ -1,2 +1,3 @@ //= require jquery //= require jquery_ujs +//= require extras diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index ec5465381..a9fbe6b91 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -185,13 +185,14 @@ export function readyComposeSuggestions(token, accounts) { }; }; -export function selectComposeSuggestion(position, accountId) { +export function selectComposeSuggestion(position, token, accountId) { return (dispatch, getState) => { const completion = getState().getIn(['accounts', accountId, 'acct']); dispatch({ type: COMPOSE_SUGGESTION_SELECT, position, + token, completion }); }; diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx new file mode 100644 index 000000000..95ca5f2f6 --- /dev/null +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -0,0 +1,155 @@ +import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/\S+$/); + let right = str.slice(caretPosition).search(/\s/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 2 || word[0] !== '@') { + return [null, null]; + } + + word = word.trim().toLowerCase().slice(1); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +const AutosuggestTextarea = React.createClass({ + + propTypes: { + value: React.PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: React.PropTypes.bool, + placeholder: React.PropTypes.string, + onSuggestionSelected: React.PropTypes.func.isRequired, + onSuggestionsClearRequested: React.PropTypes.func.isRequired, + onSuggestionsFetchRequested: React.PropTypes.func.isRequired, + onChange: React.PropTypes.func.isRequired, + onKeyUp: React.PropTypes.func + }, + + getInitialState () { + return { + suggestionsHidden: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0 + }; + }, + + onChange (e) { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token != null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null && this.state.lastToken != null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + }, + + onKeyDown (e) { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + switch(e.key) { + case 'Escape': + if (!suggestionsHidden) { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken != null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + }, + + onSuggestionClick (suggestion, e) { + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + }, + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { + this.setState({ suggestionsHidden: false }); + } + }, + + setTextarea (c) { + this.textarea = c; + }, + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp } = this.props; + const { suggestionsHidden, selectedSuggestion } = this.state; + + return ( + <div className='autosuggest-textarea'> + <textarea + ref={this.setTextarea} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + /> + + <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'> + {suggestions.map((suggestion, i) => ( + <div key={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onClick={this.onSuggestionClick.bind(this, suggestion)}> + <AutosuggestAccountContainer id={suggestion} /> + </div> + ))} + </div> + </div> + ); + } + +}); + +export default AutosuggestTextarea; diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 00589b3c8..200502dad 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -4,7 +4,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ReplyIndicator from './reply_indicator'; import UploadButton from './upload_button'; -import Autosuggest from 'react-autosuggest'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; import { debounce } from 'react-decoration'; import UploadButtonContainer from '../containers/upload_button_container'; @@ -16,59 +16,12 @@ const messages = defineMessages({ publish: { id: 'compose_form.publish', defaultMessage: 'Publish' } }); -const getTokenForSuggestions = (str, caretPosition) => { - let word; - - let left = str.slice(0, caretPosition).search(/\S+$/); - let right = str.slice(caretPosition).search(/\s/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 2 || word[0] !== '@') { - return null; - } - - word = word.trim().toLowerCase().slice(1); - - if (word.length > 0) { - return word; - } else { - return null; - } -}; - -const getSuggestionValue = suggestionId => suggestionId; -const renderSuggestion = suggestionId => <AutosuggestAccountContainer id={suggestionId} />; - -const textareaStyle = { - display: 'block', - boxSizing: 'border-box', - width: '100%', - height: '100px', - resize: 'none', - border: 'none', - color: '#282c37', - padding: '10px', - fontFamily: 'Roboto', - fontSize: '14px', - margin: '0', - resize: 'vertical' -}; - -const renderInputComponent = inputProps => ( - <textarea {...inputProps} className='compose-form__textarea' style={textareaStyle} /> -); - const ComposeForm = React.createClass({ propTypes: { text: React.PropTypes.string.isRequired, suggestion_token: React.PropTypes.string, - suggestions: React.PropTypes.array, + suggestions: ImmutablePropTypes.list, sensitive: React.PropTypes.bool, unlisted: React.PropTypes.bool, is_submitting: React.PropTypes.bool, @@ -87,10 +40,6 @@ const ComposeForm = React.createClass({ mixins: [PureRenderMixin], handleChange (e) { - if (typeof e.target.value === 'undefined' || typeof e.target.value === 'number') { - return; - } - this.props.onChange(e.target.value); }, @@ -104,45 +53,17 @@ const ComposeForm = React.createClass({ this.props.onSubmit(); }, - componentDidUpdate (prevProps) { - if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) { - const textarea = this.autosuggest.input; - - if (textarea) { - textarea.focus(); - } - } - }, - onSuggestionsClearRequested () { this.props.onClearSuggestions(); }, @debounce(500) - onSuggestionsFetchRequested ({ value }) { - const textarea = this.autosuggest.input; - - if (textarea) { - const token = getTokenForSuggestions(value, textarea.selectionStart); - - if (token !== null) { - this.props.onFetchSuggestions(token); - } else { - this.props.onClearSuggestions(); - } - } - }, - - onSuggestionSelected (e, { suggestionValue }) { - const textarea = this.autosuggest.input; - - if (textarea) { - this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue); - } + onSuggestionsFetchRequested (token) { + this.props.onFetchSuggestions(token); }, - setRef (c) { - this.autosuggest = c; + onSuggestionSelected (tokenStart, token, value) { + this.props.onSuggestionSelected(tokenStart, token, value); }, handleChangeSensitivity (e) { @@ -153,6 +74,16 @@ const ComposeForm = React.createClass({ this.props.onChangeVisibility(e.target.checked); }, + componentDidUpdate (prevProps) { + if (prevProps.in_reply_to !== this.props.in_reply_to) { + this.autosuggestTextarea.textarea.focus(); + } + }, + + setAutosuggestTextarea (c) { + this.autosuggestTextarea = c; + }, + render () { const { intl } = this.props; let replyArea = ''; @@ -162,29 +93,21 @@ const ComposeForm = React.createClass({ replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; } - const inputProps = { - placeholder: intl.formatMessage(messages.placeholder), - value: this.props.text, - onKeyUp: this.handleKeyUp, - onChange: this.handleChange, - disabled: disabled - }; - return ( <div style={{ padding: '10px' }}> {replyArea} - <Autosuggest - ref={this.setRef} + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} suggestions={this.props.suggestions} - focusFirstSuggestion={true} + onKeyUp={this.handleKeyUp} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} - getSuggestionValue={getSuggestionValue} - renderSuggestion={renderSuggestion} - renderInputComponent={renderInputComponent} - inputProps={inputProps} /> <div style={{ marginTop: '10px', overflow: 'hidden' }}> diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 8aa719476..c774b2687 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -19,7 +19,7 @@ const makeMapStateToProps = () => { return { text: state.getIn(['compose', 'text']), suggestion_token: state.getIn(['compose', 'suggestion_token']), - suggestions: state.getIn(['compose', 'suggestions']).toJS(), + suggestions: state.getIn(['compose', 'suggestions']), sensitive: state.getIn(['compose', 'sensitive']), unlisted: state.getIn(['compose', 'unlisted']), is_submitting: state.getIn(['compose', 'is_submitting']), @@ -53,8 +53,8 @@ const mapDispatchToProps = function (dispatch) { dispatch(fetchComposeSuggestions(token)); }, - onSuggestionSelected (position, accountId) { - dispatch(selectComposeSuggestion(position, accountId)); + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); }, onChangeSensitivity (checked) { diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 9d1d53083..4bb76dff0 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -75,11 +75,9 @@ function removeMedia(state, mediaId) { }); }; -const insertSuggestion = (state, position, completion) => { - const token = state.get('suggestion_token'); - +const insertSuggestion = (state, position, token, completion) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position - token.length)}${completion}${oldText.slice(position + token.length)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', Immutable.List(), list => list.clear()); }); @@ -130,7 +128,7 @@ export default function compose(state = initialState, action) { case COMPOSE_SUGGESTIONS_READY: return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: - return insertSuggestion(state, action.position, action.completion); + return insertSuggestion(state, action.position, action.token, action.completion); case TIMELINE_DELETE: if (action.id === state.get('in_reply_to')) { return state.set('in_reply_to', null); diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx new file mode 100644 index 000000000..9fd769c0b --- /dev/null +++ b/app/assets/javascripts/extras.jsx @@ -0,0 +1,20 @@ +import emojify from './components/emoji' + +$(() => { + $.each($('.entry .content, .entry .status__content, .display-name, .name, .account__header__content'), (_, content) => { + const $content = $(content); + $content.html(emojify($content.html())); + }); + + $('.video-player video').on('click', e => { + if (e.target.paused) { + e.target.play(); + } else { + e.target.pause(); + } + }); + + $('.media-spoiler').on('click', e => { + $(e.target).hide(); + }); +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 609b30726..6dd89c0ea 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -114,6 +114,18 @@ body { padding: 0; } + &.embed { + background: transparent; + margin: 0; + + .container { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + } + } + @media screen and (max-width: 360px) { padding-bottom: 0; } diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 517fcd3f1..210e722cc 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -530,3 +530,43 @@ background: lighten(#373b4a, 5%); } } + +.autosuggest-textarea { + position: relative; +} + +.autosuggest-textarea__textarea { + display: block; + box-sizing: border-box; + width: 100%; + height: 100px; + resize: none; + border: none; + color: #282c37; + padding: 10px; + font-family: 'Roboto'; + font-size: 14px; + margin: 0; + resize: vertical; +} + +.autosuggest-textarea__suggestions { + position: absolute; + top: 100%; + width: 100%; + z-index: 99; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + background: #d9e1e8; + color: #282c37; + font-size: 14px; +} + +.autosuggest-textarea__suggestions__item { + padding: 10px; + cursor: pointer; + + &.selected { + background: #2b90d9; + color: #fff; + } +} diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss index 1e29ee718..5cd140aac 100644 --- a/app/assets/stylesheets/stream_entries.scss +++ b/app/assets/stylesheets/stream_entries.scss @@ -3,232 +3,302 @@ box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); .entry { - border-bottom: 1px solid #d9e1e8; - background: #fff; - border-left: 2px solid #fff; + .status.light, .detailed-status.light { + border-bottom: 1px solid #d9e1e8; + } - &.entry-reblog { - border-left-color: #2b90d9; + &:last-child { + .status.light, .detailed-status.light { + border-bottom: 0; + border-radius: 0 0 4px 4px; + } } - &.entry-predecessor, &.entry-successor { - background: #d9e1e8; - border-left-color: #d9e1e8; - border-bottom-color: darken(#d9e1e8, 10%); + &:first-child { + .status.light, .detailed-status.light { + border-radius: 4px 4px 0 0; + } - .header { - .header__right { - .counter-btn { - color: darken(#d9e1e8, 15%); - } + &:last-child { + .status.light, .detailed-status.light { + border-radius: 4px; } } } + } - &.entry-center { - border-bottom-color: darken(#d9e1e8, 10%); - } + .status.light { + padding: 14px 14px 14px (48px + 14px*2); + position: relative; + min-height: 48px; + cursor: default; + background: lighten(#d9e1e8, 8%); - &.entry-follow, &.entry-favourite { - .content { - padding-top: 10px; - padding-bottom: 10px; + .status__header { + font-size: 15px; - strong { - font-weight: 500; + .status__meta { + float: right; + font-size: 14px; + + .status__relative-time { + color: #9baec8; } } } - &:last-child { - border-bottom: 0; - border-radius: 0 0 4px 4px; + .status__display-name { + display: block; + max-width: 100%; + padding-right: 25px; + color: #282c37; } - } - .entry:first-child { - border-radius: 4px 4px 0 0; + .status__avatar { + position: absolute; + left: 14px; + top: 14px; + width: 48px; + height: 48px; - &:last-child { - border-radius: 4px; + & > div { + width: 48px; + height: 48px; + } + + img { + display: block; + border-radius: 4px; + } } - } - @media screen and (max-width: 700px) { - border-radius: 0; - box-shadow: none; + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; - .entry { - &:last-child { - border-radius: 0; + strong { + font-weight: 500; + color: #282c37; } - &:first-child { - border-radius: 0; + span { + font-size: 14px; + color: #9baec8; + } + } - &:last-child { - border-radius: 0; - } + .status__content { + color: #282c37; + + a { + color: #2b90d9; } } - } - .entry__container { - overflow: hidden; + .status__attachments { + margin-top: 8px; + overflow: hidden; + width: 100%; + box-sizing: border-box; + height: 110px; + display: flex; + } } - .avatar { - width: 56px; - padding: 15px 10px; - padding-right: 5px; - float: left; + .detailed-status.light { + padding: 14px; + background: #fff; + cursor: default; - img { - width: 56px; - height: 56px; + .detailed-status__display-name { display: block; - border-radius: 4px; - } - } + overflow: hidden; + margin-bottom: 15px; - .entry__container__container { - margin-left: 71px; - } + & > div { + float: left; + margin-right: 10px; + } - .header { - margin-bottom: 10px; - padding: 15px; - padding-bottom: 0; - padding-left: 8px; - display: flex; + .display-name { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + strong { + font-weight: 500; + color: #282c37; + } - .header__left { - flex: 1; + span { + font-size: 14px; + color: #9baec8; + } + } } - .header__right { + .avatar { + width: 48px; + height: 48px; + img { + display: block; + border-radius: 4px; + } } - .name { - text-decoration: none; + .status__content { + color: #282c37; + + a { + color: #2b90d9; + } + } + + .detailed-status__meta { + margin-top: 15px; color: #9baec8; + font-size: 14px; + line-height: 18px; - strong { - color: #282c37; - font-weight: 500; + a { + color: inherit; } - &:hover { - strong { - text-decoration: underline; - } + span > span { + font-weight: 500; + font-size: 12px; + margin-left: 6px; + display: inline-block; } } - } - - .pre-header { - border-bottom: 1px solid #d9e1e8; - color: #2b90d9; - padding: 5px 10px; - padding-left: 8px; - clear: both; - .name { - color: #2b90d9; - font-weight: 500; - text-decoration: none; + .detailed-status__attachments { + margin-top: 8px; + overflow: hidden; + width: 100%; + box-sizing: border-box; + height: 300px; + display: flex; + } - &:hover { - text-decoration: underline; + .video-player { + margin-top: 8px; + height: 300px; + overflow: hidden; + + video { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; + top: 50%; + transform: translateY(-50%); } } } - .content { - font-size: 14px; - padding: 0 15px; - padding-left: 8px; - padding-bottom: 15px; - color: #282c37; - word-wrap: break-word; - overflow: hidden; - white-space: pre-wrap; - - p { - margin-bottom: 18px; + .media-item, .video-item { + box-sizing: border-box; + position: relative; + left: auto; + top: auto; + right: auto; + bottom: auto; + float: left; + border: medium none; + display: block; + flex: 1 1 auto; + height: 100%; + margin-right: 2px; - &:last-child { - margin-bottom: 0; - } + &:last-child { + margin-right: 0; } a { - color: #2b90d9; + display: block; + width: 100%; + height: 100%; + background: no-repeat scroll center center / cover; text-decoration: none; + cursor: zoom-in; + } + } - &:hover { - text-decoration: underline; - } + .video-item { + max-width: 196px; - &.mention { - &:hover { - text-decoration: none; + a { + cursor: pointer; + } - span { - text-decoration: underline; - } - } - } + .video-item__play { + position: absolute; + top: 50%; + left: 50%; + font-size: 36px; + transform: translate(-50%, -50%); + padding: 5px; + border-radius: 100px; + color: rgba(255, 255, 255, 0.8); } } - .time { - text-decoration: none; - color: #9baec8; + .media-spoiler { + background: #9baec8; + width: 100%; + height: 100%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + transition: all 100ms linear; &:hover { - text-decoration: underline; + background: darken(#9baec8, 5%); } - } - .media-attachments { - list-style: none; - margin: 0; - padding: 0; - display: block; - overflow: hidden; - padding-left: 10px; - margin-bottom: 15px; - - li { + span { display: block; - float: left; - width: 120px; - height: 100px; - border-radius: 4px; - margin-right: 4px; - margin-bottom: 4px; - a { - display: block; - width: 120px; - height: 100px; - border-radius: 4px; - background-position: center; - background-repeat: none; - background-size: cover; + &:first-child { + font-size: 14px; + } + + &:last-child { + font-size: 11px; + font-weight: 500; } } } +} - @media screen and (max-width: 360px) { - .avatar { - display: none; - } +.embed { + .activity-stream { + border-radius: 4px; + box-shadow: none; - .entry__container__container { - margin-left: 7px; + .entry { + &:last-child { + border-radius: 0 0 4px 4px; + } + + &:first-child { + border-radius: 4px 4px 0 0; + + &:last-child { + border-radius: 4px; + } + } } } } diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 4a591dc22..2360061ff 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -5,8 +5,8 @@ class Api::OembedController < ApiController def show @stream_entry = stream_entry_from_url(params[:url]) - @width = [300, params[:maxwidth].to_i].min - @height = [200, params[:maxheight].to_i].min + @width = params[:maxwidth].present? ? params[:maxwidth].to_i : 400 + @height = params[:maxheight].present? ? params[:maxheight].to_i : 600 end private diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 98d029030..58dd423f7 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -9,8 +9,6 @@ class StreamEntriesController < ApplicationController before_action :check_account_suspension def show - @type = @stream_entry.activity_type.downcase - respond_to do |format| format.html do return gone if @stream_entry.activity.nil? @@ -25,6 +23,15 @@ class StreamEntriesController < ApplicationController end end + def embed + response.headers['X-Frame-Options'] = 'ALLOWALL' + @external_links = true + + return gone if @stream_entry.activity.nil? + + render layout: 'embedded' + end + private def set_account @@ -37,6 +44,7 @@ class StreamEntriesController < ApplicationController def set_stream_entry @stream_entry = @account.stream_entries.find(params[:id]) + @type = @stream_entry.activity_type.downcase end def check_account_suspension diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 0aa7008be..5cd65008e 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -5,6 +5,10 @@ module StreamEntriesHelper account.display_name.blank? ? account.username : account.display_name end + def acct(account) + "@#{account.acct}#{@external_links && account.local? ? "@#{Rails.configuration.x.local_domain}" : ''}" + end + def avatar_for_status_url(status) status.reblog? ? status.reblog.account.avatar.url( :original) : status.account.avatar.url( :original) end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index a97fe89a5..2a5d23739 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -45,14 +45,14 @@ class MediaAttachment < ApplicationRecord if f.instance.image? { original: '1280x1280>', - small: '250x250>', + small: '400x400>', } else { small: { convert_options: { output: { - vf: 'scale=\'min(250\, iw):min(250\, ih)\':force_original_aspect_ratio=decrease', + vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', }, }, format: 'png', diff --git a/app/models/tag.rb b/app/models/tag.rb index e5b0511ae..77a73cce8 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -3,7 +3,7 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses - HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]+)/i + HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i validates :name, presence: true, uniqueness: true diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 2f280e03f..5c04cfee4 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -8,7 +8,6 @@ class FavouriteService < BaseService def call(account, status) favourite = Favourite.create!(account: account, status: status) - HubPingWorker.perform_async(account.id) Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id) if status.local? diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 423b833cf..ed9b62455 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -20,7 +20,6 @@ class FollowService < BaseService merge_into_timeline(target_account, source_account) - HubPingWorker.perform_async(source_account.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 9e0ced129..d5204151b 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -16,7 +16,6 @@ class PostStatusService < BaseService process_hashtags_service.call(status) DistributionWorker.perform_async(status.id) - HubPingWorker.perform_async(account.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) status diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 39fdb4ea7..7d0c90d2f 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -9,7 +9,6 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '') DistributionWorker.perform_async(reblog.id) - HubPingWorker.perform_async(account.id) Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) if reblogged_status.local? diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index fa55e668e..836b8fdc5 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -13,7 +13,6 @@ class RemoveStatusService < BaseService return unless status.account.local? - HubPingWorker.perform_async(status.account.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) end diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml index 307d75c81..160a66710 100644 --- a/app/views/about/index.html.haml +++ b/app/views/about/index.html.haml @@ -1,6 +1,15 @@ - content_for :page_title do = Rails.configuration.x.local_domain +- content_for :header_tags do + %meta{ property: 'og:site_name', content: 'Mastodon' }/ + %meta{ property: 'og:type', content: 'website' }/ + %meta{ property: 'og:title', content: Rails.configuration.x.local_domain }/ + %meta{ property: 'og:description', content: "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" }/ + %meta{ property: 'og:image', content: asset_url('mastodon_small.jpg') }/ + %meta{ property: 'og:image:width', content: '400' }/ + %meta{ property: 'og:image:height', content: '400' }/ + .wrapper %h1 = image_tag 'logo.png' diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby index b2903d189..a22568396 100644 --- a/app/views/accounts/show.atom.ruby +++ b/app/views/accounts/show.atom.ruby @@ -15,7 +15,6 @@ Nokogiri::XML::Builder.new do |xml| link_alternate xml, TagManager.instance.url_for(@account) link_self xml, account_url(@account, format: 'atom') link_hub xml, api_push_url - link_hub xml, Rails.configuration.x.hub_url link_salmon xml, api_salmon_url(@account.id) @entries.each do |stream_entry| diff --git a/app/views/api/oembed/show.json.rabl b/app/views/api/oembed/show.json.rabl index e035bc13c..f33b70ee5 100644 --- a/app/views/api/oembed/show.json.rabl +++ b/app/views/api/oembed/show.json.rabl @@ -9,6 +9,6 @@ node(:author_url) { |entry| account_url(entry.account) } node(:provider_name) { Rails.configuration.x.local_domain } node(:provider_url) { root_url } node(:cache_age) { 86_400 } -node(:html, &:content) +node(:html) { |entry| "<iframe src=\"#{embed_account_stream_entry_url(entry.account, entry)}\" style=\"width: 100%; overflow: hidden\" frameborder=\"0\" width=\"#{@width}\" height=\"#{@height}\" scrolling=\"no\"></iframe>" } node(:width) { @width } -node(:height) { @height } +node(:height) { nil } diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 498fae105..0adce05bf 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,5 @@ - content_for :header_tags do + %meta{:name => "apple-mobile-web-app-capable", :content => "yes"}/ = javascript_include_tag 'application' = react_component 'Mastodon', default_props, class: 'app-holder', prerender: false diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 87f98198c..7e28d27ec 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -9,7 +9,6 @@ %link{:rel => "manifest", :href => "/manifest.json"}/ %meta{:name => "msapplication-config", :content => "/browserconfig.xml"}/ %meta{:name => "theme-color", :content => "#2b90d9"}/ - %meta{:name => "apple-mobile-web-app-capable", :content => "yes"}/ %title = "#{yield(:page_title)} - " if content_for?(:page_title) diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml new file mode 100644 index 000000000..adbf0a287 --- /dev/null +++ b/app/views/layouts/embedded.html.haml @@ -0,0 +1,8 @@ +!!! 5 +%html{:lang => 'en'} + %head + %meta{:charset => 'utf-8'}/ + = stylesheet_link_tag 'application', media: 'all' + = javascript_include_tag 'application_public' + %body.embed + = yield diff --git a/app/views/stream_entries/_content_spoiler.html.haml b/app/views/stream_entries/_content_spoiler.html.haml new file mode 100644 index 000000000..d80ea46a0 --- /dev/null +++ b/app/views/stream_entries/_content_spoiler.html.haml @@ -0,0 +1,3 @@ +.media-spoiler + %span= t('stream_entries.sensitive_content') + %span= t('stream_entries.click_to_show') diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml new file mode 100644 index 000000000..94451d3bd --- /dev/null +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -0,0 +1,36 @@ +.detailed-status.light + = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + %div + %div.avatar + = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '' + %span.display-name + %strong= display_name(status.account) + %span= acct(status.account) + + .status__content= Formatter.instance.format(status) + + - unless status.media_attachments.empty? + - if status.media_attachments.first.video? + .video-player + - if status.sensitive? + = render partial: 'stream_entries/content_spoiler' + %video{ src: status.media_attachments.first.file.url(:original), loop: true } + - else + .detailed-status__attachments + - if status.sensitive? + = render partial: 'stream_entries/content_spoiler' + - status.media_attachments.each do |media| + .media-item + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' + + %div.detailed-status__meta + = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: @external_links ? '_blank' : nil, rel: 'noopener' do + %span= l(status.created_at) + · + %span + = fa_icon('retweet') + %span= status.reblogs.count + · + %span + = fa_icon('star') + %span= status.favourites.count diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml new file mode 100644 index 000000000..da3bc0ccb --- /dev/null +++ b/app/views/stream_entries/_simple_status.html.haml @@ -0,0 +1,28 @@ +.status.light + .status__header + .status__meta + = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener' + + = link_to TagManager.instance.url_for(status.account), class: 'status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do + .status__avatar + %div + = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '' + %span.display-name + %strong= display_name(status.account) + %span= acct(status.account) + + .status__content= Formatter.instance.format(status) + + - unless status.media_attachments.empty? + .status__attachments + - if status.sensitive? + = render partial: 'stream_entries/content_spoiler' + - if status.media_attachments.first.video? + .video-item + = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener' do + .video-item__play + = fa_icon('play') + - else + - status.media_attachments.each do |media| + .media-item + = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener' diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index 8169b8178..67cb06a83 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -1,7 +1,7 @@ - include_threads ||= false - is_predecessor ||= false - is_successor ||= false -- centered = include_threads && !is_predecessor && !is_successor +- centered ||= include_threads && !is_predecessor && !is_successor - if status.reply? && include_threads = render partial: 'status', collection: @ancestors, as: :status, locals: { is_predecessor: true } @@ -13,28 +13,7 @@ Shared by = link_to display_name(status.account), TagManager.instance.url_for(status.account), class: 'name' - .entry__container - .avatar - = image_tag avatar_for_status_url(status) - - .entry__container__container - .header - .header__left - = link_to TagManager.instance.url_for(proper_status(status).account), class: 'name' do - %strong= display_name(proper_status(status).account) - = "@#{proper_status(status).account.acct}" - - .header__right - = link_to TagManager.instance.url_for(proper_status(status)), class: 'time' do - %span{ title: proper_status(status).created_at } - = relative_time(proper_status(status).created_at) - - .content= Formatter.instance.format(proper_status(status)) - - - if (status.reblog? ? status.reblog : status).media_attachments.size > 0 - %ul.media-attachments - - (status.reblog? ? status.reblog : status).media_attachments.each do |media| - %li.transparent-background= link_to '', media.file.url( :original), style: "background-image: url(#{media.file.url( :small)})", target: '_blank' + = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) } - if include_threads = render partial: 'status', collection: @descendants, as: :status, locals: { is_successor: true } diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml new file mode 100644 index 000000000..fd07fdd91 --- /dev/null +++ b/app/views/stream_entries/embed.html.haml @@ -0,0 +1,2 @@ +.activity-stream.activity-stream-headless + = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, centered: true } diff --git a/app/workers/hub_ping_worker.rb b/app/workers/hub_ping_worker.rb deleted file mode 100644 index 14a151ba0..000000000 --- a/app/workers/hub_ping_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class HubPingWorker - include Sidekiq::Worker - include RoutingHelper - - def perform(account_id) - account = Account.find(account_id) - return unless account.local? - OStatus2::Publication.new(account_url(account, format: 'atom'), [Rails.configuration.x.hub_url]).publish - end -end |