Compare commits

...

121 Commits

Author SHA1 Message Date
kibigo!
79d898ae0a Merge upstream!! #64 <3 <3 2017-07-12 02:03:17 -07:00
kibigo!
bcf7ee48e9 Importing vanillin from upstream ;) ;) 2017-07-12 01:10:17 -07:00
kibigo!
297921fce5 Moved glitch files to their own location ;) 2017-07-12 01:02:51 -07:00
DJ Sundog
74eff5456c First lame pass at adding optional git commit hash display on /about/… (#67)
* First lame pass at adding optional git commit hash display on /about/more page.

Currently, this is implemented by checking for the existence of a file called CURRENT_RELEASE in the home directory of the user running Mastodon. If the file exists, its contents are added.

I've modified my update process to include the following before precompiling assets:

git log -1 | head -n 1 | cut -d " " -f2 > ~/CURRENT_RELEASE

That puts the current commit hash into the file ~/CURRENT_RELEASE, but you figured that out because you're a smart cookie.

As I am quite sure this is a horrible methodology for implementing this, I look forward to any improvements you have to offer!

* Updated to handle instances that share a user - the CURRENT_RELEASE file now lives in the instance's base directory.

This also requires modifying the update hook to `git log -1 | head -n 1 | cut -d " " -f2 > CURRENT_RELEASE`
2017-07-11 20:32:16 -04:00
Ondřej Hruška
60d27b4302 Fixed #66 disabling collapse collapses all toots 2017-07-11 20:48:31 +02:00
Ondřej Hruška
08d19778d5 Fix extra clickable spaces for narrow screen 2017-07-11 17:53:50 +02:00
Ondřej Hruška
9f7a5aac1e Toot context menu is now centered 2017-07-10 10:21:20 -04:00
Ondřej Hruška
945c5812d3 Added extra clickable area in status gutter 2017-07-10 09:29:20 -04:00
Ondřej Hruška
667b567606 Make avatar in compose navbar hover-to-play 2017-07-10 10:26:46 +02:00
Ondřej Hruška
8e2b1f79e4 Small indent fix in components.scss 2017-07-09 14:00:13 +02:00
Ondřej Hruška
345290a905 Avatar in the detail column no longer autoplays 2017-07-09 07:54:33 -04:00
Ondřej Hruška
2fb78fefc6 Fix fullwidth media CSS bugs with NSFW video, and bad spoiler margin on static pages (#60)
* Fix fullwidth style not applied to NSFW video correctly

* Fix botched video .media-spoiler margin on static pages
2017-07-09 09:07:14 +02:00
Ondřej Hruška
dc2b8bdecd Added a toggle for full-width media previews 2017-07-08 06:46:12 -04:00
Ondřej Hruška
e3c2183c12 New design for visibility icons 2017-07-07 23:07:16 -04:00
kibigo!
86f8df7903 Fixed avis on static pages 2017-07-07 15:56:05 -07:00
Ondřej Hruška
d41cec90cf Added toot visibility icons and removed Boost btn changing icon 2017-07-07 06:31:11 -04:00
Ondřej Hruška
7859e6ad45 Fix back button to never go to a different website using history len check 2017-07-07 05:59:15 -04:00
Surinna Curtis
3464bb30f8 replies to local-only toots default to local-only, and fix some regex bugs 2017-07-06 22:30:51 -05:00
Ondřej Hruška
d87d70e89a Fixed js type error in advanced_options_dropdown.js 2017-07-06 14:46:45 -07:00
kibigo!
0c7ee5c792 Fixed non-status notification styling 2017-07-05 19:26:19 -07:00
kibigo!
bba75c15f1 Statuses redux!
- Better unified reblogs, statuses, and notifications
- Polished up collapsed toots greatly
- Apologies to bea if this makes everything more difficult
2017-07-05 18:51:23 -07:00
Ondřej Hruška
4cbbea5881 Improved CSS for drawer to restore original looks (full height) 2017-07-05 07:21:36 -04:00
Ondřej Hruška
167c392efd Fix drawer clipping dropdowns 2017-07-05 07:21:36 -04:00
Surinna Curtis
193f354d3e a real default for advanced options long description 2017-07-05 00:09:20 -05:00
Surinna Curtis
6b67b91eb1 satisfy eslint 2017-07-04 21:33:53 -07:00
Surinna Curtis
6b77424660 some adjustments to open/active for advanced options dropdown 2017-07-04 21:33:53 -07:00
Surinna Curtis
301c185878 highlight … button if any options enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
cb7f54891f Revert "change active/hover display on advanced options"
This reverts commit ade773cb0a.
2017-07-04 21:33:53 -07:00
Surinna Curtis
f6ce1a9592 toggles for advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
aee64b996c change active/hover display on advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
0c71c0ccc8 reset advanced options when appropriate 2017-07-04 21:33:53 -07:00
Surinna Curtis
49e82c1e0f add an eye when submitting a toot with do_not_federate enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
556cede00f Local-only option and dropdown all working 2017-07-04 21:33:53 -07:00
Surinna Curtis
b73ee36949 Reduce advanced options dropdown width 2017-07-04 21:33:53 -07:00
Surinna Curtis
dd49c10cdb Further improvements to dropdown html 2017-07-04 21:33:53 -07:00
Surinna Curtis
85d5249479 The beginnings of an advanced options dropdown 2017-07-04 21:33:53 -07:00
Surinna Curtis
ff9f2088f7 Move layout override into app settings modal
Squashed commit of the following:

commit 3842f879865818a3299f8283f8ed1b43c5566500
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:57:39 2017 -0500

    Fix some style issues

commit 08628a0234392ecac90e869a1272f429de0b6db2
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:35:22 2017 -0500

    Improved styling and layout of select app settings

commit 1787a7c20f2bf7101c6d6830450564178314a737
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:13:53 2017 -0500

    remove the layout selector ui from the drawer

commit 7d93b180b1e615e2c36210ad6f119fe80a2778d8
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:01:51 2017 -0500

    Add layout setting to app settings modal
2017-07-03 21:51:00 -05:00
adbelle
15227c713d Remove color lightening for search results section
This will cause it to match the shading of the container behind it.
2017-06-30 18:38:36 -04:00
adbelle
30736f4886 Add opaque background to search results section 2017-06-30 18:38:36 -04:00
kibigo!
c58877862d createBio function added 2017-06-30 15:03:31 -07:00
beatrix
0e310f1ee3 put travis thing in readme.md 2017-06-30 12:36:10 -04:00
beatrix-bitrot
7dd4d9de96 try fix failing controller spec caused by long bios 2017-06-30 16:27:52 +00:00
kibigo!
46f83bb28b Styling fixes to media spoilers 2017-06-30 02:56:19 -07:00
kibigo!
ec2daae71c Media display improvements
- built in fullwidth styling
 - letterbox settings toggle
 - media no longer counts towards height when making toot-collapsing
measurements
2017-06-30 02:18:25 -07:00
kibigo!
b525caf40a Fixing an aspect of #32 which had previously escaped my attention 2017-06-29 23:39:57 -07:00
kibigo!
fc65b691df Images now behind CWs on static pages 2017-06-29 22:59:28 -07:00
kibigo!
651c3d643c Images behind CWs in detailed statuses too 2017-06-29 22:48:22 -07:00
kibigo!
cc4cba8afd Improvements to image icon appearance on SHOW MORE 2017-06-29 01:43:15 -07:00
kibigo!
99889ea57d Tiny Status improvements
- Media toots are no longer auto-collapsed if the media is behind a CW
- Display names no longer appear clickable when a toot is collapsed
- Fixed #36 by adding reduplicating the media icon inside the SHOW
MORE/LESS
2017-06-28 23:23:05 -07:00
kibigo!
19690d3e33 Fixes #32 bug with revealing sensitive media 2017-06-28 22:57:30 -07:00
kibigo!
0b371da971 Fixed improper centering of columns-area 2017-06-28 22:18:22 -07:00
kibigo!
2d8ebdcc72 Initial store for local settings is now Immutable all the way down 2017-06-28 22:11:21 -07:00
kibigo!
595c6de32c Added App Setttings Modal 2017-06-28 22:00:54 -07:00
kibigo!
6cbbdc805f Fixed Regexp.new syntax 2017-06-28 15:16:13 -07:00
kibigo!
7b1d233f4f Linear gradient no longer extends under metadata on account pages 2017-06-28 01:10:28 -07:00
kibigo!
03f9648377 Improvements to static metadata styling, especially for mobile 2017-06-28 00:57:32 -07:00
kibigo!
6107e95404 Backend YAML Processing + Profile Metadata on Static Pages 2017-06-28 00:27:44 -07:00
kibigo!
36805a39db Moved reblog wrapper outside of <Status> 2017-06-27 18:34:30 -07:00
kibigo!
ab4632a41e Allow line-breaks in bio metadata 2017-06-27 16:20:35 -07:00
beatrix-bitrot
ddafde942c Merge remote-tracking branch 'upstream/master' 2017-06-27 20:46:13 +00:00
kibigo!
e6300de142 Profile metadata improvements 2017-06-27 05:48:26 -07:00
Surinna Curtis
a6f5111c79 Eyes with variation-selector-16 now also do local-only 2017-06-26 20:51:11 -05:00
kibigo!
59503a88ae Disable account linking on collapsed toots too 2017-06-26 15:41:09 -07:00
kibigo!
5df7bc3a8b Disable links on collapsed toots 2017-06-26 15:22:03 -07:00
kibigo!
c806fef865 Notifications collapsed by default 2017-06-26 14:28:43 -07:00
beatrix-bitrot
49ba78d6f8 fix account spec broken by change to 500char bios 2017-06-26 20:55:44 +00:00
Surinna Curtis
7b53d4bbca Bio length on client side mostly doesn't count metadata 2017-06-26 09:34:31 -05:00
Surinna Curtis
4f36aad6e8 don't count bio metadata against bio length on server 2017-06-26 09:34:31 -05:00
kibigo!
56ca33a6d3 Time needs to be just a little wider to accomodate minutes 2017-06-25 19:38:58 -07:00
kibigo!
aeff898137 We want wrapping here, sorry 😥 2017-06-25 19:26:59 -07:00
kibigo!
b323e00bf3 Merge branch 'master' of https://github.com/glitch-soc/mastodon 2017-06-25 19:19:52 -07:00
kibigo!
a520b118e4 Expand toot by clicking on body [fixed!] 2017-06-25 19:19:45 -07:00
beatrix-bitrot
93fc8aa14c keyword muting and local only tooting WIP 2017-06-26 02:19:21 +00:00
beatrix-bitrot
c0a665865e update bio length to 500 2017-06-26 02:18:52 +00:00
kibigo!
38a1299975 Stick images inside of CWs 2017-06-25 19:15:03 -07:00
kibigo!
96e1f75679 Fixes to overflows wrt drawer/column 2017-06-25 18:05:28 -07:00
kibigo!
3a99552f0c Fixed bad drawer custom.css 2017-06-25 17:55:48 -07:00
kibigo!
22cc5c0dec Improvements to action bar positioning 2017-06-25 17:35:27 -07:00
Surinna Curtis
efa425206c Move status action bar left
This also resolves the issues where sometimes the timestamps wrap and also sits unpleasantly close to the … button.
2017-06-25 18:24:50 -05:00
kibigo!
e60f27d649 Clicking on a collapsed toot just uncollapses it. 2017-06-25 16:09:32 -07:00
kibigo!
6a50e73089 Auto-uncollapse when expanding CW 2017-06-25 15:52:55 -07:00
kibigo!
b1f9892e63 [custom.scss] More media height fixes 2017-06-25 15:29:23 -07:00
kibigo!
d6e3918d92 Disables CW [Show More/Less] links in collapsed toots 2017-06-25 15:25:19 -07:00
kibigo!
6909bbdc9e [custom.scss] max-height of media now a fixed value 2017-06-25 14:44:52 -07:00
kibigo!
ddc6b85912 Color adjustment to media icons 2017-06-25 14:25:35 -07:00
kibigo!
4bc237fcfe Adds media icons to toots 2017-06-25 14:22:11 -07:00
kibigo!
efacfec3ed Media previews for collapsed toots 2017-06-25 13:57:52 -07:00
kibigo!
8ea779e59a Patching rebase errors 2017-06-25 12:51:31 -07:00
Shel Raphen
7eda83a36a Glitchsocification 2017-06-25 19:21:51 +00:00
kibigo!
af178d0ba6 Removed no-longer-necessary custom style 2017-06-24 21:28:30 -07:00
Gô Shoemake
e4326b3f12 Merge pull request #26 from ekiru/feature/manual-column-layout-setting
User-controlled multi-column/single-column overrides
2017-06-24 20:44:43 -07:00
kibigo!
b8a5052d53 Better style handling at small sizes 2017-06-24 20:36:19 -07:00
kibigo!
7427680e75 Allowed little media rules 2017-06-24 20:14:58 -07:00
kibigo!
ca0d30c04b OKAY THIS WORKS THIS WORKS 2017-06-24 20:04:46 -07:00
kibigo!
da05cde721 Better settings handling with localSettings (new!) 2017-06-24 19:56:37 -07:00
kibigo!
4c37f629bc Don't change layout of static pages 2017-06-24 18:30:59 -07:00
kibigo!
ddba5d3b8c Use Redux store to keep track of layout 2017-06-24 18:30:30 -07:00
Surinna Curtis
ceb545c080 Pass in correct "singleColumn" prop value when auto-columns is not used. 2017-06-24 15:29:46 -05:00
Surinna Curtis
a70468aa56 Support overriding media queries for deciding between single-column/multi-column layouts with a class 2017-06-24 15:29:46 -05:00
beatrix-bitrot
8b23bf7cbd clean up old avatar class 2017-06-24 03:51:01 +00:00
Matthew Walsh
f1a60d4b81 Unified avatar styling
Avatars now have consistent styling across all pages; border radius can be adjusted with a SASS variable ($ui-avatar-border-size)
2017-06-24 03:03:27 +00:00
kibigo!
2513d92c54 Un-hide dropdown menu ;P 2017-06-23 19:39:44 -07:00
kibigo!
414dfb3955 ESLint improvements for Profile Metadata 2017-06-23 18:43:30 -07:00
Gô Shoemake
67adbcc60c Reblog support for collapsed toots 2017-06-23 18:23:26 -07:00
beatrix-bitrot
453b9c6e7e missing punctuation 2017-06-23 22:01:04 +00:00
beatrix
d9b9bb8c5e glitch the getting started image 2017-06-23 21:50:45 +00:00
kibigo!
40ecbfd4a9 Very minor styling improvements to toot-collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4fe45dda9a Updates height upon collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4bd7482a7a Minor collapsing button improvements~ 2017-06-23 21:50:45 +00:00
kibigo!
93c52301ad Collapsable toots [1/??] 2017-06-23 21:50:45 +00:00
kibigo!
0d3ec19e89 Profile Metadata HACK 😈 2017-06-23 21:45:14 +00:00
Go Shoemake
62a75891ab Fixes drawer so stuff doesn't overflow 2017-06-23 21:45:14 +00:00
Charlotte Fields
b27842dc70 cybre cleanup 2017-06-23 21:45:14 +00:00
Chronister
39b6b37b74 cybrespace to 1.4.2 2017-06-23 21:45:14 +00:00
Chronister
65528fc54e All cybrespace changes through 5/28 2017-06-23 21:45:14 +00:00
Charlotte Fields
382572c213 adding cybre changes 2017-06-23 21:45:14 +00:00
beatrix-bitrot
9bc593d675 update local modifications for cors and cp 2017-06-23 21:45:14 +00:00
beatrix-bitrot
09f7ad3614 silly readme update to test automated deploys 2017-06-23 21:45:14 +00:00
beatrix-bitrot
7c2ea42cd5 update README.md 2017-06-23 21:45:14 +00:00
beatrix
ea785d0baf Update README.md 2017-06-23 21:45:14 +00:00
Beatrix Bitrot
a337c5dbe5 CORS tweaks 2017-06-23 21:45:14 +00:00
89 changed files with 4783 additions and 506 deletions

View File

@@ -106,9 +106,9 @@ GEM
rack (>= 1.0.0) rack (>= 1.0.0)
rack-test (>= 0.5.4) rack-test (>= 0.5.4)
xpath (~> 2.0) xpath (~> 2.0)
charlock_holmes (0.7.3)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
charlock_holmes (0.7.3)
chunky_png (1.3.8) chunky_png (1.3.8)
cld3 (3.1.3) cld3 (3.1.3)
ffi (>= 1.1.0, < 1.10.0) ffi (>= 1.1.0, < 1.10.0)

View File

@@ -1,70 +1,7 @@
Mastodon Mastodon Glitch Edition
======== ========
Now with automated deploys!
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis] [![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[travis]: https://travis-ci.org/tootsuite/mastodon 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?
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
Mastodon is a free, open-source social network server. A decentralized solution 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.
An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [PubsubHubbub](https://en.wikipedia.org/wiki/PubSubHubbub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)).
Click on the screenshot 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
The project focus is a clean REST API and a good user interface. 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
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
## Features
- **Fully interoperable with GNU social and any OStatus platform**
Whatever implements Atom feeds, ActivityStreams, Salmon, PubSubHubbub and Webfinger is part of the network
- **Real-time timeline updates**
See the updates of people you're following appear in real-time in the UI via WebSockets
- **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 WebM**
Upload and view images and WebM videos attached to the updates
- **OAuth2 and a straightforward REST API**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple
- **Background processing for long-running tasks**
Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are
- **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 [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
![Mastodon error image](https://mastodon.social/oops.png)

View File

@@ -0,0 +1,20 @@
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@@ -0,0 +1,112 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
// Our imports //
import { processBio } from '../../util/bio_metadata';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
@injectIntl
export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { account, me, intl } = this.props;
if (!account) {
return null;
}
let displayName = account.get('display_name');
let info = '';
let actionBtn = '';
let lockedIcon = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
}
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div>
);
}
}
if (account.get('locked')) {
lockedIcon = <i className='fa fa-lock' />;
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const { text, metadata } = processBio(account.get('note'));
return (
<div className='account__header__wrapper'>
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<a href={account.get('url')} target='_blank' rel='noopener'>
<span className='account__header__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={90} /></span>
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
{info}
{actionBtn}
</div>
</div>
{metadata.length && (
<table className='account__metadata'>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<tr key={i}>
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
</tr>
);
}
return data;
})()}
</table>
) || null}
</div>
);
}
}

