diff --git a/.eslintrc.yml b/.eslintrc.yml
index 7c6da9d57a0..b1b38351cdb 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -29,6 +29,11 @@ settings:
import/ignore:
- node_modules
- \\.(css|scss|json)$
+ import/resolver:
+ node:
+ moduleDirectory:
+ - node_modules
+ - app/javascript
rules:
brace-style: warn
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000000..35b0cd787d2
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "app/javascript/themes/mastodon-go"]
+ path = app/javascript/themes/mastodon-go
+ url = https://github.com/marrus-sh/mastodon-go
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 299306299a9..42dfc57dc2d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,3 +1,36 @@
+# Contributing to Mastodon Glitch Edition #
+
+Thank you for your interest in contributing to the `glitch-soc` project!
+Here are some guidelines, and ways you can help.
+
+> (This document is a bit of a work-in-progress, so please bear with us.
+> If you don't see what you're looking for here, please don't hesitate to reach out!)
+
+## Planning ##
+
+Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
+We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
+
+## Documentation ##
+
+The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
+Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
+Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
+
+## Frontend Development ##
+
+Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information.
+
+## Backend Development ##
+
+See the guidelines below.
+
+ - - -
+
+You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below.
+
+
+
CONTRIBUTING
============
@@ -49,3 +82,5 @@ It is expected that you have a working development environment set up (see back-
* If you are introducing new strings, they must be using localization methods
If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet.
+
+
diff --git a/README.md b/README.md
index 5cf91d52ca9..998d5700533 100644
--- a/README.md
+++ b/README.md
@@ -1,85 +1,10 @@
-![Mastodon](https://i.imgur.com/NhZc40l.png)
-========
+# Mastodon Glitch Edition #
-[![Build Status](https://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
-[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate]
+> Now with automated deploys!
-[travis]: https://travis-ci.org/tootsuite/mastodon
-[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
+[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
-Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools.
+So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?
-Click on the screenshot below to watch a demo of the UI:
-
-[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo]
-
-[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
-
-**Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
-
-If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
-
-[patreon]: https://www.patreon.com/user?u=619786
-
----
-
-## Resources
-
-- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
-- [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org)
-- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
-- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
-- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
-- [List of sponsors](https://joinmastodon.org/sponsors)
-
-## Features
-
-**No vendor lock-in: Fully interoperable with any conforming platform**
-
-It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network!
-
-**Real-time timeline updates**
-
-See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
-
-**Federated thread resolving**
-
-If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
-
-**Media attachments like images and short videos**
-
-Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines!
-
-**OAuth2 and a straightforward REST API**
-
-Mastodon acts as an OAuth2 provider so 3rd party apps can use the API
-
-**Fast response times**
-
-Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing
-
-**Deployable via Docker**
-
-You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
-
----
-
-## Development
-
-Please follow the [development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) from the documentation repository.
-
-## Deployment
-
-There are guides in the documentation repository for [deploying on various platforms](https://github.com/tootsuite/documentation#running-mastodon).
-
-## Contributing
-
-You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. [Here are the guidelines for code contributions](CONTRIBUTING.md)
-
-**IRC channel**: #mastodon on irc.freenode.net
-
----
-
-## Extra credits
-
-The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo)
+- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
+- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
diff --git a/Vagrantfile b/Vagrantfile
index 0c21bed68c8..351ab5cfaa0 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -83,7 +83,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
- vb.customize ["modifyvm", :id, "--memory", "2048"]
+ vb.customize ["modifyvm", :id, "--memory", "4096"]
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
# https://github.com/mitchellh/vagrant/issues/1172
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 4676f60de7c..85eb2d60e29 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -13,9 +13,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def follow
- FollowService.new.call(current_user.account, @account.acct)
+ reblogs_arg = { reblogs: params[:reblogs] }
+
+ FollowService.new.call(current_user.account, @account.acct, reblogs_arg)
- options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } }
+ options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 0c43cb94302..92ad251efc8 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -8,10 +8,15 @@ class Api::V1::MutesController < Api::BaseController
respond_to :json
def index
- @accounts = load_accounts
+ @data = @accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
+ def details
+ @data = @mutes = load_mutes
+ render json: @mutes, each_serializer: REST::MuteSerializer
+ end
+
private
def load_accounts
@@ -22,6 +27,10 @@ class Api::V1::MutesController < Api::BaseController
Account.includes(:muted_by).references(:muted_by)
end
+ def load_mutes
+ paginated_mutes.includes(:account, :target_account).to_a
+ end
+
def paginated_mutes
Mute.where(account: current_account).paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
@@ -36,26 +45,34 @@ class Api::V1::MutesController < Api::BaseController
def next_path
if records_continue?
- api_v1_mutes_url pagination_params(max_id: pagination_max_id)
+ url_for pagination_params(max_id: pagination_max_id)
end
end
def prev_path
- unless @accounts.empty?
- api_v1_mutes_url pagination_params(since_id: pagination_since_id)
+ unless@data.empty?
+ url_for pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
- @accounts.last.muted_by_ids.last
+ if params[:action] == "details"
+ @mutes.last.id
+ else
+ @accounts.last.muted_by_ids.last
+ end
end
def pagination_since_id
- @accounts.first.muted_by_ids.first
+ if params[:action] == "details"
+ @mutes.first.id
+ else
+ @accounts.first.muted_by_ids.first
+ end
end
def records_continue?
- @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+ @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 8910b77e931..a949752fb43 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -24,11 +24,20 @@ class Api::V1::NotificationsController < Api::BaseController
render_empty
end
+ def destroy
+ dismiss
+ end
+
def dismiss
current_account.notifications.find_by!(id: params[:id]).destroy!
render_empty
end
+ def destroy_multiple
+ current_account.notifications.where(id: params[:ids]).destroy_all
+ render_empty
+ end
+
private
def load_notifications
diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb
index 997eed6e2c5..d1b4e040220 100644
--- a/app/controllers/api/v1/search_controller.rb
+++ b/app/controllers/api/v1/search_controller.rb
@@ -3,7 +3,7 @@
class Api::V1::SearchController < Api::BaseController
include Authorization
- RESULTS_LIMIT = 5
+ RESULTS_LIMIT = 10
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
new file mode 100644
index 00000000000..d455227eb56
--- /dev/null
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::DirectController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :read }, only: [:show]
+ before_action :require_user!, only: [:show]
+ after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
+
+ respond_to :json
+
+ def show
+ @statuses = load_statuses
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ end
+
+ private
+
+ def load_statuses
+ cached_direct_statuses
+ end
+
+ def cached_direct_statuses
+ cache_collection direct_statuses, Status
+ end
+
+ def direct_statuses
+ direct_timeline_statuses.paginate_by_max_id(
+ limit_param(DEFAULT_STATUSES_LIMIT),
+ params[:max_id],
+ params[:since_id]
+ )
+ end
+
+ def direct_timeline_statuses
+ Status.as_direct_timeline(current_account)
+ end
+
+ def insert_pagination_headers
+ set_pagination_headers(next_path, prev_path)
+ end
+
+ def pagination_params(core_params)
+ params.permit(:local, :limit).merge(core_params)
+ end
+
+ def next_path
+ api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
+ end
+
+ def prev_path
+ api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
+ end
+
+ def pagination_max_id
+ @statuses.last.id
+ end
+
+ def pagination_since_id
+ @statuses.first.id
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 21dde20ce40..ad7f09f3486 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -6,6 +6,7 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
+ @frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
end
private
diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb
new file mode 100644
index 00000000000..f79e1b320b7
--- /dev/null
+++ b/app/controllers/settings/keyword_mutes_controller.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class Settings::KeywordMutesController < ApplicationController
+ layout 'admin'
+
+ before_action :authenticate_user!
+ before_action :load_keyword_mute, only: [:edit, :update, :destroy]
+
+ def index
+ @keyword_mutes = paginated_keyword_mutes_for_account
+ end
+
+ def new
+ @keyword_mute = keyword_mutes_for_account.build
+ end
+
+ def create
+ @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
+
+ if @keyword_mute.persisted?
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ else
+ render :new
+ end
+ end
+
+ def update
+ if @keyword_mute.update(keyword_mute_params)
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @keyword_mute.destroy!
+
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ end
+
+ def destroy_all
+ keyword_mutes_for_account.delete_all
+
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ end
+
+ private
+
+ def keyword_mutes_for_account
+ Glitch::KeywordMute.where(account: current_account)
+ end
+
+ def load_keyword_mute
+ @keyword_mute = keyword_mutes_for_account.find(params[:id])
+ end
+
+ def keyword_mute_params
+ params.require(:keyword_mute).permit(:keyword, :whole_word)
+ end
+
+ def paginated_keyword_mutes_for_account
+ keyword_mutes_for_account.order(:keyword).page params[:page]
+ end
+end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index cc579dbc889..5f61e2182cf 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -48,7 +48,7 @@ class StreamEntriesController < ApplicationController
@type = @stream_entry.activity_type.downcase
raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil?
- authorize @stream_entry.activity, :show? if @stream_entry.hidden?
+ authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only?
rescue Mastodon::NotPermittedError
# Reraise in order to get a 404
raise ActiveRecord::RecordNotFound
diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb
new file mode 100644
index 00000000000..7b98cd59e0c
--- /dev/null
+++ b/app/helpers/settings/keyword_mutes_helper.rb
@@ -0,0 +1,2 @@
+module Settings::KeywordMutesHelper
+end
diff --git a/app/javascript/glitch/actions/local_settings.js b/app/javascript/glitch/actions/local_settings.js
new file mode 100644
index 00000000000..93c5a9a171f
--- /dev/null
+++ b/app/javascript/glitch/actions/local_settings.js
@@ -0,0 +1,93 @@
+/*
+
+`actions/local_settings`
+========================
+
+> For more information on the contents of this file, please contact:
+>
+> - kibigo! [@kibi@glitch.social]
+
+This file provides our Redux actions related to local settings. It
+consists of the following:
+
+ - __`changesLocalSetting(key, value)` :__
+ Changes the local setting with the given `key` to the given
+ `value`. `key` **MUST** be an array of strings, as required by
+ `Immutable.Map.prototype.getIn()`.
+
+ - __`saveLocalSettings()` :__
+ Saves the local settings to `localStorage` as a JSON object. We
+ shouldn't ever need to call this ourselves.
+
+*/
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+Constants:
+----------
+
+We provide the following constants:
+
+ - __`LOCAL_SETTING_CHANGE` :__
+ This string constant is used to dispatch a setting change to our
+ reducer in `reducers/local_settings`, where the setting is
+ actually changed.
+
+*/
+
+export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+`changeLocalSetting(key, value)`:
+---------------------------------
+
+Changes the local setting with the given `key` to the given `value`.
+`key` **MUST** be an array of strings, as required by
+`Immutable.Map.prototype.getIn()`.
+
+To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our
+reducer in `reducers/local_settings`.
+
+*/
+
+export function changeLocalSetting(key, value) {
+ return dispatch => {
+ dispatch({
+ type: LOCAL_SETTING_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveLocalSettings());
+ };
+};
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+`saveLocalSettings()`:
+----------------------
+
+Saves the local settings to `localStorage` as a JSON object.
+`changeLocalSetting()` calls this whenever it changes a setting. We
+shouldn't ever need to call this ourselves.
+
+> __TODO :__
+> Right now `saveLocalSettings()` doesn't keep track of which user
+> is currently signed in, but it might be better to give each user
+> their *own* local settings.
+
+*/
+
+export function saveLocalSettings() {
+ return (_, getState) => {
+ const localSettings = getState().get('local_settings').toJS();
+ localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
+ };
+};
diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js
new file mode 100644
index 00000000000..7bc1a2189bb
--- /dev/null
+++ b/app/javascript/glitch/components/account/header.js
@@ -0,0 +1,227 @@
+/*
+
+``
+=================
+
+> For more information on the contents of this file, please contact:
+>
+> - kibigo! [@kibi@glitch.social]
+
+Original file by @gargron@mastodon.social et al as part of
+tootsuite/mastodon. We've expanded it in order to handle user bio
+frontmatter.
+
+The `` component provides the header for account
+timelines. It is a fairly simple component which mostly just consists
+of a `render()` method.
+
+__Props:__
+
+ - __`account` (`ImmutablePropTypes.map`) :__
+ The account to render a header for.
+
+ - __`me` (`PropTypes.number.isRequired`) :__
+ The id of the currently-signed-in account.
+
+ - __`onFollow` (`PropTypes.func.isRequired`) :__
+ The function to call when the user clicks the "follow" button.
+
+ - __`intl` (`PropTypes.object.isRequired`) :__
+ Our internationalization object, inserted by `@injectIntl`.
+
+*/
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+Imports:
+--------
+
+*/
+
+// Package imports //
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+// Mastodon imports //
+import emojify from '../../../mastodon/features/emoji/emoji';
+import IconButton from '../../../mastodon/components/icon_button';
+import Avatar from '../../../mastodon/components/avatar';
+import { me } from '../../../mastodon/initial_state';
+
+// Our imports //
+import { processBio } from '../../util/bio_metadata';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+Inital setup:
+-------------
+
+The `messages` constant is used to define any messages that we need
+from inside props. In our case, these are the `unfollow`, `follow`, and
+`requested` messages used in the `title` of our buttons.
+
+*/
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+});
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*
+
+Implementation:
+---------------
+
+*/
+
+@injectIntl
+export default class AccountHeader extends ImmutablePureComponent {
+
+ static propTypes = {
+ account : ImmutablePropTypes.map,
+ onFollow : PropTypes.func.isRequired,
+ intl : PropTypes.object.isRequired,
+ };
+
+/*
+
+### `render()`
+
+The `render()` function is used to render our component.
+
+*/
+
+ render () {
+ const { account, intl } = this.props;
+
+/*
+
+If no `account` is provided, then we can't render a header. Otherwise,
+we get the `displayName` for the account, if available. If it's blank,
+then we set the `displayName` to just be the `username` of the account.
+
+*/
+
+ if (!account) {
+ return null;
+ }
+
+ let displayName = account.get('display_name_html');
+ let info = '';
+ let actionBtn = '';
+ let following = false;
+
+/*
+
+Next, we handle the account relationships. If the account follows the
+user, then we add an `info` message. If the user has requested a
+follow, then we disable the `actionBtn` and display an hourglass.
+Otherwise, if the account isn't blocked, we set the `actionBtn` to the
+appropriate icon.
+
+*/
+
+ if (me !== account.get('id')) {
+ if (account.getIn(['relationship', 'followed_by'])) {
+ info = (
+
+
+
+ );
+ }
+ if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = (
+
+
+
+ );
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ following = account.getIn(['relationship', 'following']);
+ actionBtn = (
+
+
+
+ );
+ }
+ }
+
+/*
+ we extract the `text` and
+`metadata` from our account's `note` using `processBio()`.
+
+*/
+
+ const { text, metadata } = processBio(account.get('note'));
+
+/*
+
+Here, we render our component using all the things we've defined above.
+
+*/
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js
new file mode 100644
index 00000000000..33a9730e5ac
--- /dev/null
+++ b/app/javascript/glitch/components/status/index.js
@@ -0,0 +1,760 @@
+/*
+
+``
+==========
+
+Original file by @gargron@mastodon.social et al as part of
+tootsuite/mastodon. *Heavily* rewritten (and documented!) by
+@kibi@glitch.social as a part of glitch-soc/mastodon. The following
+features have been added:
+
+ - Better separating the "guts" of statuses from their wrapper(s)
+ - Collapsing statuses
+ - Moving images inside of CWs
+
+A number of aspects of this original file have been split off into
+their own components for better maintainance; for these, see:
+
+ -
+ -
+
+…And, of course, the other -related components as well.
+
+*/
+
+ /* * * * */
+
+/*
+
+Imports:
+--------
+
+*/
+
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+// Mastodon imports //
+import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
+import { autoPlayGif } from '../../../mastodon/initial_state';
+
+// Our imports //
+import StatusPrepend from './prepend';
+import StatusHeader from './header';
+import StatusContent from './content';
+import StatusActionBar from './action_bar';
+import StatusGallery from './gallery';
+import StatusPlayer from './player';
+import NotificationOverlayContainer from '../notification/overlay/container';
+
+ /* * * * */
+
+/*
+
+The `` component:
+-------------------------
+
+The `` component is a container for statuses. It consists of a
+few parts:
+
+ - The ``, which contains tangential information about
+ the status, such as who reblogged it.
+ - The ``, which contains the avatar and username of the
+ status author, as well as a media icon and the "collapse" toggle.
+ - The ``, which contains the content of the status.
+ - The ``, which provides actions to be performed
+ on statuses, like reblogging or sending a reply.
+
+### Context
+
+ - __`router` (`PropTypes.object`) :__
+ We need to get our router from the surrounding React context.
+
+### Props
+
+ - __`id` (`PropTypes.number`) :__
+ The id of the status.
+
+ - __`status` (`ImmutablePropTypes.map`) :__
+ The status object, straight from the store.
+
+ - __`account` (`ImmutablePropTypes.map`) :__
+ Don't be confused by this one! This is **not** the account which
+ posted the status, but the associated account with any further
+ action (eg, a reblog or a favourite).
+
+ - __`settings` (`ImmutablePropTypes.map`) :__
+ These are our local settings, fetched from our store. We need this
+ to determine how best to collapse our statuses, among other things.
+
+ - __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
+ `onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
+ `onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
+ These are all functions passed through from the
+ ``. We don't deal with them directly here.
+
+ - __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
+ These tell whether or not the user has modals activated for
+ reblogging and deleting statuses. They are used by the `onReblog`
+ and `onDelete` functions, but we don't deal with them here.
+
+ - __`muted` (`PropTypes.bool`) :__
+ This has nothing to do with a user or conversation mute! "Muted" is
+ what Mastodon internally calls the subdued look of statuses in the
+ notifications column. This should be `true` for notifications, and
+ `false` otherwise.
+
+ - __`collapse` (`PropTypes.bool`) :__
+ This prop signals a directive from a higher power to (un)collapse
+ a status. Most of the time it should be `undefined`, in which case
+ we do nothing.
+
+ - __`prepend` (`PropTypes.string`) :__
+ The type of prepend: `'reblogged_by'`, `'reblog'`, or
+ `'favourite'`.
+
+ - __`withDismiss` (`PropTypes.bool`) :__
+ Whether or not the status can be dismissed. Used for notifications.
+
+ - __`intersectionObserverWrapper` (`PropTypes.object`) :__
+ This holds our intersection observer. In Mastodon parlance,
+ an "intersection" is just when the status is viewable onscreen.
+
+### State
+
+ - __`isExpanded` :__
+ Should be either `true`, `false`, or `null`. The meanings of
+ these values are as follows:
+
+ - __`true` :__ The status contains a CW and the CW is expanded.
+ - __`false` :__ The status is collapsed.
+ - __`null` :__ The status is not collapsed or expanded.
+
+ - __`isIntersecting` :__
+ This boolean tells us whether or not the status is currently
+ onscreen.
+
+ - __`isHidden` :__
+ This boolean tells us if the status has been unrendered to save
+ CPUs.
+
+*/
+
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router : PropTypes.object,
+ };
+
+ static propTypes = {
+ id : PropTypes.string,
+ status : ImmutablePropTypes.map,
+ account : ImmutablePropTypes.map,
+ settings : ImmutablePropTypes.map,
+ notification : ImmutablePropTypes.map,
+ onFavourite : PropTypes.func,
+ onReblog : PropTypes.func,
+ onModalReblog : PropTypes.func,
+ onDelete : PropTypes.func,
+ onPin : PropTypes.func,
+ onMention : PropTypes.func,
+ onMute : PropTypes.func,
+ onMuteConversation : PropTypes.func,
+ onBlock : PropTypes.func,
+ onEmbed : PropTypes.func,
+ onHeightChange : PropTypes.func,
+ onReport : PropTypes.func,
+ onOpenMedia : PropTypes.func,
+ onOpenVideo : PropTypes.func,
+ reblogModal : PropTypes.bool,
+ deleteModal : PropTypes.bool,
+ muted : PropTypes.bool,
+ collapse : PropTypes.bool,
+ prepend : PropTypes.string,
+ withDismiss : PropTypes.bool,
+ intersectionObserverWrapper : PropTypes.object,
+ };
+
+ state = {
+ isExpanded : null,
+ isIntersecting : true,
+ isHidden : false,
+ markedForDelete : false,
+ }
+
+/*
+
+### Implementation
+
+#### `updateOnProps` and `updateOnStates`.
+
+`updateOnProps` and `updateOnStates` tell the component when to update.
+We specify them explicitly because some of our props are dynamically=
+generated functions, which would otherwise always trigger an update.
+Of course, this means that if we add an important prop, we will need
+to remember to specify it here.
+
+*/
+
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'settings',
+ 'prepend',
+ 'boostModal',
+ 'muted',
+ 'collapse',
+ 'notification',
+ ]
+
+ updateOnStates = [
+ 'isExpanded',
+ 'markedForDelete',
+ ]
+
+/*
+
+#### `componentWillReceiveProps()`.
+
+If our settings have changed to disable collapsed statuses, then we
+need to make sure that we uncollapse every one. We do that by watching
+for changes to `settings.collapsed.enabled` in
+`componentWillReceiveProps()`.
+
+We also need to watch for changes on the `collapse` prop---if this
+changes to anything other than `undefined`, then we need to collapse or
+uncollapse our status accordingly.
+
+*/
+
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+ if (this.state.isExpanded === false) {
+ this.setExpansion(null);
+ }
+ } else if (
+ nextProps.collapse !== this.props.collapse &&
+ nextProps.collapse !== undefined
+ ) this.setExpansion(nextProps.collapse ? false : null);
+ }
+
+/*
+
+#### `componentDidMount()`.
+
+When mounting, we just check to see if our status should be collapsed,
+and collapse it if so. We don't need to worry about whether collapsing
+is enabled here, because `setExpansion()` already takes that into
+account.
+
+The cases where a status should be collapsed are:
+
+ - The `collapse` prop has been set to `true`
+ - The user has decided in local settings to collapse all statuses.
+ - The user has decided to collapse all notifications ('muted'
+ statuses).
+ - The user has decided to collapse long statuses and the status is
+ over 400px (without media, or 650px with).
+ - The status is a reply and the user has decided to collapse all
+ replies.
+ - The status contains media and the user has decided to collapse all
+ statuses with media.
+
+We also start up our intersection observer to monitor our statuses.
+`componentMounted` lets us know that everything has been set up
+properly and our intersection observer is good to go.
+
+*/
+
+ componentDidMount () {
+ const { node, handleIntersection } = this;
+ const {
+ status,
+ settings,
+ collapse,
+ muted,
+ id,
+ intersectionObserverWrapper,
+ prepend,
+ } = this.props;
+ const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+ if (
+ collapse ||
+ autoCollapseSettings.get('all') || (
+ autoCollapseSettings.get('notifications') && muted
+ ) || (
+ autoCollapseSettings.get('lengthy') &&
+ node.clientHeight > (
+ status.get('media_attachments').size && !muted ? 650 : 400
+ )
+ ) || (
+ autoCollapseSettings.get('reblogs') &&
+ prepend === 'reblogged_by'
+ ) || (
+ autoCollapseSettings.get('replies') &&
+ status.get('in_reply_to_id', null) !== null
+ ) || (
+ autoCollapseSettings.get('media') &&
+ !(status.get('spoiler_text').length) &&
+ status.get('media_attachments').size
+ )
+ ) this.setExpansion(false);
+
+ if (!intersectionObserverWrapper) return;
+ else intersectionObserverWrapper.observe(
+ id,
+ node,
+ handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+/*
+
+#### `shouldComponentUpdate()`.
+
+If the status is about to be both offscreen (not intersecting) and
+hidden, then we only need to update it if it's not that way currently.
+If the status is moving from offscreen to onscreen, then we *have* to
+re-render, so that we can unhide the element if necessary.
+
+If neither of these cases are true, we can leave it up to our
+`updateOnProps` and `updateOnStates` arrays.
+
+*/
+
+ shouldComponentUpdate (nextProps, nextState) {
+ switch (true) {
+ case !nextState.isIntersecting && nextState.isHidden:
+ return this.state.isIntersecting || !this.state.isHidden;
+ case nextState.isIntersecting && !this.state.isIntersecting:
+ return true;
+ default:
+ return super.shouldComponentUpdate(nextProps, nextState);
+ }
+ }
+
+/*
+
+#### `componentDidUpdate()`.
+
+If our component is being rendered for any reason and an update has
+triggered, this will save its height.
+
+This is, frankly, a bit overkill, as the only instance when we
+actually *need* to update the height right now should be when the
+value of `isExpanded` has changed. But it makes for more readable
+code and prevents bugs in the future where the height isn't set
+properly after some change.
+
+*/
+
+ componentDidUpdate () {
+ if (
+ this.state.isIntersecting || !this.state.isHidden
+ ) this.saveHeight();
+ }
+
+/*
+
+#### `componentWillUnmount()`.
+
+If our component is about to unmount, then we'd better unset
+`this.componentMounted`.
+
+*/
+
+ componentWillUnmount () {
+ this.componentMounted = false;
+ }
+
+/*
+
+#### `handleIntersection()`.
+
+`handleIntersection()` either hides the status (if it is offscreen) or
+unhides it (if it is onscreen). It's called by
+`intersectionObserverWrapper.observe()`.
+
+If our status isn't intersecting, we schedule an idle task (using the
+aptly-named `scheduleIdleTask()`) to hide the status at the next
+available opportunity.
+
+tootsuite/mastodon left us with the following enlightening comment
+regarding this function:
+
+> Edge 15 doesn't support isIntersecting, but we can infer it
+
+It then implements a polyfill (intersectionRect.height > 0) which isn't
+actually sufficient. The short answer is, this behaviour isn't really
+supported on Edge but we can get kinda close.
+
+*/
+
+ handleIntersection = (entry) => {
+ const isIntersecting = (
+ typeof entry.isIntersecting === 'boolean' ?
+ entry.isIntersecting :
+ entry.intersectionRect.height > 0
+ );
+ this.setState(
+ (prevState) => {
+ if (prevState.isIntersecting && !isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting : isIntersecting,
+ isHidden : false,
+ };
+ }
+ );
+ }
+
+/*
+
+#### `hideIfNotIntersecting()`.
+
+This function will hide the status if we're still not intersecting.
+Hiding the status means that it will just render an empty div instead
+of actual content, which saves RAMS and CPUs or some such.
+
+*/
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) return;
+ this.setState(
+ (prevState) => ({ isHidden: !prevState.isIntersecting })
+ );
+ }
+
+/*
+
+#### `saveHeight()`.
+
+`saveHeight()` saves the height of our status so that when whe hide it
+we preserve its dimensions. We only want to store our height, though,
+if our status has content (otherwise, it would imply that it is
+already hidden).
+
+*/
+
+ saveHeight = () => {
+ if (this.node && this.node.children.length) {
+ this.height = this.node.getBoundingClientRect().height;
+ }
+ }
+
+/*
+
+#### `setExpansion()`.
+
+`setExpansion()` sets the value of `isExpanded` in our state. It takes
+one argument, `value`, which gives the desired value for `isExpanded`.
+The default for this argument is `null`.
+
+`setExpansion()` automatically checks for us whether toot collapsing
+is enabled, so we don't have to.
+
+We use a `switch` statement to simplify our code.
+
+*/
+
+ setExpansion = (value) => {
+ switch (true) {
+ case value === undefined || value === null:
+ this.setState({ isExpanded: null });
+ break;
+ case !value && this.props.settings.getIn(['collapsed', 'enabled']):
+ this.setState({ isExpanded: false });
+ break;
+ case !!value:
+ this.setState({ isExpanded: true });
+ break;
+ }
+ }
+
+/*
+
+#### `handleRef()`.
+
+`handleRef()` just saves a reference to our status node to `this.node`.
+It also saves our height, in case the height of our node has changed.
+
+*/
+
+ handleRef = (node) => {
+ this.node = node;
+ this.saveHeight();
+ }
+
+/*
+
+#### `parseClick()`.
+
+`parseClick()` takes a click event and responds appropriately.
+If our status is collapsed, then clicking on it should uncollapse it.
+If `Shift` is held, then clicking on it should collapse it.
+Otherwise, we open the url handed to us in `destination`, if
+applicable.
+
+*/
+
+ parseClick = (e, destination) => {
+ const { router } = this.context;
+ const { status } = this.props;
+ const { isExpanded } = this.state;
+ if (!router) return;
+ if (destination === undefined) {
+ destination = `/statuses/${
+ status.getIn(['reblog', 'id'], status.get('id'))
+ }`;
+ }
+ if (e.button === 0) {
+ if (isExpanded === false) this.setExpansion(null);
+ else if (e.shiftKey) {
+ this.setExpansion(false);
+ document.getSelection().removeAllRanges();
+ } else router.history.push(destination);
+ e.preventDefault();
+ }
+ }
+
+/*
+
+#### `render()`.
+
+`render()` actually puts our element on the screen. The particulars of
+this operation are further explained in the code below.
+
+*/
+
+ render () {
+ const {
+ parseClick,
+ setExpansion,
+ saveHeight,
+ handleRef,
+ } = this;
+ const { router } = this.context;
+ const {
+ status,
+ account,
+ settings,
+ collapsed,
+ muted,
+ prepend,
+ intersectionObserverWrapper,
+ onOpenVideo,
+ onOpenMedia,
+ notification,
+ ...other
+ } = this.props;
+ const { isExpanded, isIntersecting, isHidden } = this.state;
+ let background = null;
+ let attachments = null;
+ let media = null;
+ let mediaIcon = null;
+
+/*
+
+If we don't have a status, then we don't render anything.
+
+*/
+
+ if (status === null) {
+ return null;
+ }
+
+/*
+
+If our status is offscreen and hidden, then we render an empty
in
+its place. We fill it with "content" but note that opacity is set to 0.
+
+*/
+
+ if (!isIntersecting && isHidden) {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js
new file mode 100644
index 00000000000..8c0aed0f417
--- /dev/null
+++ b/app/javascript/glitch/components/status/prepend.js
@@ -0,0 +1,159 @@
+/*
+
+``
+=================
+
+Originally a part of ``, but extracted into a separate
+component for better documentation and maintainance by
+@kibi@glitch.social as a part of glitch-soc/mastodon.
+
+*/
+
+ /* * * * */
+
+/*
+
+Imports:
+--------
+
+*/
+
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+
+ /* * * * */
+
+/*
+
+The `` component:
+--------------------------------
+
+The `` component holds a status's prepend, ie the text
+that says “X reblogged this,” etc. It is represented by an `