about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/mastodon.jpgbin0 -> 154354 bytes
-rw-r--r--app/assets/images/mastodon_small.jpgbin0 -> 131474 bytes
-rw-r--r--app/assets/javascripts/application_public.js1
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx3
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx155
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx123
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx8
-rw-r--r--app/assets/javascripts/extras.jsx20
-rw-r--r--app/assets/stylesheets/application.scss12
-rw-r--r--app/assets/stylesheets/components.scss40
-rw-r--r--app/assets/stylesheets/stream_entries.scss378
-rw-r--r--app/controllers/api/oembed_controller.rb4
-rw-r--r--app/controllers/stream_entries_controller.rb12
-rw-r--r--app/helpers/stream_entries_helper.rb4
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/tag.rb2
-rw-r--r--app/services/favourite_service.rb1
-rw-r--r--app/services/follow_service.rb1
-rw-r--r--app/services/post_status_service.rb1
-rw-r--r--app/services/reblog_service.rb1
-rw-r--r--app/services/remove_status_service.rb1
-rw-r--r--app/views/about/index.html.haml9
-rw-r--r--app/views/accounts/show.atom.ruby1
-rw-r--r--app/views/api/oembed/show.json.rabl4
-rw-r--r--app/views/home/index.html.haml1
-rw-r--r--app/views/layouts/application.html.haml1
-rw-r--r--app/views/layouts/embedded.html.haml8
-rw-r--r--app/views/stream_entries/_content_spoiler.html.haml3
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml36
-rw-r--r--app/views/stream_entries/_simple_status.html.haml28
-rw-r--r--app/views/stream_entries/_status.html.haml25
-rw-r--r--app/views/stream_entries/embed.html.haml2
-rw-r--r--app/workers/hub_ping_worker.rb12
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