View File

@@ -0,0 +1,112 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
import { injectIntl, defineMessages } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
const messages = defineMessages({
local_only_short: { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long: { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title: { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
const iconStyle = {
height: null,
lineHeight: '27px',
};
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool.isRequired,
}).isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
onToggleDropdown = () => {
this.setState({ open: !this.state.open });
};
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
}
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
state = {
open: false,
};
handleClick = (e) => {
const option = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onChange(option);
}
toggleHandler(option) {
return () => this.props.onChange(option);
}
setRef = (c) => {
this.node = c;
}
render () {
const { open } = this.state;
const { intl, values } = this.props;
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, key: 'do_not_federate' },
];
const anyEnabled = values.some((enabled) => enabled);
const optionElems = options.map((option) => {
const active = values.get(option.key);
return (
<div role='button' className='advanced-options-dropdown__option' key={option.key} >
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.toggleHandler(option.key)} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{intl.formatMessage(option.shortText)}</strong>
{intl.formatMessage(option.longText)}
</div>
</div>
);
});
return (<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className='advanced-options-dropdown__value'
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='ellipsis-h' active={open || anyEnabled}
size={18}
style={iconStyle}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{optionElems}
</div>
</div>);
}
}

View File

@@ -0,0 +1,93 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import AccountContainer from '../../../mastodon/containers/account_container';
import Permalink from '../../../mastodon/components/permalink';
import emojify from '../../../mastodon/emoji';
// Our imports //
import StatusContainer from '../../containers/status';
export default class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
renderFollow (notification) {
const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
return (
<div className='notification notification-follow'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div>
<AccountContainer id={account.get('id')} withNote={false} />
</div>
);
}
renderMention (notification) {
return (
<StatusContainer
id={notification.get('status')}
withDismiss
/>
);
}
renderFavourite (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='favourite'
muted
withDismiss
/>
);
}
renderReblog (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='reblog'
muted
withDismiss
/>
);
}
render () {
const { notification } = this.props;
switch(notification.get('type')) {
case 'follow':
return this.renderFollow(notification);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification);
case 'reblog':
return this.renderReblog(notification);
}
return null;
}
}

View File

@@ -0,0 +1,221 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
// Our imports //
import SettingsItem from './item';
const messages = defineMessages({
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
});
@injectIntl
export default class Settings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
toggleSetting: PropTypes.func.isRequired,
changeSetting: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
currentIndex: 0,
};
General = () => {
const { intl } = this.props;
return (
<div>
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
<SettingsItem
settings={this.props.settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
onChange={this.props.changeSetting}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</SettingsItem>
</div>
);
}
CollapsedStatuses = () => {
return (
<div>
<h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'enabled']}
id='mastodon-settings--collapsed-enabled'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
</SettingsItem>
<section>
<h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'all']}
id='mastodon-settings--collapsed-auto-all'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'notifications']}
id='mastodon-settings--collapsed-auto-notifications'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'lengthy']}
id='mastodon-settings--collapsed-auto-lengthy'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'replies']}
id='mastodon-settings--collapsed-auto-replies'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'media']}
id='mastodon-settings--collapsed-auto-media'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
</SettingsItem>
</section>
<section>
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'backgrounds', 'user_backgrounds']}
id='mastodon-settings--collapsed-user-backgrouns'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'backgrounds', 'preview_images']}
id='mastodon-settings--collapsed-preview-images'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
</SettingsItem>
</section>
</div>
);
}
Media = () => {
return (
<div>
<h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
<SettingsItem
settings={this.props.settings}
item={['media', 'letterbox']}
id='mastodon-settings--media-letterbox'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['media', 'fullwidth']}
id='mastodon-settings--media-fullwidth'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
</SettingsItem>
</div>
);
}
navigateTo = (e) =>
this.setState({ currentIndex: +e.currentTarget.getAttribute('data-mastodon-navigation_index') });
render () {
const { General, CollapsedStatuses, Media, navigateTo } = this;
const { onClose } = this.props;
const { currentIndex } = this.state;
return (
<div className='modal-root__modal settings-modal'>
<nav className='settings-modal__navigation'>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='0' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 0 ? ' active' : ''}`}>
<FormattedMessage id='settings.general' defaultMessage='General' />
</a>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='1' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 1 ? ' active' : ''}`}>
<FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' />
</a>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='2' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 2 ? ' active' : ''}`}>
<FormattedMessage id='settings.media' defaultMessage='Media' />
</a>
<a href='/settings/preferences' className='settings-modal__navigation-item'>
<i className='fa fa-fw fa-cog' /> <FormattedMessage id='settings.preferences' defaultMessage='User preferences' />
</a>
<a onClick={onClose} role='button' tabIndex='0' className='settings-modal__navigation-close'>
<FormattedMessage id='settings.close' defaultMessage='Close' />
</a>
</nav>
<div className='settings-modal__content'>
{
[
<General />,
<CollapsedStatuses />,
<Media />,
][currentIndex] || <General />
}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,79 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default class SettingsItem extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
item: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
message: PropTypes.object.isRequired,
})),
dependsOn: PropTypes.array,
dependsOnNot: PropTypes.array,
children: PropTypes.element.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
const { item, onChange } = this.props;
onChange(item, e);
}
render () {
const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
let enabled = true;
if (dependsOn) {
for (let i = 0; i < dependsOn.length; i++) {
enabled = enabled && settings.getIn(dependsOn[i]);
}
}
if (dependsOnNot) {
for (let i = 0; i < dependsOnNot.length; i++) {
enabled = enabled && !settings.getIn(dependsOnNot[i]);
}
}
if (options && options.length > 0) {
const currentValue = settings.getIn(item);
const optionElems = options && options.length > 0 && options.map((opt) => (
<option key={opt.value} selected={currentValue === opt.value} value={opt.value} >
{opt.message}
</option>
));
return (
<label htmlFor={id}>
<p>{children}</p>
<p>
<select
id={id}
disabled={!enabled}
onBlur={this.handleChange}
>
{optionElems}
</select>
</p>
</label>
);
} else {
return (
<label htmlFor={id}>
<input
id={id}
type='checkbox'
checked={settings.getIn(item)}
onChange={this.handleChange}
disabled={!enabled}
/>
{children}
</label>
);
}
}
}

View File

@@ -0,0 +1,159 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
import IconButton from '../../../mastodon/components/icon_button';
import DropdownMenu from '../../../mastodon/components/dropdown_menu';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
});
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
onBlock: PropTypes.func,
onReport: PropTypes.func,
onMuteConversation: PropTypes.func,
me: PropTypes.number.isRequired,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'me',
'withDismiss',
]
handleReplyClick = () => {
this.props.onReply(this.props.status, this.context.router.history);
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
handleReblogClick = (e) => {
this.props.onReblog(this.props.status, e);
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
}
handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => {
this.props.onMute(this.props.status.get('account'));
}
handleBlockClick = () => {
this.props.onBlock(this.props.status.get('account'));
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
handleReport = () => {
this.props.onReport(this.props.status);
}
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
}
render () {
const { status, me, intl, withDismiss } = this.props;
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const mutingConversation = status.get('muted');
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
menu.push(null);
if (withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
if (status.getIn(['account', 'id']) === me) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
}
/*
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
*/
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<div className='status__action-bar-dropdown'>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
</div>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
);
}
}

View File

@@ -0,0 +1,239 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import { isRtl } from '../../../mastodon/rtl';
import Permalink from '../../../mastodon/components/permalink';
export default class StatusContent extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.oneOf([true, false, null]),
setExpansion: PropTypes.func,
onHeightUpdate: PropTypes.func,
media: PropTypes.element,
mediaIcon: PropTypes.string,
parseClick: PropTypes.func,
};
state = {
hidden: true,
};
componentDidMount () {
const node = this.node;
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.addEventListener('click', this.onLinkClick.bind(this), false);
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
link.setAttribute('title', link.href);
}
}
}
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
onLinkClick = (e) => {
if (this.props.expanded === false) {
if (this.props.parseClick) this.props.parseClick(e);
}
}
onMentionClick = (mention, e) => {
if (this.props.parseClick) {
this.props.parseClick(e, `/accounts/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.props.parseClick) {
this.props.parseClick(e, `/timelines/tag/${hashtag}`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp = (e) => {
const { parseClick } = this.props;
if (!this.startXY) {
return;
}
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
parseClick(e);
}
this.startXY = null;
}
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
setRef = (c) => {
this.node = c;
}
render () {
const { status, media, mediaIcon } = this.props;
const hidden = (
this.props.setExpansion ?
!this.props.expanded :
this.state.hidden
);
const content = { __html: emojify(status.get('content')) };
const spoilerContent = {
__html: emojify(escapeTextContentForBrowser(
status.get('spoiler_text', '')
)),
};
const directionStyle = { direction: 'ltr' };
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink
to={`/accounts/${item.get('id')}`}
href={item.get('url')}
key={item.get('id')}
className='mention'
>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? [
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/>,
mediaIcon ? (
<i
className={
`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
}
aria-hidden='true'
key='1'
/>
) : null,
] : [
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/>,
];
if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>;
}
return (
<div className='status__content status__content--with-action' ref={this.setRef}>
<p
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<span dangerouslySetInnerHTML={spoilerContent} />
{' '}
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
{toggleText}
</button>
</p>
{mentionsPlaceholder}
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
<div
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
</div>
);
} else if (this.props.parseClick) {
return (
<div
ref={this.setRef}
className='status__content status__content--with-action'
style={directionStyle}
>
<div
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
);
} else {
return (
<div
ref={this.setRef}
className='status__content'
style={directionStyle}
>
<div dangerouslySetInnerHTML={content} />
{media}
</div>
);
}
}
}

View File

@@ -0,0 +1,79 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import StatusGalleryItem from './item';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
@injectIntl
export default class StatusGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
visible: !this.props.sensitive,
};
handleOpen = () => {
this.setState({ visible: !this.state.visible });
}
handleClick = (index) => {
this.props.onOpenMedia(this.props.media, index);
}
render () {
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
let children;
if (!this.state.visible) {
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
children = (
<div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />);
}
return (
<div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}>
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}
}

View File

@@ -0,0 +1,132 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
// Mastodon imports //
import { isIOS } from '../../../../mastodon/is_mobile';
export default class StatusGalleryItem extends React.PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
handleClick = (e) => {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
}
render () {
const { attachment, index, size, letterbox } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img className={letterbox ? 'letterbox' : ''} src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
<video
className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
role='application'
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
}

View File

@@ -0,0 +1,247 @@
/*
`<StatusHeader>`
================
Originally a part of `<Status>`, 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 { defineMessages, injectIntl } from 'react-intl';
// Mastodon imports //
import Avatar from '../../../mastodon/components/avatar';
import AvatarOverlay from '../../../mastodon/components/avatar_overlay';
import DisplayName from '../../../mastodon/components/display_name';
import IconButton from '../../../mastodon/components/icon_button';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `collapse` and
`uncollapse` messages used with our collapse/uncollapse buttons.
*/
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
/* * * * */
/*
The `<StatusHeader>` component:
-------------------------------
The `<StatusHeader>` component wraps together the header information
(avatar, display name) and upper buttons and icons (collapsing, media
icons) into a single `<header>` element.
### Props
- __`account`, `friend` (`ImmutablePropTypes.map`) :__
These give the accounts associated with the status. `account` is
the author of the post; `friend` will have their avatar appear
in the overlay if provided.
- __`mediaIcon` (`PropTypes.string`) :__
If a mediaIcon should be placed in the header, this string
specifies it.
- __`collapsible`, `collapsed` (`PropTypes.bool`) :__
These props tell whether a post can be, and is, collapsed.
- __`parseClick` (`PropTypes.func`) :__
This function will be called when the user clicks inside the header
information.
- __`setExpansion` (`PropTypes.func`) :__
This function is used to set the expansion state of the post.
- __`intl` (`PropTypes.object`) :__
This is our internationalization object, provided by
`injectIntl()`.
*/
@injectIntl
export default class StatusHeader extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map,
mediaIcon: PropTypes.string,
collapsible: PropTypes.bool,
collapsed: PropTypes.bool,
parseClick: PropTypes.func.isRequired,
setExpansion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
visibility: PropTypes.string,
};
/*
### Implementation
#### `handleCollapsedClick()`.
`handleCollapsedClick()` is just a simple callback for our collapsing
button. It calls `setExpansion` to set the collapsed state of the
status.
*/
handleCollapsedClick = (e) => {
const { collapsed, setExpansion } = this.props;
if (e.button === 0) {
setExpansion(collapsed ? null : false);
e.preventDefault();
}
}
/*
#### `handleAccountClick()`.
`handleAccountClick()` handles any clicks on the header info. It calls
`parseClick()` with our `account` as the anticipatory `destination`.
*/
handleAccountClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `render()`.
`render()` actually puts our element on the screen. `<StatusHeader>`
has a very straightforward rendering process.
*/
render () {
const {
account,
friend,
mediaIcon,
collapsible,
collapsed,
intl,
visibility,
} = this.props;
const visibilityClass = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
return (
<header className='status__info'>
{
/*
We have to include the status icons before the header content because
it is rendered as a float.
*/
}
<div className='status__info__icons'>
{mediaIcon ? (
<i
className={`fa fa-fw fa-${mediaIcon}`}
aria-hidden='true'
/>
) : null}
{(
<i
className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`}
title={intl.formatMessage(messages[visibility])}
aria-hidden='true'
/>
)}
{collapsible ? (
<IconButton
className='status__collapse-button'
animate flip
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
) : null}
</div>
{
/*
This begins our header content. It is all wrapped inside of a link
which gets handled by `handleAccountClick`. We use an `<AvatarOverlay>`
if we have a `friend` and a normal `<Avatar>` if we don't.
*/
}
<a
href={account.get('url')}
className='status__display-name'
onClick={this.handleAccountClick}
>
<div className='status__avatar'>{
friend ? (
<AvatarOverlay
staticSrc={account.get('avatar_static')}
overlaySrc={friend.get('avatar_static')}
/>
) : (
<Avatar
src={account.get('avatar')}
staticSrc={account.get('avatar_static')}
size={48}
/>
)
}</div>
<DisplayName account={account} />
</a>
</header>
);
}
}

View File

@@ -0,0 +1,719 @@
/*
`<Status>`
==========
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:
- <StatusHeader>
- <StatusPrepend>
…And, of course, the other <Status>-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';
// Our imports //
import StatusPrepend from './prepend';
import StatusHeader from './header';
import StatusContent from './content';
import StatusActionBar from './action_bar';
import StatusGallery from './gallery';
import StatusVideoPlayer from './video_player';
/* * * * */
/*
The `<Status>` component:
-------------------------
The `<Status>` component is a container for statuses. It consists of a
few parts:
- The `<StatusPrepend>`, which contains tangential information about
the status, such as who reblogged it.
- The `<StatusHeader>`, which contains the avatar and username of the
status author, as well as a media icon and the "collapse" toggle.
- The `<StatusContent>`, which contains the content of the status.
- The `<StatusActionBar>`, 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.
- __`me` (`PropTypes.number`) :__
This is the id of the currently-signed-in user.
- __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
`onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
`onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
These are all functions passed through from the
`<StatusContainer>`. 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.
- __`autoPlayGif` (`PropTypes.bool`) :__
This tells the frontend whether or not to autoplay gifs!
- __`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.number,
status : ImmutablePropTypes.map,
account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map,
me : PropTypes.number,
onFavourite : PropTypes.func,
onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
onDelete : PropTypes.func,
onMention : PropTypes.func,
onMute : PropTypes.func,
onMuteConversation : PropTypes.func,
onBlock : PropTypes.func,
onReport : PropTypes.func,
onOpenMedia : PropTypes.func,
onOpenVideo : PropTypes.func,
reblogModal : PropTypes.bool,
deleteModal : PropTypes.bool,
autoPlayGif : PropTypes.bool,
muted : PropTypes.bool,
collapse : PropTypes.bool,
prepend : PropTypes.string,
withDismiss : PropTypes.bool,
intersectionObserverWrapper : PropTypes.object,
};
state = {
isExpanded : null,
isIntersecting : true,
isHidden : 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',
'me',
'boostModal',
'autoPlayGif',
'muted',
'collapse',
]
updateOnStates = [
'isExpanded',
]
/*
#### `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'])) {
this.setExpansion(true);
} 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,
} = 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('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 (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, handleRef } = this;
const {
status,
account,
settings,
collapsed,
muted,
prepend,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
autoPlayGif,
...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 <div> in
its place. We fill it with "content" but note that opacity is set to 0.
*/
if (!isIntersecting && isHidden) {
return (
<div
ref={this.handleRef}
data-id={status.get('id')}
style={{
height : `${this.height}px`,
opacity : 0,
overflow : 'hidden',
}}
>
{
status.getIn(['account', 'display_name']) ||
status.getIn(['account', 'username'])
}
{status.get('content')}
</div>
);
}
/*
If user backgrounds for collapsed statuses are enabled, then we
initialize our background accordingly. This will only be rendered if
the status is collapsed.
*/
if (
settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
) background = status.getIn(['account', 'header']);
/*
This handles our media attachments. Note that we don't show media on
muted (notification) statuses. If the media type is unknown, then we
simply ignore it.
After we have generated our appropriate media element and stored it in
`media`, we snatch the thumbnail to use as our `background` if media
backgrounds for collapsed statuses are enabled.
*/
attachments = status.get('media_attachments');
if (attachments.size && !muted) {
if (attachments.some((item) => item.get('type') === 'unknown')) {
} else if (
attachments.getIn([0, 'type']) === 'video'
) {
media = ( // Media type is 'video'
<StatusVideoPlayer
media={attachments.get(0)}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenVideo={onOpenVideo}
/>
);
mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv'
media = (
<StatusGallery
media={attachments}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={onOpenMedia}
autoPlayGif={autoPlayGif}
/>
);
mediaIcon = 'picture-o';
}
if (
!status.get('sensitive') &&
!(status.get('spoiler_text').length > 0) &&
settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
) background = attachments.getIn([0, 'preview_url']);
}
/*
Finally, we can render our status. We just put the pieces together
from above. We only render the action bar if the status isn't
collapsed.
*/
return (
<article
className={
`status${
muted ? ' muted' : ''
} status-${status.get('visibility')}${
isExpanded === false ? ' collapsed' : ''
}${
isExpanded === false && background ? ' has-background' : ''
}`
}
style={{
backgroundImage: (
isExpanded === false && background ?
`url(${background})` :
'none'
),
}}
ref={handleRef}
>
{prepend && account ? (
<StatusPrepend
type={prepend}
account={account}
parseClick={parseClick}
/>
) : null}
<StatusHeader
account={status.get('account')}
friend={account}
mediaIcon={mediaIcon}
visibility={status.get('visibility')}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isExpanded === false}
parseClick={parseClick}
setExpansion={setExpansion}
/>
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
expanded={isExpanded}
setExpansion={this.setExpansion}
onHeightUpdate={this.saveHeight}
parseClick={parseClick}
/>
{isExpanded !== false ? (
<StatusActionBar
{...other}
status={status}
account={status.get('account')}
/>
) : null}
</article>
);
}
}

View File

@@ -0,0 +1,164 @@
/*
`<StatusPrepend>`
=================
Originally a part of `<Status>`, 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 escapeTextContentForBrowser from 'escape-html';
import { FormattedMessage } from 'react-intl';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
/* * * * */
/*
The `<StatusPrepend>` component:
--------------------------------
The `<StatusPrepend>` component holds a status's prepend, ie the text
that says “X reblogged this,” etc. It is represented by an `<aside>`
element.
### Props
- __`type` (`PropTypes.string`) :__
The type of prepend. One of `'reblogged_by'`, `'reblog'`,
`'favourite'`.
- __`account` (`ImmutablePropTypes.map`) :__
The account associated with the prepend.
- __`parseClick` (`PropTypes.func.isRequired`) :__
Our click parsing function.
*/
export default class StatusPrepend extends React.PureComponent {
static propTypes = {
type: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired,
parseClick: PropTypes.func.isRequired,
};
/*
### Implementation
#### `handleClick()`.
This is just a small wrapper for `parseClick()` that gets fired when
an account link is clicked.
*/
handleClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `<Message>`.
`<Message>` is a quick functional React component which renders the
actual prepend message based on our provided `type`. First we create a
`link` for the account's name, and then use `<FormattedMessage>` to
generate the message.
*/
Message = () => {
const { type, account } = this.props;
let link = (
<a
onClick={this.handleClick}
href={account.get('url')}
className='status__display-name'
>
<b
dangerouslySetInnerHTML={{
__html : emojify(escapeTextContentForBrowser(
account.get('display_name') || account.get('username')
)),
}}
/>
</a>
);
switch (type) {
case 'reblogged_by':
return (
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} boosted'
values={{ name : link }}
/>
);
case 'favourite':
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favourited your status'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name : link }}
/>
);
}
return null;
}
/*
#### `render()`.
Our `render()` is incredibly simple; we just render the icon and then
the `<Message>` inside of an <aside>.
*/
render () {
const { Message } = this;
const { type } = this.props;
return !type ? null : (
<aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<i
className={`fa fa-fw fa-${
type === 'favourite' ? 'star star-icon' : 'retweet'
} status__prepend-icon`}
/>
</div>
<Message />
</aside>
);
}
}

View File

@@ -0,0 +1,199 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
import { isIOS } from '../../../mastodon/is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class StatusVideoPlayer extends React.PureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview && !autoplay) {
return (
<div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div>
);
}
if (this.state.videoError) {
return (
<div style={{ height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className={`status__video-player-video${letterbox ? ' letterbox' : ''}`}
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { changeComposeAdvancedOption } from '../../../mastodon/actions/compose';
// Our imports //
import ComposeAdvancedOptions from '../../components/compose/advanced_options';
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(changeComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

@@ -0,0 +1,21 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { makeGetNotification } from '../../../mastodon/selectors';
// Our imports //
import Notification from '../../components/notification';
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const mapStateToProps = (state, props) => ({
notification: getNotification(state, props.notification, props.accountId),
settings: state.get('local_settings'),
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(Notification);

View File

@@ -0,0 +1,27 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { closeModal } from '../../../mastodon/actions/modal';
// Our imports //
import { changeLocalSetting } from '../../actions/local_settings';
import Settings from '../../components/settings';
const mapStateToProps = state => ({
settings: state.get('local_settings'),
});
const mapDispatchToProps = dispatch => ({
toggleSetting (setting, e) {
dispatch(changeLocalSetting(setting, e.target.checked));
},
changeSetting (setting, e) {
dispatch(changeLocalSetting(setting, e.target.value));
},
onClose () {
dispatch(closeModal());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Settings);

View File

@@ -0,0 +1,252 @@
/*
`<StatusContainer>`
===================
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
detecting reblogs has been moved here from <Status>.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import { connect } from 'react-redux';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
// Mastodon imports //
import { makeGetStatus } from '../../../mastodon/selectors';
import {
replyCompose,
mentionCompose,
} from '../../../mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
} from '../../../mastodon/actions/interactions';
import {
blockAccount,
muteAccount,
} from '../../../mastodon/actions/accounts';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from '../../../mastodon/actions/statuses';
import { initReport } from '../../../mastodon/actions/reports';
import { openModal } from '../../../mastodon/actions/modal';
// Our imports //
import Status from '../../components/status';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we will
need in our component. In our case, these are the various confirmation
messages used with statuses.
*/
const messages = defineMessages({
deleteConfirm : {
id : 'confirmations.delete.confirm',
defaultMessage : 'Delete',
},
deleteMessage : {
id : 'confirmations.delete.message',
defaultMessage : 'Are you sure you want to delete this status?',
},
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
},
muteConfirm : {
id : 'confirmations.mute.confirm',
defaultMessage : 'Mute',
},
});
/* * * * */
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in a `makeMapStateToProps()`
function to give us closure and preserve `getStatus()` across function
calls.
*/
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, ownProps) => {
let status = getStatus(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let account = undefined;
let prepend = undefined;
/*
Here we process reblogs. If our status is a reblog, then we create a
`prependMessage` to pass along to our `<Status>` along with the
reblogger's `account`, and set `coreStatus` (the one we will actually
render) to the status which has been reblogged.
*/
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';
}
/*
Here are the props we pass to `<Status>`.
*/
return {
status : status,
account : account || ownProps.account,
me : state.getIn(['meta', 'me']),
settings : state.get('local_settings'),
prepend : prepend || ownProps.prepend,
reblogModal : state.getIn(['meta', 'boost_modal']),
deleteModal : state.getIn(['meta', 'delete_modal']),
autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
};
};
return mapStateToProps;
};
/* * * * */
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We need to provide dispatches for all
of the things you can do with a status: reply, reblog, favourite, et
cetera.
For a few of these dispatches, we open up confirmation modals; the rest
just immediately execute their corresponding actions.
*/
const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch(replyCompose(status, router));
},
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.reblogModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onDelete (status) {
if (!this.deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
},
onMention (account, router) {
dispatch(mentionCompose(account, router));
},
onOpenMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
onBlock (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
onMute (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id'))),
}));
},
onMuteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
});
export default injectIntl(
connect(makeMapStateToProps, mapDispatchToProps)(Status)
);

View File

@@ -0,0 +1,44 @@
// Package imports //
import Immutable from 'immutable';
// Mastodon imports //
import { STORE_HYDRATE } from '../../mastodon/actions/store';
// Our imports //
import { LOCAL_SETTING_CHANGE } from '../actions/local_settings';
const initialState = Immutable.fromJS({
layout : 'auto',
stretch : true,
collapsed : {
enabled : true,
auto : {
all : false,
notifications : true,
lengthy : true,
replies : false,
media : false,
},
backgrounds : {
user_backgrounds : false,
preview_images : false,
},
},
media : {
letterbox : true,
fullwidth : true,
},
});
const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
export default function localSettings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('local_settings'));
case LOCAL_SETTING_CHANGE:
return state.setIn(action.key, action.value);
default:
return state;
}
};

View File

@@ -0,0 +1,380 @@
/*********************************************************************\
To my lovely code maintainers,
The syntax recognized by the Mastodon frontend for its bio metadata
feature is a subset of that provided by the YAML 1.2 specification.
In particular, Mastodon recognizes metadata which is provided as an
implicit YAML map, where each key-value pair takes up only a single
line (no multi-line values are permitted). To simplify the level of
processing required, Mastodon metadata frontmatter has been limited
to only allow those characters in the `c-printable` set, as defined
by the YAML 1.2 specification, instead of permitting those from the
`nb-json` characters inside double-quoted strings like YAML proper.
¶ It is important to note that Mastodon only borrows the *syntax*
of YAML, not its semantics. This is to say, Mastodon won't make any
attempt to interpret the data it receives. `true` will not become a
boolean; `56` will not be interpreted as a number. Rather, each key
and every value will be read as a string, and as a string they will
remain. The order of the pairs is unchanged, and any duplicate keys
are preserved. However, YAML escape sequences will be replaced with
the proper interpretations according to the YAML 1.2 specification.
¶ The implementation provided below interprets `<br>` as `\n` and
allows for an open <p> tag at the beginning of the bio. It replaces
the escaped character entities `&apos;` and `&quot;` with single or
double quotes, respectively, prior to processing. However, no other
escaped characters are replaced, not even those which might have an
impact on the syntax otherwise. These minor allowances are provided
because the Mastodon backend will insert these things automatically
into a bio before sending it through the API, so it is important we
account for them. Aside from this, the YAML frontmatter must be the
very first thing in the bio, leading with three consecutive hyphen-
minues (`---`), and ending with the same or, alternatively, instead
with three periods (`...`). No limits have been set with respect to
the number of characters permitted in the frontmatter, although one
should note that only limited space is provided for them in the UI.
¶ The regular expression used to check the existence of, and then
process, the YAML frontmatter has been split into a number of small
components in the code below, in the vain hope that it will be much
easier to read and to maintain. I leave it to the future readers of
this code to determine the extent of my successes in this endeavor.
Sending love + warmth eternal,
- kibigo [@kibi@glitch.social]
\*********************************************************************/
/* CONVENIENCE FUNCTIONS */
const unirex = str => new RegExp(str, 'u');
const rexstr = exp => '(?:' + exp.source + ')';
/* CHARACTER CLASSES */
const DOCUMENT_START = /^/;
const DOCUMENT_END = /$/;
const ALLOWED_CHAR = // `c-printable` in the YAML 1.2 spec.
/[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u;
const WHITE_SPACE = /[ \t]/;
const INDENTATION = / */; // Indentation must be only spaces.
const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/;
const ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/;
const HEXADECIMAL_CHARS = /[0-9a-fA-F]/;
const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/;
const FLOW_CHAR = /[,[\]{}]/;
/* NEGATED CHARACTER CLASSES */
const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
const NOT_ALLOWED_CHAR = unirex(
'(?!' + rexstr(ALLOWED_CHAR) + ')[^]'
);
/* BASIC CONSTRUCTS */
const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*');
const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
const NEW_LINE = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
);
const SOME_NEW_LINES = unirex(
'(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
);
const POSSIBLE_STARTS = unirex(
rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
);
const POSSIBLE_ENDS = unirex(
rexstr(SOME_NEW_LINES) + '|' +
rexstr(DOCUMENT_END) + '|' +
rexstr(/<\/p>/)
);
const CHARACTER_ESCAPE = unirex(
rexstr(/\\/) +
'(?:' +
rexstr(ESCAPE_CHAR) + '|' +
rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
')'
);
const ESCAPED_CHAR = unirex(
rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
rexstr(CHARACTER_ESCAPE)
);
const ANY_ESCAPED_CHARS = unirex(
rexstr(ESCAPED_CHAR) + '*'
);
const ESCAPED_APOS = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
);
const ANY_ESCAPED_APOS = unirex(
rexstr(ESCAPED_APOS) + '*'
);
const FIRST_KEY_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')'
);
const FIRST_VALUE_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')'
// Flow indicators are allowed in values.
);
const LATER_KEY_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
const LATER_VALUE_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
// Flow indicators are allowed in values.
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
/* YAML CONSTRUCTS */
const YAML_START = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
);
const YAML_END = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
);
const YAML_LOOKAHEAD = unirex(
'(?=' +
rexstr(YAML_START) +
rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
')'
);
const YAML_DOUBLE_QUOTE = unirex(
rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
);
const YAML_SINGLE_QUOTE = unirex(
rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
);
const YAML_SIMPLE_KEY = unirex(
rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
);
const YAML_SIMPLE_VALUE = unirex(
rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
);
const YAML_KEY = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_KEY)
);
const YAML_VALUE = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_VALUE)
);
const YAML_SEPARATOR = unirex(
rexstr(ANY_WHITE_SPACE) +
':' + rexstr(WHITE_SPACE) +
rexstr(ANY_WHITE_SPACE)
);
const YAML_LINE = unirex(
'(' + rexstr(YAML_KEY) + ')' +
rexstr(YAML_SEPARATOR) +
'(' + rexstr(YAML_VALUE) + ')'
);
/* FRONTMATTER REGEX */
const YAML_FRONTMATTER = unirex(
rexstr(POSSIBLE_STARTS) +
rexstr(YAML_LOOKAHEAD) +
rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
'(?:' +
'(' + rexstr(INDENTATION) + ')' +
rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'(?:' +
'\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'){0,4}' +
')?' +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
);
/* SEARCHES */
const FIND_YAML_LINES = unirex(
rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
);
/* STRING PROCESSING */
function processString(str) {
switch (str.charAt(0)) {
case '"':
return str
.substring(1, str.length - 1)
.replace(/\\0/g, '\x00')
.replace(/\\a/g, '\x07')
.replace(/\\b/g, '\x08')
.replace(/\\t/g, '\x09')
.replace(/\\\x09/g, '\x09')
.replace(/\\n/g, '\x0a')
.replace(/\\v/g, '\x0b')
.replace(/\\f/g, '\x0c')
.replace(/\\r/g, '\x0d')
.replace(/\\e/g, '\x1b')
.replace(/\\ /g, '\x20')
.replace(/\\"/g, '\x22')
.replace(/\\\//g, '\x2f')
.replace(/\\\\/g, '\x5c')
.replace(/\\N/g, '\x85')
.replace(/\\_/g, '\xa0')
.replace(/\\L/g, '\u2028')
.replace(/\\P/g, '\u2029')
.replace(
new RegExp(
unirex(
rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
);
case '\'':
return str
.substring(1, str.length - 1)
.replace(/''/g, '\'');
default:
return str;
}
}
/* BIO PROCESSING */
export function processBio(content) {
content = content.replace(/&quot;/g, '"').replace(/&apos;/g, '\'');
let result = {
text: content,
metadata: [],
};
let yaml = content.match(YAML_FRONTMATTER);
if (!yaml) return result;
else yaml = yaml[0];
let start = content.search(YAML_START);
let end = start + yaml.length - yaml.search(YAML_START);
result.text = content.substr(0, start) + content.substr(end);
let metadata = null;
let query = new RegExp(FIND_YAML_LINES, 'g');
while ((metadata = query.exec(yaml))) {
result.metadata.push([
processString(metadata[1]),
processString(metadata[2]),
]);
}
return result;
}
/* BIO CREATION */
export function createBio(note, data) {
if (!note) note = '';
let frontmatter = '';
if ((data && data.length) || note.match(/^\s*---\s+/)) {
if (!data) frontmatter = '---\n...\n';
else {
frontmatter += '---\n';
for (let i = 0; i < data.length; i++) {
let key = '' + data[i][0];
let val = '' + data[i][1];
// Key processing
if (key === (key.match(YAML_SIMPLE_KEY) || [])[0]) /* do nothing */;
else if (key.indexOf('\'') === -1 && key === (key.match(ANY_ESCAPED_APOS) || [])[0]) key = '\'' + key + '\'';
else {
key = key
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = key.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
key = key.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
key = '"' + key + '"';
}
// Value processing
if (val === (val.match(YAML_SIMPLE_VALUE) || [])[0]) /* do nothing */;
else if (val.indexOf('\'') === -1 && val === (val.match(ANY_ESCAPED_APOS) || [])[0]) val = '\'' + val + '\'';
else {
val = val
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = val.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
val = val.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
val = '"' + val + '"';
}
frontmatter += key + ': ' + val + '\n';
}
frontmatter += '...\n';
}
}
return frontmatter + note;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -24,6 +24,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@@ -73,11 +74,14 @@ export function mentionCompose(account, router) {
export function submitCompose() { export function submitCompose() {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], '')); let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
if (!status || !status.length) { if (!status || !status.length) {
return; return;
} }
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
status = status + ' 👁️';
}
api(getState).post('/api/v1/statuses', { api(getState).post('/api/v1/statuses', {
status, status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
@@ -244,6 +248,13 @@ export function unmountCompose() {
}; };
}; };
export function changeComposeAdvancedOption(option) {
return {
type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
option: option,
};
}
export function changeComposeSensitivity() { export function changeComposeSensitivity() {
return { return {
type: COMPOSE_SENSITIVITY_CHANGE, type: COMPOSE_SENSITIVITY_CHANGE,

View File

@@ -9,8 +9,12 @@ export default class ColumnBackButton extends React.PureComponent {
}; };
handleClick = () => { handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/'); // if history is exhausted, or we would leave mastodon, just go to root.
else this.context.router.history.goBack(); if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
} }
render () { render () {

View File

@@ -9,8 +9,12 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
}; };
handleClick = () => { handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/'); // if history is exhausted, or we would leave mastodon, just go to root.
else this.context.router.history.goBack(); if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
} }
render () { render () {

View File

@@ -45,8 +45,12 @@ export default class ColumnHeader extends React.PureComponent {
} }
handleBackClick = () => { handleBackClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/'); // if history is exhausted, or we would leave mastodon, just go to root.
else this.context.router.history.goBack(); if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
} }
handleTransitionEnd = () => { handleTransitionEnd = () => {

View File

@@ -17,6 +17,7 @@ export default class IconButton extends React.PureComponent {
disabled: PropTypes.bool, disabled: PropTypes.bool,
inverted: PropTypes.bool, inverted: PropTypes.bool,
animate: PropTypes.bool, animate: PropTypes.bool,
flip: PropTypes.bool,
overlay: PropTypes.bool, overlay: PropTypes.bool,
}; };
@@ -69,7 +70,7 @@ export default class IconButton extends React.PureComponent {
} }
return ( return (
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> <Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}>
{({ rotate }) => {({ rotate }) =>
<button <button
aria-label={this.props.title} aria-label={this.props.title}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container'; import StatusContainer from '../../glitch/containers/status';
import LoadMore from './load_more'; import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';

View File

@@ -23,7 +23,13 @@ const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
export const store = configureStore(); export const store = configureStore();
const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent)); const initialState = JSON.parse(document.getElementById('initial-state').textContent);
try {
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch (e) {
initialState.local_settings = {};
}
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction); store.dispatch(hydrateAction);
export default class Mastodon extends React.PureComponent { export default class Mastodon extends React.PureComponent {

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import InnerHeader from '../../account/components/header'; import InnerHeader from '../../../../glitch/components/account/header';
import ActionBar from '../../account/components/action_bar'; import ActionBar from '../../account/components/action_bar';
import MissingIndicator from '../../../components/missing_indicator'; import MissingIndicator from '../../../components/missing_indicator';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@@ -11,6 +11,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable'; import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../../../../glitch/containers/compose/advanced_options';
import SensitiveButtonContainer from '../containers/sensitive_button_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from './emoji_picker_dropdown'; import EmojiPickerDropdown from './emoji_picker_dropdown';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
@@ -35,6 +36,9 @@ export default class ComposeForm extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list, suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool, spoiler: PropTypes.bool,
privacy: PropTypes.string, privacy: PropTypes.string,
advanced_options: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool,
}),
spoiler_text: PropTypes.string, spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date), focusDate: PropTypes.instanceOf(Date),
preselectDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date),
@@ -192,6 +196,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__buttons'> <div className='compose-form__buttons'>
<UploadButtonContainer /> <UploadButtonContainer />
<PrivacyDropdownContainer /> <PrivacyDropdownContainer />
<ComposeAdvancedOptionsContainer />
<SensitiveButtonContainer /> <SensitiveButtonContainer />
<SpoilerButtonContainer /> <SpoilerButtonContainer />
</div> </div>

View File

@@ -15,7 +15,7 @@ export default class NavigationBar extends ImmutablePureComponent {
return ( return (
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<Avatar src={this.props.account.get('avatar')} animate size={40} /> <Avatar src={this.props.account.get('avatar')} staticSrc={this.props.account.get('avatar_static')} size={40} />
</Permalink> </Permalink>
<div className='navigation-bar__profile'> <div className='navigation-bar__profile'>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../../glitch/containers/status';
import Link from 'react-router-dom/Link'; import Link from 'react-router-dom/Link';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@@ -15,6 +15,7 @@ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']), suggestions: state.getIn(['compose', 'suggestions']),
advanced_options: state.getIn(['compose', 'advanced_options']),
spoiler: state.getIn(['compose', 'spoiler']), spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']), spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']), privacy: state.getIn(['compose', 'privacy']),

View File

@@ -5,6 +5,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose'; import { mountCompose, unmountCompose } from '../../actions/compose';
import { openModal } from '../../actions/modal';
import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import Link from 'react-router-dom/Link'; import Link from 'react-router-dom/Link';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container'; import SearchContainer from './containers/search_container';
@@ -18,7 +20,7 @@ const messages = defineMessages({
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
}); });
@@ -47,6 +49,16 @@ export default class Compose extends React.PureComponent {
this.props.dispatch(unmountCompose()); this.props.dispatch(unmountCompose());
} }
onLayoutClick = (e) => {
const layout = e.currentTarget.getAttribute('data-mastodon-layout');
this.props.dispatch(changeLocalSetting(['layout'], layout));
e.preventDefault();
}
openSettings = () => {
this.props.dispatch(openModal('SETTINGS', {}));
}
render () { render () {
const { multiColumn, showSearch, intl } = this.props; const { multiColumn, showSearch, intl } = this.props;
@@ -69,12 +81,14 @@ export default class Compose extends React.PureComponent {
{!columns.some(column => column.get('id') === 'PUBLIC') && ( {!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role='img' aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link> <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role='img' aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link>
)} )}
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role='img' aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a> <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)}><i role='img' aria-label={intl.formatMessage(messages.settings)} className='fa fa-fw fa-cogs' /></a>
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role='img' aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a> <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role='img' aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a>
</div> </div>
); );
} }
return ( return (
<div className='drawer'> <div className='drawer'>
{header} {header}
@@ -95,6 +109,7 @@ export default class Compose extends React.PureComponent {
} }
</Motion> </Motion>
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading'; import ColumnSubheading from '../ui/components/column_subheading';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from '../../actions/modal';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -17,6 +18,7 @@ const messages = defineMessages({
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
@@ -39,8 +41,13 @@ export default class GettingStarted extends ImmutablePureComponent {
me: ImmutablePropTypes.map.isRequired, me: ImmutablePropTypes.map.isRequired,
columns: ImmutablePropTypes.list, columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
}; };
openSettings = () => {
this.props.dispatch(openModal('SETTINGS', {}));
}
render () { render () {
const { intl, me, columns, multiColumn } = this.props; const { intl, me, columns, multiColumn } = this.props;
@@ -79,27 +86,30 @@ export default class GettingStarted extends ImmutablePureComponent {
return ( return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
<div className='getting-started__wrapper'> <div className='scrollable optionally-scrollable'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> <div className='getting-started__wrapper'>
{navItems} <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> {navItems}
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
</div> <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='getting-started__footer scrollable optionally-scrollable'> <div className='getting-started__footer'>
<div className='static-content getting-started'> <div className='static-content getting-started'>
<p> <p>
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
</p> </p>
<p> <p>
<FormattedMessage <FormattedMessage
id='getting_started.open_source_notice' id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }} values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
/> />
</p> </p>
</div>
</div> </div>
</div> </div>
</Column> </Column>

View File

@@ -6,7 +6,7 @@ import Column from '../../components/column';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from '../../../glitch/containers/notification';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';

View File

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar'; import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content'; import StatusContent from '../../../../glitch/components/status/content';
import MediaGallery from '../../../components/media_gallery'; import StatusGallery from '../../../../glitch/components/status/gallery';
import VideoPlayer from '../../../components/video_player'; import StatusVideoPlayer from '../../../../glitch/components/status/video_player';
import AttachmentList from '../../../components/attachment_list'; import AttachmentList from '../../../components/attachment_list';
import Link from 'react-router-dom/Link'; import Link from 'react-router-dom/Link';
import { FormattedDate, FormattedNumber } from 'react-intl'; import { FormattedDate, FormattedNumber } from 'react-intl';
@@ -20,6 +20,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
@@ -36,21 +37,41 @@ export default class DetailedStatus extends ImmutablePureComponent {
render () { render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
const { settings } = this.props;
let media = ''; let media = '';
let mediaIcon = null;
let applicationLink = ''; let applicationLink = '';
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />; media = <AttachmentList media={status.get('media_attachments')} />;
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />; media = (
<StatusVideoPlayer
sensitive={status.get('sensitive')}
media={status.getIn(['media_attachments', 0])}
letterbox={settings.getIn(['media', 'letterbox'])}
height={250}
onOpenVideo={this.props.onOpenVideo}
autoplay
/>
);
mediaIcon = 'video-camera';
} else { } else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; media = (
<StatusGallery
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
letterbox={settings.getIn(['media', 'letterbox'])}
height={250}
onOpenMedia={this.props.onOpenMedia}
autoPlayGif={this.props.autoPlayGif}
/>
);
mediaIcon = 'picture-o';
} }
} else if (status.get('spoiler_text').length === 0) { } else media = <CardContainer statusId={status.get('id')} />;
media = <CardContainer statusId={status.get('id')} />;
}
if (status.get('application')) { if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
@@ -63,9 +84,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>
<StatusContent status={status} /> <StatusContent
status={status}
{media} media={media}
mediaIcon={mediaIcon}
/>
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>

View File

@@ -22,7 +22,7 @@ import { initReport } from '../../actions/reports';
import { makeGetStatus } from '../../selectors'; import { makeGetStatus } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button'; import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container'; import StatusContainer from '../../../glitch/containers/status';
import { openModal } from '../../actions/modal'; import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -37,6 +37,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
status: getStatus(state, Number(props.params.statusId)), status: getStatus(state, Number(props.params.statusId)),
settings: state.get('local_settings'),
ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]), ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]),
descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]), descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['meta', 'me']), me: state.getIn(['meta', 'me']),
@@ -60,6 +61,7 @@ export default class Status extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
settings: ImmutablePropTypes.map.isRequired,
ancestorsIds: ImmutablePropTypes.list, ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list,
me: PropTypes.number, me: PropTypes.number,
@@ -143,7 +145,7 @@ export default class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; const { status, settings, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
if (status === null) { if (status === null) {
return ( return (
@@ -172,6 +174,7 @@ export default class Status extends ImmutablePureComponent {
<DetailedStatus <DetailedStatus
status={status} status={status}
settings={settings}
autoPlayGif={autoPlayGif} autoPlayGif={autoPlayGif}
me={me} me={me}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}

View File

@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from '../../../components/button'; import Button from '../../../components/button';
import StatusContent from '../../../components/status_content'; import StatusContent from '../../../../glitch/components/status/content';
import Avatar from '../../../components/avatar'; import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp'; import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Link from 'react-router-dom/Link'; import Link from 'react-router-dom/Link';
const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { const ColumnLink = ({ icon, text, to, onClick, href, method, hideOnMobile }) => {
if (href) { if (href) {
return ( return (
<a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
@@ -10,13 +10,20 @@ const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => {
{text} {text}
</a> </a>
); );
} else { } else if (to) {
return ( return (
<Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}> <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}>
<i className={`fa fa-fw fa-${icon} column-link__icon`} /> <i className={`fa fa-fw fa-${icon} column-link__icon`} />
{text} {text}
</Link> </Link>
); );
} else {
return (
<a onClick={onClick} role='button' tabIndex='0' className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
<i className={`fa fa-fw fa-${icon} column-link__icon`} />
{text}
</a>
);
} }
}; };
@@ -24,6 +31,7 @@ ColumnLink.propTypes = {
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
to: PropTypes.string, to: PropTypes.string,
onClick: PropTypes.func,
href: PropTypes.string, href: PropTypes.string,
method: PropTypes.string, method: PropTypes.string,
hideOnMobile: PropTypes.bool, hideOnMobile: PropTypes.bool,

View File

@@ -12,6 +12,7 @@ import {
BoostModal, BoostModal,
ConfirmationModal, ConfirmationModal,
ReportModal, ReportModal,
SettingsModal,
} from '../../../features/ui/util/async-components'; } from '../../../features/ui/util/async-components';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
@@ -21,6 +22,7 @@ const MODAL_COMPONENTS = {
'BOOST': BoostModal, 'BOOST': BoostModal,
'CONFIRM': ConfirmationModal, 'CONFIRM': ConfirmationModal,
'REPORT': ReportModal, 'REPORT': ReportModal,
'SETTINGS': SettingsModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

View File

@@ -28,8 +28,8 @@ const PageOne = ({ acct, domain }) => (
</div> </div>
<div> <div>
<h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1>
<p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p>
<p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p>
</div> </div>
</div> </div>
@@ -148,8 +148,8 @@ const PageSix = ({ admin, domain }) => {
<div className='onboarding-modal__page onboarding-modal__page-six'> <div className='onboarding-modal__page onboarding-modal__page-six'>
<h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
{adminSection} {adminSection}
<p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
<p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
<p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
</div> </div>
); );

View File

@@ -39,10 +39,12 @@ import {
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import '../../components/status'; import '../../../glitch/components/status';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
systemFontUi: state.getIn(['meta', 'system_font_ui']), systemFontUi: state.getIn(['meta', 'system_font_ui']),
layout: state.getIn(['local_settings', 'layout']),
isWide: state.getIn(['local_settings', 'stretch']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@@ -51,6 +53,8 @@ export default class UI extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
children: PropTypes.node, children: PropTypes.node,
layout: PropTypes.string,
isWide: PropTypes.bool,
systemFontUi: PropTypes.bool, systemFontUi: PropTypes.bool,
}; };
@@ -148,16 +152,28 @@ export default class UI extends React.PureComponent {
render () { render () {
const { width, draggingOver } = this.state; const { width, draggingOver } = this.state;
const { children } = this.props; const { children, layout, isWide } = this.props;
const className = classNames('ui', { const columnsClass = layout => {
switch (layout) {
case 'single':
return 'single-column';
case 'multiple':
return 'multi-columns';
default:
return 'auto-columns';
}
};
const className = classNames('ui', columnsClass(layout), {
'wide': isWide,
'system-font': this.props.systemFontUi, 'system-font': this.props.systemFontUi,
}); });
return ( return (
<div className={className} ref={this.setRef}> <div className={className} ref={this.setRef}>
<TabsBar /> <TabsBar />
<ColumnsAreaContainer singleColumn={isMobile(width)}> <ColumnsAreaContainer singleColumn={isMobile(width, layout)}>
<WrappedSwitch> <WrappedSwitch>
<Redirect from='/' to='/getting-started' exact /> <Redirect from='/' to='/getting-started' exact />
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />

View File

@@ -1,111 +0,0 @@
export function EmojiPicker () {
return import(/* webpackChunkName: "emojione_picker" */'emojione-picker');
}
export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose');
}
export function Notifications () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
}
export function HomeTimeline () {
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
}
export function PublicTimeline () {
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
}
export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
}
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
}
export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
}
export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
}
export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers');
}
export function Following () {
return import(/* webpackChunkName: "features/following" */'../../following');
}
export function Reblogs () {
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
}
export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
}
export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
}
export function GenericNotFound () {
return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found');
}
export function FavouritedStatuses () {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
}
export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
}
export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
}
export function MediaModal () {
return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal');
}
export function OnboardingModal () {
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
}
export function VideoModal () {
return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal');
}
export function BoostModal () {
return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal');
}
export function ConfirmationModal () {
return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal');
}
export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}
export function MediaGallery () {
return import(/* webpackChunkName: "status/MediaGallery" */'../../../components/media_gallery');
}
export function VideoPlayer () {
return import(/* webpackChunkName: "status/VideoPlayer" */'../../../components/video_player');
}

View File

@@ -1,7 +1,14 @@
const LAYOUT_BREAKPOINT = 1024; const LAYOUT_BREAKPOINT = 1024;
export function isMobile(width) { export function isMobile(width, columns) {
return width <= LAYOUT_BREAKPOINT; switch (columns) {
case 'multiple':
return false;
case 'single':
return true;
default:
return width <= LAYOUT_BREAKPOINT;
}
}; };
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;

View File

@@ -188,6 +188,14 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "Collapse",
"id": "status.collapse"
},
{
"defaultMessage": "Uncollapse",
"id": "status.uncollapse"
},
{ {
"defaultMessage": "{name} boosted", "defaultMessage": "{name} boosted",
"id": "status.reblogged_by" "id": "status.reblogged_by"
@@ -652,12 +660,28 @@
"id": "navigation_bar.community_timeline" "id": "navigation_bar.community_timeline"
}, },
{ {
"defaultMessage": "Preferences", "defaultMessage": "App settings",
"id": "navigation_bar.preferences" "id": "navigation_bar.app_settings"
}, },
{ {
"defaultMessage": "Logout", "defaultMessage": "Logout",
"id": "navigation_bar.logout" "id": "navigation_bar.logout"
},
{
"defaultMessage": "Your current layout is:",
"id": "layout.current_is"
},
{
"defaultMessage": "Mobile",
"id": "layout.mobile"
},
{
"defaultMessage": "Auto",
"id": "layout.auto"
},
{
"defaultMessage": "Desktop",
"id": "layout.desktop"
} }
], ],
"path": "app/javascript/mastodon/features/compose/index.json" "path": "app/javascript/mastodon/features/compose/index.json"
@@ -727,6 +751,10 @@
"defaultMessage": "Preferences", "defaultMessage": "Preferences",
"id": "navigation_bar.preferences" "id": "navigation_bar.preferences"
}, },
{
"defaultMessage": "App settings",
"id": "navigation_bar.app_settings"
},
{ {
"defaultMessage": "Follow requests", "defaultMessage": "Follow requests",
"id": "navigation_bar.follow_requests" "id": "navigation_bar.follow_requests"
@@ -764,7 +792,7 @@
"id": "getting_started.appsshort" "id": "getting_started.appsshort"
}, },
{ {
"defaultMessage": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", "defaultMessage": "Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.",
"id": "getting_started.open_source_notice" "id": "getting_started.open_source_notice"
} }
], ],
@@ -1066,11 +1094,11 @@
"id": "column.public" "id": "column.public"
}, },
{ {
"defaultMessage": "Welcome to Mastodon!", "defaultMessage": "Welcome to {domain}!",
"id": "onboarding.page_one.welcome" "id": "onboarding.page_one.welcome"
}, },
{ {
"defaultMessage": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "defaultMessage": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"id": "onboarding.page_one.federation" "id": "onboarding.page_one.federation"
}, },
{ {
@@ -1118,7 +1146,7 @@
"id": "onboarding.page_six.almost_done" "id": "onboarding.page_six.almost_done"
}, },
{ {
"defaultMessage": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "defaultMessage": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
"id": "onboarding.page_six.github" "id": "onboarding.page_six.github"
}, },
{ {
@@ -1165,6 +1193,79 @@
], ],
"path": "app/javascript/mastodon/features/ui/components/report_modal.json" "path": "app/javascript/mastodon/features/ui/components/report_modal.json"
}, },
{
"descriptors": [
{
"defaultMessage": "General",
"id": "settings.general"
},
{
"defaultMessage": "Wide view (Desktop mode only)",
"id": "settings.wide_view"
},
{
"defaultMessage": "Collapsed toots",
"id": "settings.collapsed_statuses"
},
{
"defaultMessage": "Enable collapsed toots",
"id": "settings.enable_collapsed"
},
{
"defaultMessage": "Automatic collapsing",
"id": "settings.auto_collapse"
},
{
"defaultMessage": "Everything",
"id": "settings.auto_collapse_all"
},
{
"defaultMessage": "Notifications",
"id": "settings.auto_collapse_notifications"
},
{
"defaultMessage": "Lengthy toots",
"id": "settings.auto_collapse_lengthy"
},
{
"defaultMessage": "Replies",
"id": "settings.auto_collapse_replies"
},
{
"defaultMessage": "Toots with media",
"id": "settings.auto_collapse_media"
},
{
"defaultMessage": "Image backgrounds",
"id": "settings.image_backgrounds"
},
{
"defaultMessage": "Give collapsed toots an image background",
"id": "settings.image_backgrounds_users"
},
{
"defaultMessage": "Preview collapsed toot media",
"id": "settings.image_backgrounds_media"
},
{
"defaultMessage": "Media",
"id": "settings.media"
},
{
"defaultMessage": "Letterbox media",
"id": "settings.media_letterbox"
},
{
"defaultMessage": "User preferences",
"id": "settings.preferences"
},
{
"defaultMessage": "Close",
"id": "settings.close"
}
],
"path": "app/javascript/mastodon/features/ui/components/settings_modal.json"
},
{ {
"descriptors": [ "descriptors": [
{ {

View File

@@ -77,7 +77,7 @@
"getting_started.appsshort": "Apps", "getting_started.appsshort": "Apps",
"getting_started.faq": "FAQ", "getting_started.faq": "FAQ",
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.",
"getting_started.userguide": "User Guide", "getting_started.userguide": "User Guide",
"home.column_settings.advanced": "Advanced", "home.column_settings.advanced": "Advanced",
"home.column_settings.basic": "Basic", "home.column_settings.basic": "Basic",
@@ -85,10 +85,15 @@
"home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies", "home.column_settings.show_replies": "Show replies",
"home.settings": "Column settings", "home.settings": "Column settings",
"layout.auto": "Auto",
"layout.current_is": "Your current layout is:",
"layout.desktop": "Desktop",
"layout.mobile": "Mobile",
"lightbox.close": "Close", "lightbox.close": "Close",
"loading_indicator.label": "Loading...", "loading_indicator.label": "Loading...",
"media_gallery.toggle_visible": "Toggle visibility", "media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Not found", "missing_indicator.label": "Not found",
"navigation_bar.app_settings": "App settings",
"navigation_bar.blocks": "Blocked users", "navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline", "navigation_bar.community_timeline": "Local timeline",
"navigation_bar.edit_profile": "Edit profile", "navigation_bar.edit_profile": "Edit profile",
@@ -117,14 +122,14 @@
"onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
"onboarding.page_four.home": "The home timeline shows posts from people you follow.", "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
"onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
"onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
"onboarding.page_one.welcome": "Welcome to Mastodon!", "onboarding.page_one.welcome": "Welcome to {domain}!",
"onboarding.page_six.admin": "Your instance's admin is {admin}.", "onboarding.page_six.admin": "Your instance's admin is {admin}.",
"onboarding.page_six.almost_done": "Almost done...", "onboarding.page_six.almost_done": "Almost done...",
"onboarding.page_six.appetoot": "Bon Appetoot!", "onboarding.page_six.appetoot": "Bon Appetoot!",
"onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
"onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "community guidelines", "onboarding.page_six.guidelines": "community guidelines",
"onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
"onboarding.page_six.various_app": "mobile apps", "onboarding.page_six.various_app": "mobile apps",
@@ -147,7 +152,26 @@
"report.target": "Reporting {target}", "report.target": "Reporting {target}",
"search.placeholder": "Search", "search.placeholder": "Search",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"settings.auto_collapse": "Automatic collapsing",
"settings.auto_collapse_all": "Everything",
"settings.auto_collapse_lengthy": "Lengthy toots",
"settings.auto_collapse_media": "Toots with media",
"settings.auto_collapse_notifications": "Notifications",
"settings.auto_collapse_replies": "Replies",
"settings.close": "Close",
"settings.collapsed_statuses": "Collapsed toots",
"settings.enable_collapsed": "Enable collapsed toots",
"settings.general": "General",
"settings.image_backgrounds": "Image backgrounds",
"settings.image_backgrounds_media": "Preview collapsed toot media",
"settings.image_backgrounds_users": "Give collapsed toots an image background",
"settings.media": "Media",
"settings.media_letterbox": "Letterbox media",
"settings.media_fullwidth": "Full-width media previews",
"settings.preferences": "User preferences",
"settings.wide_view": "Wide view (Desktop mode only)",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.collapse": "Collapse",
"status.delete": "Delete", "status.delete": "Delete",
"status.favourite": "Favourite", "status.favourite": "Favourite",
"status.load_more": "Load more", "status.load_more": "Load more",
@@ -164,6 +188,7 @@
"status.sensitive_warning": "Sensitive content", "status.sensitive_warning": "Sensitive content",
"status.show_less": "Show less", "status.show_less": "Show less",
"status.show_more": "Show more", "status.show_more": "Show more",
"status.uncollapse": "Uncollapse",
"status.unmute_conversation": "Unmute conversation", "status.unmute_conversation": "Unmute conversation",
"tabs_bar.compose": "Compose", "tabs_bar.compose": "Compose",
"tabs_bar.federated_timeline": "Federated", "tabs_bar.federated_timeline": "Federated",

View File

@@ -30,6 +30,11 @@ function main() {
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
perf.stop('main()'); perf.stop('main()');
// remember the initial URL
if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') {
window._mastoInitialHistoryLen = window.history.length;
}
}); });
} }

View File

@@ -16,6 +16,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_ADVANCED_OPTIONS_CHANGE,
COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
@@ -29,6 +30,9 @@ import uuid from '../uuid';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
mounted: false, mounted: false,
advanced_options: ImmutableMap({
do_not_federate: false,
}),
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
@@ -44,6 +48,9 @@ const initialState = ImmutableMap({
suggestion_token: null, suggestion_token: null,
suggestions: ImmutableList(), suggestions: ImmutableList(),
me: null, me: null,
default_advanced_options: ImmutableMap({
do_not_federate: false,
}),
default_privacy: 'public', default_privacy: 'public',
default_sensitive: false, default_sensitive: false,
resetFileKey: Math.floor((Math.random() * 0x10000)), resetFileKey: Math.floor((Math.random() * 0x10000)),
@@ -68,6 +75,7 @@ function clearAll(state) {
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('is_submitting', false); map.set('is_submitting', false);
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('advanced_options', state.get('default_advanced_options'));
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false); map.set('sensitive', false);
map.update('media_attachments', list => list.clear()); map.update('media_attachments', list => list.clear());
@@ -147,6 +155,11 @@ export default function compose(state = initialState, action) {
return state.set('mounted', true); return state.set('mounted', true);
case COMPOSE_UNMOUNT: case COMPOSE_UNMOUNT:
return state.set('mounted', false); return state.set('mounted', false);
case COMPOSE_ADVANCED_OPTIONS_CHANGE:
return state
.set('advanced_options',
state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
.set('idempotencyKey', uuid());
case COMPOSE_SENSITIVITY_CHANGE: case COMPOSE_SENSITIVITY_CHANGE:
return state return state
.set('sensitive', !state.get('sensitive')) .set('sensitive', !state.get('sensitive'))
@@ -174,6 +187,9 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status)); map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('advanced_options', new ImmutableMap({
do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')),
}));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('preselectDate', new Date()); map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
@@ -193,6 +209,7 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('advanced_options', state.get('default_advanced_options'));
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); });
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:

View File

@@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters';
import statuses from './statuses'; import statuses from './statuses';
import relationships from './relationships'; import relationships from './relationships';
import settings from './settings'; import settings from './settings';
import local_settings from '../../glitch/reducers/local_settings';
import status_lists from './status_lists'; import status_lists from './status_lists';
import cards from './cards'; import cards from './cards';
import reports from './reports'; import reports from './reports';
@@ -32,6 +33,7 @@ const reducers = {
statuses, statuses,
relationships, relationships,
settings, settings,
local_settings,
cards, cards,
reports, reports,
contexts, contexts,

View File

@@ -6,6 +6,7 @@ import uuid from '../uuid';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
onboarded: false, onboarded: false,
layout: 'auto',
home: ImmutableMap({ home: ImmutableMap({
shows: ImmutableMap({ shows: ImmutableMap({

View File

@@ -0,0 +1 @@
require('../styles/custom.scss');

View File

@@ -4,6 +4,7 @@ import { delegate } from 'rails-ujs';
import emojify from '../mastodon/emoji'; import emojify from '../mastodon/emoji';
import { getLocale } from '../mastodon/locales'; import { getLocale } from '../mastodon/locales';
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
import { processBio } from '../glitch/util/bio_metadata';
import TimelineContainer from '../mastodon/containers/timeline_container'; import TimelineContainer from '../mastodon/containers/timeline_container';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@@ -101,7 +102,8 @@ function main() {
delegate(document, '.account_note', 'input', ({ target }) => { delegate(document, '.account_note', 'input', ({ target }) => {
const noteCounter = document.querySelector('.note-counter'); const noteCounter = document.querySelector('.note-counter');
if (noteCounter) { if (noteCounter) {
noteCounter.textContent = 160 - length(target.value); const noteWithoutMetadata = processBio(target.value).text;
noteCounter.textContent = 500 - length(noteWithoutMetadata);
} }
}); });
} }

View File

@@ -1,5 +1,5 @@
@mixin avatar-radius() { @mixin avatar-radius() {
border-radius: 4px; border-radius: $ui-avatar-border-size;
background: transparent no-repeat; background: transparent no-repeat;
background-position: 50%; background-position: 50%;
background-clip: padding-box; background-clip: padding-box;
@@ -10,3 +10,33 @@
height: $size; height: $size;
background-size: $size $size; background-size: $size $size;
} }
@mixin single-column($media, $parent: '&') {
.auto-columns #{$parent} {
@media #{$media} {
@content;
}
}
.single-column #{$parent} {
@content;
}
}
@mixin limited-single-column($media, $parent: '&') {
.auto-columns #{$parent}, .single-column #{$parent} {
@media #{$media} {
@content;
}
}
}
@mixin multi-columns($media, $parent: '&') {
.auto-columns #{$parent} {
@media #{$media} {
@content;
}
}
.multi-columns #{$parent} {
@content;
}
}

View File

@@ -168,16 +168,14 @@
text-align: center; text-align: center;
.avatar { .avatar {
width: 80px; @include avatar-size(80px);
height: 80px;
margin: 0 auto; margin: 0 auto;
margin-bottom: 15px; margin-bottom: 15px;
img { img {
@include avatar-radius();
@include avatar-size(80px);
display: block; display: block;
width: 80px;
height: 80px;
border-radius: 48px;
} }
} }

View File

@@ -1,41 +1,46 @@
.card { .card {
display: flex;
background: $ui-base-color; background: $ui-base-color;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
padding: 60px 0;
padding-bottom: 0;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
overflow: hidden; overflow: hidden;
position: relative;
@media screen and (max-width: 700px) { @media screen and (max-width: 700px) {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
} }
&::after { .details {
background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8)); position: relative;
display: block; padding: 60px 0 0;
content: ""; text-align: center;
position: absolute; flex: auto;
left: 0;
top: 0; &::after {
width: 100%; background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8));
height: 100%; display: block;
z-index: 1; content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
}
} }
.name { .name {
display: block; display: block;
position: relative;
font-size: 20px; font-size: 20px;
line-height: 18px * 1.5; line-height: 18px * 1.5;
color: $primary-text-color; color: $primary-text-color;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
position: relative;
z-index: 2;
text-shadow: 0 0 2px $base-shadow-color; text-shadow: 0 0 2px $base-shadow-color;
z-index: 2;
small { small {
display: block; display: block;
@@ -46,17 +51,16 @@
} }
.avatar { .avatar {
width: 120px; position: relative;
@include avatar-size(120px);
margin: 0 auto; margin: 0 auto;
margin-bottom: 15px; margin-bottom: 15px;
position: relative;
z-index: 2; z-index: 2;
img { img {
width: 120px; @include avatar-radius();
height: 120px; @include avatar-size(120px);
display: block; display: block;
border-radius: 120px;
} }
} }
@@ -67,59 +71,38 @@
z-index: 2; z-index: 2;
} }
.details {
display: flex;
margin-top: 30px;
position: relative;
z-index: 2;
flex-direction: row;
}
.details-counters { .details-counters {
display: flex; display: inline-flex;
position: relative;
flex-direction: row; flex-direction: row;
order: 0; margin: 15px 0;
z-index: 2;
} }
.counter { .counter {
width: 80px; width: 80px;
color: $ui-primary-color; color: $ui-primary-color;
padding: 5px 10px 0; padding: 5px 10px 0;
margin-bottom: 10px;
border-right: 1px solid $ui-primary-color;
cursor: default; cursor: default;
position: relative; position: relative;
& + .counter {
border-left: 1px solid $ui-primary-color;
}
& > * {
opacity: .7;
transition: opacity .3s ease;
}
&.active > *, &:hover > * {
opacity: 1;
}
a { a {
display: block; display: block;
} }
&::after {
display: block;
content: "";
position: absolute;
bottom: -10px;
left: 0;
width: 100%;
border-bottom: 4px solid $ui-primary-color;
opacity: 0.5;
transition: all 0.8s ease;
}
&.active {
&::after {
border-bottom: 4px solid $ui-highlight-color;
opacity: 1;
}
}
&:hover {
&::after {
opacity: 1;
transition-duration: 0.2s;
}
}
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
@@ -141,30 +124,73 @@
} }
.bio { .bio {
flex: 1; position: relative;
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
margin: 15px 0;
padding: 5px 10px; padding: 5px 10px;
color: $ui-secondary-color; color: $ui-secondary-color;
order: 1; z-index: 2;
} }
@media screen and (max-width: 480px) { .metadata {
.details { position: relative;
display: block; min-width: 180px;
} max-width: 40%;
background: rgba($base-shadow-color, 0.8);
color: $primary-text-color;
text-align: left;
overflow-y: auto;
white-space: pre-wrap;
z-index: 3;
.bio { .metadata-item {
text-align: center; border-bottom: 1px $ui-primary-color solid;
margin-bottom: 20px; padding: 15px 10px;
} font-size: 18px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
.counter { a {
flex: 1 1 auto; color: $ui-highlight-color;
} text-decoration: none;
.counter:last-child { &:hover {
border-right: none; text-decoration: underline;
}
}
b {
display: block;
font-size: 12px;
line-height: 16px;
text-transform: uppercase;
color: $ui-primary-color;
a {
color: $ui-primary-color;
}
}
}
}
}
@media screen and (max-width: 500px) {
.card {
display: block;
.metadata {
max-width: none;
background: $base-shadow-color;
border-top: 1px $ui-primary-color solid;
.metadata-item {
padding: 15px 20px;
}
} }
} }
} }
@@ -283,16 +309,14 @@
} }
.avatar { .avatar {
width: 60px; @include avatar-size(60px);
height: 60px;
float: left; float: left;
margin-right: 15px; margin-right: 15px;
img { img {
@include avatar-radius();
@include avatar-size(60px);
display: block; display: block;
width: 60px;
height: 60px;
border-radius: 60px;
} }
} }
@@ -359,15 +383,14 @@
} }
& > div { & > div {
@include avatar-size(48px);
float: left; float: left;
margin-right: 10px; margin-right: 10px;
width: 48px;
height: 48px;
} }
.avatar { .avatar {
@include avatar-radius();
display: block; display: block;
border-radius: 4px;
} }
.display-name { .display-name {

File diff suppressed because one or more lines are too long

View File

@@ -451,13 +451,87 @@
cursor: pointer; cursor: pointer;
} }
// --- Extra clickable area in the status gutter ---
.ui.wide {
@mixin xtraspaces-full {
height: calc(100% + 10px);
bottom: -40px;
}
@mixin xtraspaces-short {
height: calc(100% - 35px);
bottom: 0;
}
// Avi must go on top if the toot is too short
.status__avatar {
z-index: 10;
}
// Base styles
.status__content--with-action > div::after {
content: '';
display: block;
width: 64px;
position: absolute;
left: -68px;
// more than 4 never fit on FullHD, short
@include xtraspaces-short;
}
@media screen and (min-width: 1800px) {
// 4, very wide screen
.column:nth-child(2):nth-last-child(4) {
&, & ~ .column {
.status__content--with-action > div::after {
@include xtraspaces-full;
}
}
}
}
// 1 or 2, always fit
.column:nth-child(2):nth-last-child(1),
.column:nth-child(2):nth-last-child(2),
.column:nth-child(2):nth-last-child(3) {
&, & ~ .column {
.status__content--with-action > div::after {
@include xtraspaces-full;
}
}
}
@media screen and (max-width: 1440px) {
// 3, small screen
.column:nth-child(2):nth-last-child(3) {
&, & ~ .column {
.status__content--with-action > div::after {
@include xtraspaces-short;
}
}
}
}
// Phone or iPad
@media screen and (max-width: 1060px) {
.status__content--with-action > div::after {
display: none;
}
}
// I am very sorry
}
// --- end extra clickable spaces ---
.status__content, .status__content,
.reply-indicator__content { .reply-indicator__content {
position: relative;
font-size: 15px; font-size: 15px;
line-height: 20px; line-height: 20px;
color: $primary-text-color;
word-wrap: break-word; word-wrap: break-word;
font-weight: 400; font-weight: 400;
overflow: hidden; overflow: visible;
white-space: pre-wrap; white-space: pre-wrap;
.emojione { .emojione {
@@ -500,19 +574,10 @@
} }
} }
.status__content__spoiler-link { .status__content__spoiler {
background: lighten($ui-base-color, 30%);
&:hover {
background: lighten($ui-base-color, 33%);
text-decoration: none;
}
}
.status__content__text {
display: none; display: none;
&.status__content__text--visible { &.status__content__spoiler--visible {
display: block; display: block;
} }
} }
@@ -521,15 +586,30 @@
.status__content__spoiler-link { .status__content__spoiler-link {
display: inline-block; display: inline-block;
border-radius: 2px; border-radius: 2px;
background: transparent; background: lighten($ui-base-color, 30%);
border: 0; border: none;
color: lighten($ui-base-color, 8%); color: lighten($ui-base-color, 8%);
font-weight: 500; font-weight: 500;
font-size: 11px; font-size: 11px;
padding: 0 6px; padding: 0 5px;
text-transform: uppercase; text-transform: uppercase;
line-height: inherit; line-height: inherit;
cursor: pointer; cursor: pointer;
vertical-align: bottom;
&:hover {
background: lighten($ui-base-color, 33%);
text-decoration: none;
}
.status__content__spoiler-icon {
display: inline-block;
margin: 0 0 0 5px;
border-left: 1px solid currentColor;
padding: 0 0 0 4px;
font-size: 16px;
vertical-align: -2px;
}
} }
.status__prepend-icon-wrapper { .status__prepend-icon-wrapper {
@@ -541,6 +621,7 @@
padding: 8px 10px; padding: 8px 10px;
padding-left: 68px; padding-left: 68px;
position: relative; position: relative;
height: auto;
min-height: 48px; min-height: 48px;
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: default; cursor: default;
@@ -597,6 +678,41 @@
} }
} }
} }
&.collapsed {
background-position: center;
background-size: cover;
user-select: none;
&.has-background::before {
display: block;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));
content: "";
}
.status__display-name:hover strong {
text-decoration: none;
}
.status__content {
height: 20px;
overflow: hidden;
text-overflow: ellipsis;
a:hover {
text-decoration: none;
}
}
}
.notification__message {
margin: -10px 0 10px;
}
} }
.notification-favourite { .notification-favourite {
@@ -610,9 +726,16 @@
} }
.status__relative-time { .status__relative-time {
display: inline-block;
margin-left: auto;
padding-left: 18px;
width: 120px;
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
float: right;
font-size: 14px; font-size: 14px;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.status__display-name { .status__display-name {
@@ -626,7 +749,20 @@
} }
.status__info { .status__info {
margin: 2px 0 0;
font-size: 15px; font-size: 15px;
line-height: 24px;
}
.status__info__icons {
display: inline-block;
position: relative;
float: right;
color: lighten($ui-base-color, 26%);
}
.status__visibility-icon {
padding-left: 6px;
} }
.status-check-box { .status-check-box {
@@ -651,10 +787,9 @@
} }
.status__prepend { .status__prepend {
margin-left: 68px; margin: -10px 0 10px;
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
padding: 8px 0; padding: 8px 0 2px;
padding-bottom: 2px;
font-size: 14px; font-size: 14px;
position: relative; position: relative;
@@ -667,17 +802,43 @@
align-items: center; align-items: center;
display: flex; display: flex;
margin-top: 10px; margin-top: 10px;
margin-left: -58px;
&::before {
display: block;
flex: 1 1 0;
max-width: 58px;
content: "";
}
} }
.status__action-bar-button { .status__action-bar-button {
float: left; float: left;
margin-right: 18px; margin-right: 18px;
flex: 0 0 auto;
} }
.status__action-bar-dropdown { .status__action-bar-dropdown {
float: left; float: left;
height: 18px; height: 18px;
width: 18px; width: 18px;
// Dropdown style override for centering on the icon
.dropdown--active {
position: relative;
.dropdown__content.dropdown__right {
left: calc(50% + 3px);
right: initial;
transform: translate(-50%, 0);
top: 22px;
}
&::after {
right: 1px;
bottom: -2px;
}
}
} }
.detailed-status__action-bar-dropdown { .detailed-status__action-bar-dropdown {
@@ -821,9 +982,12 @@
padding: 10px; padding: 10px;
} }
.account__header { .account__header__wrapper {
flex: 0 0 auto; flex: 0 0 auto;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
}
.account__header {
text-align: center; text-align: center;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@@ -888,6 +1052,59 @@
} }
} }
.account__metadata {
width: 100%;
font-size: 15px;
line-height: 20px;
overflow: hidden;
border-collapse: collapse;
a {
text-decoration: none;
&:hover{
text-decoration: underline;
}
}
tr {
border-top: 1px solid lighten($ui-base-color, 8%);
}
th, td {
padding: 14px 20px;
vertical-align: middle;
& > div {
max-height: 40px;
overflow-y: auto;
white-space: pre-wrap;
text-overflow: ellipsis;
}
}
th {
color: $ui-primary-color;
background: lighten($ui-base-color, 13%);
font-variant: small-caps;
max-width: 120px;
a {
color: $primary-text-color;
}
}
td {
flex: auto;
color: $primary-text-color;
background: $ui-base-color;
a {
color: $ui-highlight-color;
}
}
}
.account__action-bar { .account__action-bar {
border-top: 1px solid lighten($ui-base-color, 8%); border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -949,12 +1166,11 @@
} }
.account__header__avatar { .account__header__avatar {
background-size: 90px 90px; @include avatar-radius();
@include avatar-size(90px);
display: block; display: block;
height: 90px;
margin: 0 auto 10px; margin: 0 auto 10px;
overflow: hidden; overflow: hidden;
width: 90px;
} }
.account-authorize { .account-authorize {
@@ -986,12 +1202,6 @@
strong { strong {
color: $primary-text-color; color: $primary-text-color;
} }
&.muted {
.emojione {
opacity: 0.5;
}
}
} }
.status__display-name, .status__display-name,
@@ -1036,10 +1246,9 @@
} }
.status__avatar { .status__avatar {
height: 48px;
left: 10px;
position: absolute; position: absolute;
top: 10px; margin-left: -58px;
height: 48px;
width: 48px; width: 48px;
} }
@@ -1053,7 +1262,7 @@
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
} }
.status__avatar { .status__avatar, .emojione {
opacity: 0.5; opacity: 0.5;
} }
@@ -1108,6 +1317,7 @@
.display-name { .display-name {
display: block; display: block;
position: relative;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1290,11 +1500,12 @@
justify-content: flex-start; justify-content: flex-start;
overflow-x: auto; overflow-x: auto;
position: relative; position: relative;
padding: 10px;
} }
@media screen and (min-width: 360px) { @include limited-single-column('screen and (max-width: 360px)', $parent: null) {
.columns-area { .columns-area {
padding: 10px; padding: 0;
} }
.react-swipeable-view-container .columns-area { .react-swipeable-view-container .columns-area {
@@ -1321,6 +1532,13 @@
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
.wide & {
flex: auto;
min-width: 330px;
max-width: 400px;
}
> .scrollable { > .scrollable {
background: $ui-base-color; background: $ui-base-color;
@@ -1341,7 +1559,13 @@
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: auto;
.wide & {
flex: 1 1 200px;
min-width: 300px;
max-width: 400px;
}
} }
.drawer__tab { .drawer__tab {
@@ -1353,53 +1577,56 @@
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
outline: none;
cursor: pointer;
} }
.column, .column,
.drawer { .drawer {
flex: 1 1 100%;
overflow: hidden;
@supports(display: grid) { // hack to fix Chrome <57 @supports(display: grid) { // hack to fix Chrome <57
contain: strict; contain: strict;
} }
} }
@media screen and (min-width: 360px) { @include limited-single-column('screen and (max-width: 360px)', $parent: null) {
.tabs-bar { .tabs-bar {
margin: 10px; margin: 0;
margin-bottom: 0;
} }
.search { .search {
margin-bottom: 10px; margin-bottom: 0;
} }
} }
@media screen and (max-width: 1024px) { :root { // Overrides .wide stylings for mobile view
.column, @include single-column('screen and (max-width: 1024px)', $parent: null) {
.drawer { .column,
width: 100%; .drawer {
padding: 0; flex: auto;
} width: 100%;
min-width: 0;
max-width: none;
padding: 0;
}
.columns-area { .columns-area {
flex-direction: column; flex-direction: column;
} }
.search__input, .search__input,
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {
font-size: 16px; font-size: 16px;
}
} }
} }
@media screen and (min-width: 1025px) { @include multi-columns('screen and (min-width: 1025px)', $parent: null) {
.columns-area { .columns-area {
padding: 0; padding: 0;
} }
.column, .column,
.drawer { .drawer {
flex: 0 0 auto;
padding: 10px; padding: 10px;
padding-left: 5px; padding-left: 5px;
padding-right: 5px; padding-right: 5px;
@@ -1425,28 +1652,25 @@
.drawer__pager { .drawer__pager {
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
flex-grow: 1; flex: 1 1 auto;
position: relative; position: relative;
overflow: hidden;
display: flex;
} }
.drawer__inner { .drawer__inner {
position: absolute;
top: 0;
left: 0;
background: lighten($ui-base-color, 13%); background: lighten($ui-base-color, 13%);
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
display: flex; position: absolute;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
width: 100%;
height: 100%; height: 100%;
width: 100%;
&.darker { &.darker {
position: absolute;
top: 0;
left: 0;
background: $ui-base-color; background: $ui-base-color;
width: 100%;
height: 100%;
} }
} }
@@ -1479,6 +1703,8 @@
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
flex: 0 0 auto; flex: 0 0 auto;
overflow-y: auto; overflow-y: auto;
margin: 10px;
margin-bottom: 0;
} }
.tabs-bar__link { .tabs-bar__link {
@@ -1506,7 +1732,7 @@
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
@media screen and (min-width: 1025px) { @include multi-columns('screen and (min-width: 1025px)') {
background: lighten($ui-base-color, 14%); background: lighten($ui-base-color, 14%);
transition: all 100ms linear; transition: all 100ms linear;
} }
@@ -1518,7 +1744,7 @@
} }
} }
@media screen and (min-width: 600px) { @include limited-single-column('screen and (max-width: 600px)', $parent: null) {
.tabs-bar__link { .tabs-bar__link {
span { span {
display: inline; display: inline;
@@ -1526,7 +1752,7 @@
} }
} }
@media screen and (min-width: 1025px) { @include multi-columns('screen and (min-width: 1025px)', $parent: null) {
.tabs-bar { .tabs-bar {
display: none; display: none;
} }
@@ -1709,13 +1935,15 @@
font-size: 16px; font-size: 16px;
padding: 15px; padding: 15px;
text-decoration: none; text-decoration: none;
cursor: pointer;
outline: none;
&:hover { &:hover {
background: lighten($ui-base-color, 11%); background: lighten($ui-base-color, 11%);
} }
&.hidden-on-mobile { &.hidden-on-mobile {
@media screen and (max-width: 1024px) { @include single-column('screen and (max-width: 1024px)') {
display: none; display: none;
} }
} }
@@ -1759,7 +1987,7 @@
outline: 0; outline: 0;
} }
@media screen and (max-width: 600px) { @include limited-single-column('screen and (max-width: 600px)') {
font-size: 16px; font-size: 16px;
} }
} }
@@ -1776,7 +2004,7 @@
padding-right: 10px + 22px; padding-right: 10px + 22px;
resize: none; resize: none;
@media screen and (max-width: 600px) { @include limited-single-column('screen and (max-width: 600px)') {
height: 100px !important; // prevent auto-resize textarea height: 100px !important; // prevent auto-resize textarea
resize: vertical; resize: vertical;
} }
@@ -1889,7 +2117,7 @@
border-bottom-color: $ui-highlight-color; border-bottom-color: $ui-highlight-color;
} }
@media screen and (max-width: 600px) { @include limited-single-column('screen and (max-width: 600px)') {
font-size: 16px; font-size: 16px;
} }
@@ -2103,7 +2331,7 @@ button.icon-button.active i.fa-retweet {
} }
&.hidden-on-mobile { &.hidden-on-mobile {
@media screen and (max-width: 1024px) { @include single-column('screen and (max-width: 1024px)') {
display: none; display: none;
} }
} }
@@ -2239,6 +2467,15 @@ button.icon-button.active i.fa-retweet {
position: relative; position: relative;
text-align: center; text-align: center;
z-index: 100; z-index: 100;
.status__content > & {
margin-top: 15px; // Add margin when used bare for NSFW video player
}
&.full-width {
margin-left: -68px;
width: calc(100% + 80px);
}
} }
.media-spoiler__warning { .media-spoiler__warning {
@@ -2803,8 +3040,82 @@ button.icon-button.active i.fa-retweet {
} }
} }
.advanced-options-dropdown {
position: relative;
}
.advanced-options-dropdown__dropdown {
display: none;
position: absolute;
left: 0;
top: 27px;
width: 210px;
background: $simple-background-color;
border-radius: 0 4px 4px;
z-index: 2;
overflow: hidden;
}
.advanced-options-dropdown__option {
color: $ui-base-color;
padding: 10px;
cursor: pointer;
display: flex;
&:hover,
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
.advanced-options-dropdown__option__content {
color: $primary-text-color;
strong {
color: $primary-text-color;
}
}
}
&.active:hover {
background: lighten($ui-highlight-color, 4%);
}
}
.advanced-options-dropdown__option__toggle {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.advanced-options-dropdown__option__content {
flex: 1 1 auto;
color: darken($ui-primary-color, 24%);
strong {
font-weight: 500;
display: block;
color: $ui-base-color;
}
}
.advanced-options-dropdown.open {
.advanced-options-dropdown__value {
background: $simple-background-color;
border-radius: 4px 4px 0 0;
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
}
.advanced-options-dropdown__dropdown {
display: block;
box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
}
}
.search { .search {
position: relative; position: relative;
margin-bottom: 10px;
} }
.search__input { .search__input {
@@ -2837,7 +3148,7 @@ button.icon-button.active i.fa-retweet {
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
} }
@media screen and (max-width: 600px) { @include limited-single-column('screen and (max-width: 600px)') {
font-size: 16px; font-size: 16px;
} }
} }
@@ -2897,6 +3208,10 @@ button.icon-button.active i.fa-retweet {
font-weight: 500; font-weight: 500;
} }
.search-results__section {
background: $ui-base-color;
}
.search-results__hashtag { .search-results__hashtag {
display: block; display: block;
padding: 10px; padding: 10px;
@@ -3290,6 +3605,89 @@ button.icon-button.active i.fa-retweet {
margin-bottom: 20px; margin-bottom: 20px;
} }
.settings-modal {
position: relative;
display: flex;
flex-direction: row;
background: $ui-secondary-color;
color: $ui-base-color;
border-radius: 8px;
height: 80vh;
width: 80vw;
max-width: 740px;
max-height: 450px;
overflow: hidden;
label {
display: block;
}
h1 {
font-size: 18px;
font-weight: 500;
line-height: 24px;
margin-bottom: 20px;
}
h2 {
font-size: 15px;
font-weight: 500;
line-height: 20px;
margin-top: 20px;
margin-bottom: 10px;
}
}
.settings-modal__navigation {
background: $primary-text-color;
color: $ui-base-color;
width: 200px;
font-size: 15px;
line-height: 20px;
overflow-y: auto;
.settings-modal__navigation-item, .settings-modal__navigation-close {
display: block;
padding: 15px 20px;
cursor: pointer;
outline: none;
text-decoration: none;
}
.settings-modal__navigation-item {
background: $primary-text-color;
color: inherit;
border-bottom: 1px $ui-primary-color solid;
transition: background .3s;
&:hover {
background: $ui-secondary-color;
}
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
}
}
.settings-modal__navigation-close {
background: $error-value-color;
color: $primary-text-color;
}
}
.settings-modal__content {
display: block;
flex: auto;
padding: 15px 20px 15px 20px;
width: 360px;
overflow-y: auto;
select {
margin-bottom: 5px;
}
}
.onboard-sliders { .onboard-sliders {
display: inline-block; display: inline-block;
max-width: 30px; max-width: 30px;
@@ -3508,10 +3906,21 @@ button.icon-button.active i.fa-retweet {
/* Media Gallery */ /* Media Gallery */
.media-gallery { .media-gallery {
box-sizing: border-box; box-sizing: border-box;
margin-top: 8px; margin-top: 15px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: $base-shadow-color;
width: 100%; width: 100%;
&.full-width {
margin-left: -68px;
width: calc(100% + 80px);
}
.detailed-status & {
margin-left:-10px;
width: calc(100% + 22px);
}
} }
.media-gallery__item { .media-gallery__item {
@@ -3526,13 +3935,16 @@ button.icon-button.active i.fa-retweet {
cursor: zoom-in; cursor: zoom-in;
display: block; display: block;
text-decoration: none; text-decoration: none;
width: 100%;
height: 100%; height: 100%;
&,
img { img {
width: 100%; width: 100%;
height: 100%;
object-fit: cover; &:not(.letterbox) {
height: 100%;
object-fit: cover;
}
} }
} }
@@ -3546,12 +3958,13 @@ button.icon-button.active i.fa-retweet {
.media-gallery__item-gifv-thumbnail { .media-gallery__item-gifv-thumbnail {
cursor: zoom-in; cursor: zoom-in;
height: 100%; height: 100%;
object-fit: cover;
position: relative; position: relative;
top: 50%;
transform: translateY(-50%);
width: 100%;
z-index: 1; z-index: 1;
&:not(.letterbox) {
height: 100%;
object-fit: cover;
}
} }
.media-gallery__item-thumbnail-label { .media-gallery__item-thumbnail-label {
@@ -3564,22 +3977,31 @@ button.icon-button.active i.fa-retweet {
/* Status Video Player */ /* Status Video Player */
.status__video-player { .status__video-player {
background: $base-overlay-background; display: flex;
align-items: center;
background: $base-shadow-color;
box-sizing: border-box; box-sizing: border-box;
cursor: default; /* May not be needed */ cursor: default; /* May not be needed */
margin-top: 8px; margin-top: 15px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: 100%;
&.full-width {
margin-left: -68px;
width: calc(100% + 80px);
}
} }
.status__video-player-video { .status__video-player-video {
height: 100%;
object-fit: cover;
position: relative; position: relative;
top: 50%;
transform: translateY(-50%);
width: 100%; width: 100%;
z-index: 1; z-index: 1;
&:not(.letterbox) {
height: 100%;
object-fit: cover;
}
} }
.status__video-player-expand, .status__video-player-expand,
@@ -3620,8 +4042,14 @@ button.icon-button.active i.fa-retweet {
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
cursor: pointer; cursor: pointer;
margin-top: 8px; margin-top: 15px;
position: relative; position: relative;
width: 100%;
&.full-width {
margin-left: -68px;
width: calc(100% + 80px);
}
} }
.media-spoiler-video-play-icon { .media-spoiler-video-play-icon {

View File

@@ -0,0 +1 @@
@import 'application';

View File

@@ -64,19 +64,16 @@
.status__avatar { .status__avatar {
position: absolute; position: absolute;
left: 14px; @include avatar-size(48px);
top: 14px; margin-left: -62px;
width: 48px;
height: 48px;
& > div { & > div {
width: 48px; @include avatar-size(48px);
height: 48px;
} }
img { img {
@include avatar-radius();
display: block; display: block;
border-radius: 4px;
} }
} }
@@ -164,12 +161,11 @@
} }
.avatar { .avatar {
width: 48px; @include avatar-size(48px);
height: 48px;
img { img {
@include avatar-radius();
display: block; display: block;
border-radius: 4px;
} }
} }

View File

@@ -26,3 +26,6 @@ $ui-base-color: $classic-base-color !default; // Darkest
$ui-primary-color: $classic-primary-color !default; // Lighter $ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default; // Vibrant $ui-highlight-color: $classic-highlight-color !default; // Vibrant
// Avatar border size (8% default, 100% for rounded avatars)
$ui-avatar-border-size: 8%;

View File

@@ -93,6 +93,12 @@ class FeedManager
end end
def filter_from_home?(status, receiver_id) def filter_from_home?(status, receiver_id)
# extremely violent filtering code BEGIN
#filter_string = 'e'
#reggie = Regexp.new(filter_string)
#return true if reggie === status.content || reggie === status.spoiler_text
# extremely violent filtering code END
return true if status.reply? && status.in_reply_to_id.nil? return true if status.reply? && status.in_reply_to_id.nil?
check_for_mutes = [status.account_id] check_for_mutes = [status.account_id]

View File

@@ -0,0 +1,244 @@
# frozen_string_literal: true
require 'singleton'
# See also `app/javascript/features/account/util/bio_metadata.js`.
class FrontmatterHandler
include Singleton
# CONVENIENCE FUNCTIONS #
def self.unirex(str)
Regexp.new str, Regexp::MULTILINE, 'u'
end
def self.rexstr(exp)
'(?:' + exp.source + ')'
end
# CHARACTER CLASSES #
DOCUMENT_START = /^/
DOCUMENT_END = /$/
ALLOWED_CHAR = # c-printable` in the YAML 1.2 spec.
/[\t\n\r\u{20}-\u{7e}\u{85}\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u
WHITE_SPACE = /[ \t]/
INDENTATION = / */
LINE_BREAK = /\r?\n|\r|<br\s*\/?>/
ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/
HEXADECIMAL_CHARS = /[0-9a-fA-F]/
INDICATOR = /[-?:,\[\]{}&#*!|>'"%@`]/
FLOW_CHAR = /[,\[\]{}]/
# NEGATED CHARACTER CLASSES #
NOT_WHITE_SPACE = unirex '(?!' + rexstr(WHITE_SPACE) + ').'
NOT_LINE_BREAK = unirex '(?!' + rexstr(LINE_BREAK) + ').'
NOT_INDICATOR = unirex '(?!' + rexstr(INDICATOR) + ').'
NOT_FLOW_CHAR = unirex '(?!' + rexstr(FLOW_CHAR) + ').'
NOT_ALLOWED_CHAR = unirex '(?!' + rexstr(ALLOWED_CHAR) + ').'
# BASIC CONSTRUCTS #
ANY_WHITE_SPACE = unirex rexstr(WHITE_SPACE) + '*'
ANY_ALLOWED_CHARS = unirex rexstr(ALLOWED_CHAR) + '*'
NEW_LINE = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
)
SOME_NEW_LINES = unirex(
'(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
)
POSSIBLE_STARTS = unirex(
rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
)
POSSIBLE_ENDS = unirex(
rexstr(SOME_NEW_LINES) + '|' +
rexstr(DOCUMENT_END) + '|' +
rexstr(/<\/p>/)
)
CHARACTER_ESCAPE = unirex(
rexstr(/\\/) +
'(?:' +
rexstr(ESCAPE_CHAR) + '|' +
rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
')'
)
ESCAPED_CHAR = unirex(
rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
rexstr(CHARACTER_ESCAPE)
)
ANY_ESCAPED_CHARS = unirex(
rexstr(ESCAPED_CHAR) + '*'
)
ESCAPED_APOS = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
)
ANY_ESCAPED_APOS = unirex(
rexstr(ESCAPED_APOS) + '*'
)
FIRST_KEY_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')'
)
FIRST_VALUE_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')'
# Flow indicators are allowed in values.
)
LATER_KEY_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
)
LATER_VALUE_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
# Flow indicators are allowed in values.
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
)
# YAML CONSTRUCTS #
YAML_START = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
)
YAML_END = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
)
YAML_LOOKAHEAD = unirex(
'(?=' +
rexstr(YAML_START) +
rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
')'
)
YAML_DOUBLE_QUOTE = unirex(
rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
)
YAML_SINGLE_QUOTE = unirex(
rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
)
YAML_SIMPLE_KEY = unirex(
rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
)
YAML_SIMPLE_VALUE = unirex(
rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
)
YAML_KEY = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_KEY)
)
YAML_VALUE = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_VALUE)
)
YAML_SEPARATOR = unirex(
rexstr(ANY_WHITE_SPACE) +
':' + rexstr(WHITE_SPACE) +
rexstr(ANY_WHITE_SPACE)
)
YAML_LINE = unirex(
'(' + rexstr(YAML_KEY) + ')' +
rexstr(YAML_SEPARATOR) +
'(' + rexstr(YAML_VALUE) + ')'
)
# FRONTMATTER REGEX #
YAML_FRONTMATTER = unirex(
rexstr(POSSIBLE_STARTS) +
rexstr(YAML_LOOKAHEAD) +
rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
'(?:' +
'(' + rexstr(INDENTATION) + ')' +
rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'(?:' +
'\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'){0,4}' +
')?' +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
)
# SEARCHES #
FIND_YAML_LINES = unirex(
rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
)
# STRING PROCESSING #
def process_string(str)
case str[0]
when '"'
str[1..-2]
.gsub(/\\0/, "\u{00}")
.gsub(/\\a/, "\u{07}")
.gsub(/\\b/, "\u{08}")
.gsub(/\\t/, "\u{09}")
.gsub(/\\\u{09}/, "\u{09}")
.gsub(/\\n/, "\u{0a}")
.gsub(/\\v/, "\u{0b}")
.gsub(/\\f/, "\u{0c}")
.gsub(/\\r/, "\u{0d}")
.gsub(/\\e/, "\u{1b}")
.gsub(/\\ /, "\u{20}")
.gsub(/\\"/, "\u{22}")
.gsub(/\\\//, "\u{2f}")
.gsub(/\\\\/, "\u{5c}")
.gsub(/\\N/, "\u{85}")
.gsub(/\\_/, "\u{a0}")
.gsub(/\\L/, "\u{2028}")
.gsub(/\\P/, "\u{2029}")
.gsub(/\\x([0-9a-fA-F]{2})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
.gsub(/\\u([0-9a-fA-F]{4})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
.gsub(/\\U([0-9a-fA-F]{8})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
when "'"
str[1..-2].gsub(/''/, "'")
else
str
end
end
# BIO PROCESSING #
def process_bio content
result = {
text: content.gsub(/&quot;/, '"').gsub(/&apos;/, "'"),
metadata: []
}
yaml = YAML_FRONTMATTER.match(result[:text])
return result unless yaml
yaml = yaml[0]
start = YAML_START =~ result[:text]
ending = start + yaml.length - (YAML_START =~ yaml)
result[:text][start..ending - 1] = ''
metadata = nil
index = 0
while metadata = FIND_YAML_LINES.match(yaml, index) do
index = metadata.end(0)
result[:metadata].push [
process_string(metadata[1]), process_string(metadata[2])
]
end
return result
end
end

View File

@@ -60,7 +60,7 @@ class Account < ApplicationRecord
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? } validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? } validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
# Timelines # Timelines
has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -251,6 +251,22 @@ class Account < ApplicationRecord
self.public_key = keypair.public_key.to_pem self.public_key = keypair.public_key.to_pem
end end
YAML_START = "---\r\n"
YAML_END = "\r\n...\r\n"
def note_length_does_not_exceed_length_limit
note_without_metadata = note
if note.start_with? YAML_START
idx = note.index YAML_END
unless idx.nil?
note_without_metadata = note[(idx + YAML_END.length) .. -1]
end
end
if note_without_metadata.mb_chars.grapheme_length > 500
errors.add(:note, "can't be longer than 500 graphemes")
end
end
def normalize_domain def normalize_domain
return if local? return if local?

View File

@@ -31,4 +31,13 @@ class InstancePresenter
def version_number def version_number
Mastodon::Version Mastodon::Version
end end
def commit_hash
current_release_file = Pathname.new('CURRENT_RELEASE').expand_path
if current_release_file.file?
IO.read(current_release_file)
else
""
end
end
end end

View File

@@ -36,7 +36,11 @@ class PostStatusService < BaseService
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
DistributionWorker.perform_async(status.id) DistributionWorker.perform_async(status.id)
Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
# match both with and without U+FE0F (the emoji variation selector)
unless /👁\ufe0f?\z/.match?(status.content)
Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
end
if options[:idempotency].present? if options[:idempotency].present?
redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)

View File

@@ -20,7 +20,10 @@ class ReblogService < BaseService
reblog = account.statuses.create!(reblog: reblogged_status, text: '') reblog = account.statuses.create!(reblog: reblogged_status, text: '')
DistributionWorker.perform_async(reblog.id) DistributionWorker.perform_async(reblog.id)
Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) unless /👁$/.match?(reblogged_status.content)
Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
end
if reblogged_status.local? if reblogged_status.local?
NotifyService.new.call(reblog.reblog.account, reblog) NotifyService.new.call(reblog.reblog.account, reblog)

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusLengthValidator < ActiveModel::Validator class StatusLengthValidator < ActiveModel::Validator
MAX_CHARS = 500 MAX_CHARS = 512
def validate(status) def validate(status)
return unless status.local? && !status.reblog? return unless status.local? && !status.reblog?

View File

@@ -9,4 +9,4 @@
%li= link_to t('about.get_started'), new_user_registration_path %li= link_to t('about.get_started'), new_user_registration_path
%li= link_to t('auth.login'), new_user_session_path %li= link_to t('auth.login'), new_user_session_path
%li= link_to t('about.terms'), terms_path %li= link_to t('about.terms'), terms_path
%li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' %li= link_to t('about.source_code'), 'https://github.com/chronister/mastodon'

View File

@@ -1,4 +1,9 @@
.panel .panel
.panel-header= t 'about.version' .panel-header= t 'about.version'
.panel-body .panel-body
%strong= version.version_number - if @instance_presenter.commit_hash == ""
%strong= version.version_number
- else
%strong= version.version_number
%strong= "#{@instance_presenter.commit_hash}"

View File

@@ -1,23 +1,24 @@
- processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
.card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
- if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
.controls
- if current_account.following?(account)
= link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button'
- else
= link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button'
- elsif !user_signed_in?
.controls
.remote-follow
= link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button'
.avatar= image_tag account.avatar.url(:original), class: 'u-photo'
%h1.name
%span.p-name.emojify= display_name(account)
%small
%span @#{account.username}
= fa_icon('lock') if account.locked?
.details .details
- if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
.controls
- if current_account.following?(account)
= link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button'
- else
= link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button'
- elsif !user_signed_in?
.controls
.remote-follow
= link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button'
.avatar= image_tag account.avatar.url(:original), class: 'u-photo'
%h1.name
%span.p-name.emojify= display_name(account)
%small
%span @#{account.username}
= fa_icon('lock') if account.locked?
.bio .bio
.account__header__content.p-note.emojify= Formatter.instance.simplified_format(account) .account__header__content.p-note.emojify!=processed_bio[:text]
.details-counters .details-counters
.counter{ class: active_nav_class(short_account_url(account)) } .counter{ class: active_nav_class(short_account_url(account)) }
@@ -32,3 +33,9 @@
= link_to account_followers_url(account) do = link_to account_followers_url(account) do
%span.counter-label= t('accounts.followers') %span.counter-label= t('accounts.followers')
%span.counter-number= number_with_delimiter account.followers_count %span.counter-number= number_with_delimiter account.followers_count
- if processed_bio[:metadata].length > 0
.metadata<
- processed_bio[:metadata].each do |i|
.metadata-item><
%b.emojify>!=i[0]
%span.emojify>!=i[1]

View File

@@ -6,7 +6,7 @@
.fields-group .fields-group
= f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe
= f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe
= f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar') = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar')
= f.input :header, wrapper: :with_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header') = f.input :header, wrapper: :with_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header')

View File

@@ -1,4 +1,4 @@
.media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' } .media-spoiler-wrapper{ class: sensitive == false && 'media-spoiler-wrapper__visible' }><
.spoiler-button .spoiler-button
.icon-button.overlayed .icon-button.overlayed
%i.fa.fa-fw.fa-eye %i.fa.fa-fw.fa-eye

View File

@@ -12,19 +12,20 @@
%p{ style: 'margin-bottom: 0' }< %p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp; %span.p-summary> #{status.spoiler_text}&nbsp;
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
.e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
= Formatter.instance.format(status)
- unless status.media_attachments.empty? - unless status.media_attachments.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
.video-player .video-player><
= render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? } = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
%video.u-video{ src: status.media_attachments.first.file.url(:original), loop: true } %video.u-video{ src: status.media_attachments.first.file.url(:original), loop: true }
- else - else
.detailed-status__attachments .detailed-status__attachments><
= render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? } = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
.status__attachments__inner .status__attachments__inner<
- status.media_attachments.each do |media| - status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media } = render partial: 'stream_entries/media', locals: { media: media }
.detailed-status__meta .detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 } %data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@@ -1,4 +1,4 @@
.media-item .media-item><
= link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : '', target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : '', target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
- unless media.image? - unless media.image?
%video{ src: media.file.url(:original), autoplay: true, loop: true }/ %video{ src: media.file.url(:original), autoplay: true, loop: true }/

View File

@@ -18,18 +18,19 @@
%p{ style: 'margin-bottom: 0' }< %p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp; %span.p-summary> #{status.spoiler_text}&nbsp;
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
.e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
= Formatter.instance.format(status)
- unless status.media_attachments.empty? - unless status.media_attachments.empty?
.status__attachments .status__attachments><
= render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? } = render partial: 'stream_entries/content_spoiler', locals: { sensitive: status.sensitive? }
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
.status__attachments__inner .status__attachments__inner<
.video-item .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', class: 'u-video' do = 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', class: 'u-video' do
.video-item__play .video-item__play
= fa_icon('play') = fa_icon('play')
- else - else
.status__attachments__inner .status__attachments__inner<
- status.media_attachments.each do |media| - status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media } = render partial: 'stream_entries/media', locals: { media: media }

View File

@@ -68,12 +68,17 @@ module Mastodon
config.active_job.queue_adapter = :sidekiq config.active_job.queue_adapter = :sidekiq
#config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do
config.middleware.insert_before 0, Rack::Cors do config.middleware.insert_before 0, Rack::Cors do
allow do allow do
origins '*' origins '*'
resource '/@:username', headers: :any, methods: [:get], credentials: false resource '/@:username', headers: :any, methods: [:get], credentials: false
resource '/api/*', headers: :any, methods: [:post, :put, :delete, :get, :patch, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id'] resource '/api/*', headers: :any, methods: [:post, :put, :delete, :get, :patch, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id']
resource '/oauth/token', headers: :any, methods: [:post], credentials: false resource '/oauth/token', headers: :any, methods: [:post], credentials: false
resource '/assets/*', headers: :any, methods: [:get, :head, :options]
resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options]
resource '/javascripts/*', headers: :any, methods: [:get, :head, :options]
resource '/packs/*', headers: :any, methods: [:get, :head, :options]
end end
end end

View File

@@ -95,9 +95,12 @@ Rails.application.configure do
end end
config.action_dispatch.default_headers = { config.action_dispatch.default_headers = {
'Server' => 'Mastodon', 'Server' => 'Mastodon',
'X-Frame-Options' => 'DENY', 'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff', 'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block', 'X-XSS-Protection' => '1; mode=block',
'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social 'unsafe-inline'; base-uri 'none';" ,
'Referrer-Policy' => 'no-referrer, strict-origin-when-cross-origin',
'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload'
} }
end end

View File

@@ -7,7 +7,7 @@
# For more information, see docs/Running-Mastodon/Administration-guide.md # For more information, see docs/Running-Mastodon/Administration-guide.md
# #
defaults: &defaults defaults: &defaults
site_title: Mastodon site_title: 'dev.glitch.social'
site_description: '' site_description: ''
site_extended_description: '' site_extended_description: ''
site_terms: '' site_terms: ''

View File

@@ -8,8 +8,8 @@
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #282c37; background: #181818 url("/background-photo.png");
color: #9baec8; color: #1ea21e;
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
@@ -33,7 +33,7 @@
<body> <body>
<div class="dialog"> <div class="dialog">
<img src="/oops.png" alt="Mastodon" /> <img src="/logo.png" alt="dev.glitch.social" />
<div> <div>
<h1>We're sorry, but something went wrong.</h1> <h1>We're sorry, but something went wrong.</h1>

BIN
public/background-cybre.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View File

@@ -3,7 +3,7 @@
<msapplication> <msapplication>
<tile> <tile>
<square150x150logo src="/mstile-150x150.png"/> <square150x150logo src="/mstile-150x150.png"/>
<TileColor>#2b5797</TileColor> <TileColor>#1ea21e</TileColor>
</tile> </tile>
</msapplication> </msapplication>
</browserconfig> </browserconfig>

54
public/clock.js Normal file
View File

@@ -0,0 +1,54 @@
document.addEventListener("DOMContentLoaded", function(event) {
updateClock();
setInterval(updateClock, 1000);
});
function getNextOpen(now) {
var days = [[0, 14], [4, 18], [8, 22], [12], [2, 16], [6, 20], [10]]
var nowday = now.getUTCDay();
var nour = now.getUTCHours();
var open_hour = -1;
var d = 0;
while (open_hour == -1) {
var times = days[(nowday + d) % 7];
for (var i = 0; i < times.length; ++i) {
var time = times[i];
if (time == nour) {
return "refresh";
} else if (time > nour || d > 0) {
open_hour = time;
break;
}
}
if (open_hour == -1) {
d += 1;
nour = -1;
}
}
var open = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + d));
var ts = open.setUTCHours(open_hour);
return open;
}
function updateClock() {
var clock = document.querySelector(".closed-registrations-message .clock");
var now = new Date();
var open = getNextOpen(now);
if (open == "refresh") {
location.reload();
return;
}
var until = open - now;
var ms = until % 1000;
var s = Math.floor((until / 1000)) % 60;
var m = Math.floor((until / 1000 / 60)) % 60;
var h = Math.floor((until / 1000 / 60 / 60));
if (m < 10) m = "0" + m;
if (s < 10) s = "0" + s;
clock.innerHTML = h + ":" + m + ":" + s;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

BIN
public/riot-glitch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -44,7 +44,8 @@ describe Api::V1::Accounts::CredentialsController do
describe 'with invalid data' do describe 'with invalid data' do
before do before do
patch :update, params: { note: 'This is too long. ' * 10 } # note length limit is 501, presently hardcoded, so give it 510 to fail
patch :update, params: { note: '1234567890' * 51 }
end end
it 'returns http unprocessable entity' do it 'returns http unprocessable entity' do

View File

@@ -558,8 +558,8 @@ RSpec.describe Account, type: :model do
expect(account).to model_have_error_on_field(:display_name) expect(account).to model_have_error_on_field(:display_name)
end end
it 'is invalid if the note is longer than 160 characters' do it 'is invalid if the note is longer than 500 characters' do
account = Fabricate.build(:account, note: Faker::Lorem.characters(161)) account = Fabricate.build(:account, note: Faker::Lorem.characters(501))
account.valid? account.valid?
expect(account).to model_have_error_on_field(:note) expect(account).to model_have_error_on_field(:note)
end end
@@ -598,8 +598,8 @@ RSpec.describe Account, type: :model do
expect(account).not_to model_have_error_on_field(:display_name) expect(account).not_to model_have_error_on_field(:display_name)
end end
it 'is valid even if the note is longer than 160 characters' do it 'is valid even if the note is longer than 500 characters' do
account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(161)) account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(501))
account.valid? account.valid?
expect(account).not_to model_have_error_on_field(:note) expect(account).not_to model_have_error_on_field(:note)
end end