Compare commits

..

41 Commits

Author SHA1 Message Date
kibigo!
6839ee390f Many improvements to images in collapsed toots
- Now works on detailed and static pages
- Fixed bug with nested CW / Sensitive Media
- Now apparent which toots contain media
2017-06-29 23:31:22 -07:00
kibigo!
54c1f56c9a Put images behind CWs 2017-06-27 17:01:46 -07:00
m4sk1n
2a9805b987 i18n: Minor fix in devise.pl.yml (#3978) 2017-06-27 23:14:02 +02:00
m4sk1n
126f929c39 i18n: Use instance name in email notifications instead of Mastodon (pl) (#3976)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-06-27 23:10:43 +02:00
m4sk1n
da42bfadb5 i18n: E-mail notifications to admins about new reports (pl) (#3975) 2017-06-27 22:21:35 +02:00
m4sk1n
6ad72728f6 i18n: Turn report screen into a modal (pl) (#3974) 2017-06-27 22:14:31 +02:00
Sorin Davidoi
64d9c016bd fix(components/status): Up & down jump due to content being added to the DOM (#3972) 2017-06-27 18:43:53 +02:00
Eugen Rochko
12e7c81dd8 Turn report screen into a modal (#3965) 2017-06-27 18:07:21 +02:00
Midgard
16d0aed403 Use instance name in email notifications instead of "Mastodon" (#3763)
* Use instance name in "password changed" mail

instead of "Mastodon".

Fixes tootsuite#2620.

* Use instance name in password reset mail

instead of "Mastodon".
2017-06-27 14:22:36 +02:00
Debanshu Kundu
da9317fa56 #1456 Added rake task to add a user. (#1482) 2017-06-27 14:18:53 +02:00
Sorin Davidoi
be92babd00 Responsive images in media gallery (#3963)
* feat(components/media_gallery): Responsive images

* fix(components/media_gallery): Link to image URL
2017-06-27 13:46:37 +02:00
Yamagishi Kazutoshi
e2dd576a1b Update dependencies for Node.js (#3967)
* Update @storybook/addon-actions to v3.1.6

* Update @storybook/react to v3.1.6

* Update babel-loader to v7.1.0

* Update babel-plugin-transform-react-remove-prop-types to v0.4.6

* Update enzyme to v2.9.1

* Update fsevents to v1.1.2

* Update intersection-observer to v0.3.2

* Update npmlog to v4.1.2

* Update pg to v6.4.0

* Update postcss-loader to v2.0.6

* Update rails-ujs to v5.1.2

* Update react to v15.6.1

* Update react-addons-shallow-compare to v15.6.0

* Update react-dom to v15.6.0

* Update react-notification to v6.7.1

* Update react-test-renderer to v15.6.1

* Update react-textarea-autosize to v5.0.7

* Update redux to v3.7.1

* Update resolve-url-loader to v2.1.0

* Update sass-loader to v6.0.6

* Update sinon to v2.3.5

* Update stringz to v0.2.2

* Update uuid to v3.1.0

* Update websocket.js to v0.1.12

* Update yargs to v8.0.2

* yarn upgrade
2017-06-27 13:46:11 +02:00
Yamagishi Kazutoshi
8f2c91568c Maintain aspect ratio for preview image (#3966) 2017-06-27 13:43:53 +02:00
Yamagishi Kazutoshi
98eaa2aa27 Update Rails to v5.1.2 (#3968) 2017-06-27 13:41:03 +02:00
Eugen Rochko
42b8220632 Fix #1624 - Send e-mail notifications to admins about new reports (#3949) 2017-06-27 00:04:00 +02:00
ThibG
a91d968cab Raise an error if salmon request response is unsatisfactory (#3960) 2017-06-26 19:39:58 +02:00
m4sk1n
646de92781 i18n: Updated Polish translation (#3956)
* i18n: Updated Polish translation

* Update pl.yml
2017-06-26 17:18:45 +02:00
m4sk1n
ae2b722f55 i18n: Warning to look into the spam folder (pl) (#3955) 2017-06-26 17:10:54 +02:00
Daniel Hunsaker
7aeb9168b0 Add .gitattributes file to avoid unwanted CRLF (#3954)
When Windows checks out files, it defaults to changing line endings to CRLF. If these files are then copied to a Linux system to be run, and the endings aren't changed at some point in that process, things break. This file forces git to use LF for all text files on all systems (except the request testing specfiles) to prevent issues everywhere.
2017-06-26 13:15:24 +02:00
Alda Marteau-Hardi
f53ed108b0 Translate pin/unpin and fix some inconsistencies in gender neutral strings (#3952) 2017-06-26 13:04:36 +02:00
Yamagishi Kazutoshi
285038972b Stop using Babel with streaming server (#3950) 2017-06-26 04:49:39 +02:00
Takuya Yoshida
e5563843a2 Re-fix errorMiddleware (#3922) 2017-06-26 01:46:15 +02:00
unarist
c972e1ee1f Ignore DB_NAME for development env on streaming as well as rails side (#3948) 2017-06-26 01:45:50 +02:00
Eugen Rochko
5e8d037e27 Fix #3910 - Require OTP authentication to disable 2FA (#3935)
* Fix #3910 - Require OTP authentication to disable 2FA. Also, remove ability
to generate new OTP backup codes *after* initial backup codes were handed
out during activation

* Restore recovery code re-generation

* Improve display of some 2FA elements
2017-06-25 23:51:46 +02:00
Eugen Rochko
ed7dc1704d Bind web UI access tokens to sessions (#3940)
* Add overview of active sessions

* Better display of browser/platform name

* Improve how browser information is stored and displayed for sessions overview

* Fix test

* Fix #2347 - Bind web UI access token to session

When you logout, session also destroys the access token, so it's no longer
valid. If access token is destroyed some other way, the session is also
destroyed, requiring a re-login.

Fix #1681 - Add scheduler to remove revoked access tokens and grants

* Fix test
2017-06-25 23:51:32 +02:00
amazedkoumei
436ce03772 fix unnecessary variable (#3947) 2017-06-25 23:29:22 +02:00
Eugen Rochko
d821aba002 Rename "Credentials" page to "Security" for clarity (#3941)
* Rename "Credentials" page to "Security" for clarity

* Change "security" icon from cog to lock
2017-06-25 22:13:02 +02:00
Sorin Davidoi
4ce1540094 fix(features/compose): Handle external changes to the textarea (#3632) 2017-06-25 21:43:27 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
67243bda31 Cover Auth::RegistrationsController more (#3353) 2017-06-25 21:42:55 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
8f991831b8 Cover Admin::DomainBlocksController more (#3329)
Also domain_block fabricator now sets unique domains
2017-06-25 21:42:36 +02:00
amazedkoumei
87efa38721 more free pgconfig by .env (#3909)
* more free pgconfig for streaming by .env

* fix wrong default values

* database.yml read ENV as same as streaming server
2017-06-25 18:13:31 +02:00
Eugen Rochko
f7301bd5b9 Add overview of active sessions (#3929)
* Add overview of active sessions

* Better display of browser/platform name

* Improve how browser information is stored and displayed for sessions overview

* Fix test
2017-06-25 16:54:30 +02:00
PFM
099a3b4eac Fix "undefined" in className (#3939) 2017-06-25 16:02:56 +02:00
unarist
3d4e21f1ec Don't set ASSET_HOST on build:development (#3936)
Setting ASSET_HOST to `http://0.0.0.0:8080` makes urls in manifest.json to
be invalid, e.g. `http://0.0.0.0:8080/packs/application.js`.

Anyway, we don't need set this on build:development because assets would
be delivered from same origin in development (and w/o dev-server).
2017-06-25 12:52:42 +02:00
unarist
68dca26a5d Fix react-intl/locale-data import issue on production build (#3937)
Webpack seems to fail to import `react-intl/locale-data/*.js` if those
files has been proceed by babel, and this also breaks applying our translation.

Note that this won't be a problem on English locale, because react-intl
includes it as default and works fine without manually added locale-data.
Also this issue seems to only occurs on production build, but I'm not sure
about reason.
2017-06-25 12:49:53 +02:00
unarist
1fc096ec75 Fix elephant in onboarding modal being very small sized on small devices (#3932) 2017-06-24 23:18:32 +02:00
unarist
21c2bc119c Clean column collapsible (#3931)
* Remove unused column_collapsable.js
* Remove old styles
* Extract `> div`  style to independent class
2017-06-24 23:18:11 +02:00
Sorin Davidoi
d23293c762 feat(components/onboarding_modal): Swipe between pages (#3934) 2017-06-24 23:17:39 +02:00
unarist
138e5a0b1e Fix webpack config for Windows (#3926) 2017-06-24 14:03:52 +02:00
Yamagishi Kazutoshi
79dacea962 Fix #3924 (regression from #3906) (#3925) 2017-06-24 12:24:02 +02:00
unarist
4e6b5e7879 Use debounce for dispatch scrollTopNotification and expandNotifications (#3700) 2017-06-24 02:43:26 +02:00
129 changed files with 1966 additions and 1495 deletions

14
.gitattributes vendored Normal file
View File

@@ -0,0 +1,14 @@
* text=auto eol=lf
*.eot -text
*.gif -text
*.gz -text
*.ico -text
*.jpg -text
*.mp3 -text
*.ogg -text
*.png -text
*.ttf -text
*.webm -text
*.woff -text
*.woff2 -text
spec/fixtures/requests/** -text !eol

View File

@@ -20,6 +20,7 @@ gem 'paperclip-av-transcoder', '~> 0.6'
gem 'addressable', '~> 2.5'
gem 'bootsnap'
gem 'browser'
gem 'cld3', '~> 3.1'
gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0'

View File

@@ -1,40 +1,40 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (5.1.1)
actionpack (= 5.1.1)
actioncable (5.1.2)
actionpack (= 5.1.2)
nio4r (~> 2.0)
websocket-driver (~> 0.6.1)
actionmailer (5.1.1)
actionpack (= 5.1.1)
actionview (= 5.1.1)
activejob (= 5.1.1)
actionmailer (5.1.2)
actionpack (= 5.1.2)
actionview (= 5.1.2)
activejob (= 5.1.2)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.1.1)
actionview (= 5.1.1)
activesupport (= 5.1.1)
actionpack (5.1.2)
actionview (= 5.1.2)
activesupport (= 5.1.2)
rack (~> 2.0)
rack-test (~> 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.1.1)
activesupport (= 5.1.1)
actionview (5.1.2)
activesupport (= 5.1.2)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_record_query_trace (1.5.4)
activejob (5.1.1)
activesupport (= 5.1.1)
activejob (5.1.2)
activesupport (= 5.1.2)
globalid (>= 0.3.6)
activemodel (5.1.1)
activesupport (= 5.1.1)
activerecord (5.1.1)
activemodel (= 5.1.1)
activesupport (= 5.1.1)
activemodel (5.1.2)
activesupport (= 5.1.2)
activerecord (5.1.2)
activemodel (= 5.1.2)
activesupport (= 5.1.2)
arel (~> 8.0)
activesupport (5.1.1)
activesupport (5.1.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7)
minitest (~> 5.1)
@@ -70,6 +70,7 @@ GEM
bootsnap (1.0.0)
msgpack (~> 1.0)
brakeman (3.6.2)
browser (2.4.0)
builder (3.2.3)
bullet (5.5.1)
activesupport (>= 3.0.0)
@@ -297,17 +298,17 @@ GEM
rack-test (0.6.3)
rack (>= 1.0)
rack-timeout (0.4.2)
rails (5.1.1)
actioncable (= 5.1.1)
actionmailer (= 5.1.1)
actionpack (= 5.1.1)
actionview (= 5.1.1)
activejob (= 5.1.1)
activemodel (= 5.1.1)
activerecord (= 5.1.1)
activesupport (= 5.1.1)
rails (5.1.2)
actioncable (= 5.1.2)
actionmailer (= 5.1.2)
actionpack (= 5.1.2)
actionview (= 5.1.2)
activejob (= 5.1.2)
activemodel (= 5.1.2)
activerecord (= 5.1.2)
activesupport (= 5.1.2)
bundler (>= 1.3.0, < 2.0)
railties (= 5.1.1)
railties (= 5.1.2)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1)
@@ -323,9 +324,9 @@ GEM
railties (~> 5.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
railties (5.1.1)
actionpack (= 5.1.1)
activesupport (= 5.1.1)
railties (5.1.2)
actionpack (= 5.1.2)
activesupport (= 5.1.2)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
@@ -483,6 +484,7 @@ DEPENDENCIES
binding_of_caller (~> 0.7)
bootsnap
brakeman (~> 3.6)
browser
bullet (~> 5.5)
bundler-audit (~> 0.5)
capistrano (~> 3.8)

View File

@@ -1,5 +1,70 @@
Mastodon Glitch Edition
Mastodon
========
Now with automated deploys!
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?
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[travis]: https://travis-ci.org/tootsuite/mastodon
[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

@@ -17,6 +17,9 @@ class Api::V1::ReportsController < Api::BaseController
status_ids: reported_status_ids,
comment: report_params[:comment]
)
User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
render :show
end

View File

@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
include UserTrackingConcern
helper_method :current_account
helper_method :current_session
helper_method :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found
@@ -68,6 +69,10 @@ class ApplicationController < ActionController::Base
@current_account ||= current_user.try(:account)
end
def current_session
@current_session ||= SessionActivation.find_by(session_id: session['auth_id'])
end
def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes)

View File

@@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
def destroy
not_found
@@ -41,4 +42,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end
def set_sessions
@sessions = current_user.session_activations
end
end

View File

@@ -5,7 +5,7 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
@token = find_or_create_access_token.token
@token = current_session.token
@web_settings = Web::Setting.find_by(user: current_user)&.data || {}
@admin = Account.find_local(Setting.site_contact_username)
@streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
@@ -16,14 +16,4 @@ class HomeController < ApplicationController
def authenticate_user!
redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.where(superapp: true).first,
current_user.id,
Doorkeeper::OAuth::Scopes.from_string('read write follow'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
end
end

View File

@@ -7,7 +7,9 @@ module Settings
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
def show; end
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
current_user.otp_secret = User.generate_otp_secret(32)
@@ -16,13 +18,23 @@ module Settings
end
def destroy
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
if current_user.validate_and_consume_otp!(confirmation_params[:code])
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@confirmation = Form::TwoFactorConfirmation.new
render :show
end
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:code)
end
def verify_otp_required
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
end

View File

@@ -41,4 +41,16 @@ module SettingsHelper
def hash_to_object(hash)
HashObject.new(hash)
end
def session_device_icon(session)
device = session.detection.device
if device.mobile?
'mobile'
elsif device.tablet?
'tablet'
else
'desktop'
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,4 +1,5 @@
import api from '../api';
import { openModal, closeModal } from './modal';
export const REPORT_INIT = 'REPORT_INIT';
export const REPORT_CANCEL = 'REPORT_CANCEL';
@@ -11,10 +12,14 @@ export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
export function initReport(account, status) {
return {
type: REPORT_INIT,
account,
status,
return dispatch => {
dispatch({
type: REPORT_INIT,
account,
status,
});
dispatch(openModal('REPORT'));
};
};
@@ -40,7 +45,10 @@ export function submitReport() {
account_id: getState().getIn(['reports', 'new', 'account_id']),
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
comment: getState().getIn(['reports', 'new', 'comment']),
}).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
}).then(response => {
dispatch(closeModal());
dispatch(submitReportSuccess(response.data));
}).catch(error => dispatch(submitReportFail(error)));
};
};

View File

@@ -1,50 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class ColumnCollapsable extends React.PureComponent {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string,
fullHeight: PropTypes.number.isRequired,
children: PropTypes.node,
onCollapse: PropTypes.func,
};
state = {
collapsed: true,
animating: false,
};
handleToggleCollapsed = () => {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState, animating: true });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render () {
const { icon, title, fullHeight, children } = this.props;
const { collapsed, animating } = this.state;
return (
<div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`} onTransitionEnd={this.handleTransitionEnd}>
<div role='button' tabIndex='0' title={`${title}`} className='column-collapsable__button column-icon' onClick={this.handleToggleCollapsed}>
<i className={`fa fa-${icon}`} />
</div>
<div className='column-collapsable__content' style={{ height: `${fullHeight}px` }}>
{(!collapsed || animating) && children}
</div>
</div>
);
}
}

View File

@@ -132,7 +132,7 @@ export default class ColumnHeader extends React.PureComponent {
</div>
<div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
<div>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>

View File

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

View File

@@ -85,14 +85,24 @@ class Item extends React.PureComponent {
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 // eslint-disable-line jsx-a11y/anchor-has-content
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || attachment.get('url')}
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
/>
>
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;

View File

@@ -25,7 +25,7 @@ export default class Permalink extends React.PureComponent {
const { href, children, className, ...other } = this.props;
return (
<a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
<a href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
{children}
</a>
);

View File

@@ -3,24 +3,19 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import AvatarOverlay from './avatar_overlay';
import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import MediaGallery from './media_gallery';
import VideoPlayer from './video_player';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import emojify from '../emoji';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
});
class StatusUnextended extends ImmutablePureComponent {
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -42,14 +37,12 @@ class StatusUnextended extends ImmutablePureComponent {
autoPlayGif: PropTypes.bool,
muted: PropTypes.bool,
intersectionObserverWrapper: PropTypes.object,
intl: PropTypes.object.isRequired,
};
state = {
isExpanded: false,
isIntersecting: true, // assume intersecting until told otherwise
isHidden: false, // set to true in requestIdleCallback to trigger un-render
isCollapsed: false,
}
// Avoid checking props that are functions (and whose equality will always
@@ -67,11 +60,7 @@ class StatusUnextended extends ImmutablePureComponent {
updateOnStates = ['isExpanded']
shouldComponentUpdate (nextProps, nextState) {
if (nextState.isCollapsed !== this.state.isCollapsed) {
// If the collapsed state of the element has changed then we definitely
// need to re-update.
return true;
} else if (!nextState.isIntersecting && nextState.isHidden) {
if (!nextState.isIntersecting && nextState.isHidden) {
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
// that either "isIntersecting" or "isHidden" matter, and then they're
// the only things that matter.
@@ -85,13 +74,7 @@ class StatusUnextended extends ImmutablePureComponent {
return super.shouldComponentUpdate(nextProps, nextState);
}
componentDidUpdate (prevProps, prevState) {
if (prevState.isCollapsed !== this.state.isCollapsed) this.saveHeight();
}
componentDidMount () {
const node = this.node;
if (!this.props.intersectionObserverWrapper) {
// TODO: enable IntersectionObserver optimization for notification statuses.
// These are managed in notifications/index.js rather than status_list.js
@@ -103,8 +86,6 @@ class StatusUnextended extends ImmutablePureComponent {
this.handleIntersection
);
if (node.clientHeight > 400 && !(this.props.status.get('reblog', null) !== null && typeof this.props.status.get('reblog') === 'object')) this.setState({ isCollapsed: true });
this.componentMounted = true;
}
@@ -143,7 +124,7 @@ class StatusUnextended extends ImmutablePureComponent {
saveHeight = () => {
if (this.node && this.node.children.length !== 0) {
this.height = this.node.clientHeight;
this.height = this.node.getBoundingClientRect().height;
}
}
@@ -169,18 +150,15 @@ class StatusUnextended extends ImmutablePureComponent {
this.setState({ isExpanded: !this.state.isExpanded });
};
handleCollapsedClick = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
}
render () {
let media = null;
let mediaIcon = null;
let statusAvatar;
// Exclude intersectionObserverWrapper from `other` variable
// because intersection is managed in here.
const { status, account, intersectionObserverWrapper, intl, ...other } = this.props;
const { isExpanded, isIntersecting, isHidden, isCollapsed } = this.state;
const { status, account, intersectionObserverWrapper, ...other } = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
if (status === null) {
return null;
@@ -221,8 +199,10 @@ class StatusUnextended extends ImmutablePureComponent {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
mediaIcon = 'video-camera';
} else {
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
mediaIcon = 'picture-o';
}
}
@@ -233,17 +213,9 @@ class StatusUnextended extends ImmutablePureComponent {
}
return (
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')} ${isCollapsed ? 'status-collapsed' : ''}`} data-id={status.get('id')} ref={this.handleRef}>
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}>
<div className='status__info'>
<IconButton
className='status__collapse-button'
animate flip
active={isCollapsed}
title={isCollapsed ? intl.formatMessage(messages.uncollapse) : intl.formatMessage(messages.collapse)}
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
<div className='status__avatar'>
@@ -252,19 +224,17 @@ class StatusUnextended extends ImmutablePureComponent {
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight} />
<StatusContent status={status} mediaIcon={mediaIcon} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight}>
{isCollapsed ? null : media}
{media}
{isCollapsed ? null : <StatusActionBar status={status} account={account} {...other} />}
</StatusContent>
<StatusActionBar {...this.props} />
</div>
);
}
}
const Status = injectIntl(StatusUnextended);
export default Status;

View File

@@ -5,7 +5,6 @@ import IconButton from './icon_button';
import DropdownMenu from './dropdown_menu';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -88,7 +87,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
handleReport = () => {
this.props.onReport(this.props.status);
this.context.router.history.push('/report');
}
handleConversationMuteClick = () => {
@@ -146,8 +144,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
<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

@@ -19,6 +19,8 @@ export default class StatusContent extends React.PureComponent {
onExpandedToggle: PropTypes.func,
onHeightUpdate: PropTypes.func,
onClick: PropTypes.func,
mediaIcon: PropTypes.string,
children: PropTypes.element,
};
state = {
@@ -107,7 +109,7 @@ export default class StatusContent extends React.PureComponent {
}
render () {
const { status } = this.props;
const { status, children, mediaIcon } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@@ -128,15 +130,19 @@ export default class StatusContent extends React.PureComponent {
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
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} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<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>
@@ -144,7 +150,15 @@ export default class StatusContent extends React.PureComponent {
{mentionsPlaceholder}
<div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
<div
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{children}
</div>
</div>
);
} else if (this.props.onClick) {
@@ -153,10 +167,14 @@ export default class StatusContent extends React.PureComponent {
ref={this.setRef}
className='status__content status__content--with-action'
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
>
<div
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{children}
</div>
);
} else {
return (
@@ -164,8 +182,10 @@ export default class StatusContent extends React.PureComponent {
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
/>
>
<div dangerouslySetInnerHTML={content} />
{children}
</div>
);
}
}

View File

@@ -5,7 +5,9 @@ import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Avatar from '../../../components/avatar';
import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
@@ -14,57 +16,61 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
/*
THIS IS A MESS BECAUSE EFFING MASTODON AND ITS EFFING HTML BIOS
INSTEAD OF JUST STORING EVERYTHING IN PLAIN EFFING TEXT ! ! ! !
BLANK LINES ALSO WON'T WORK BECAUSE RIGHT NOW MASTODON CONVERTS
THOSE INTO `<P>` ELEMENTS INSTEAD OF LEAVING IT AS `<BR><BR>` !
TL:DR; THIS IS LARGELY A HACK. WITH BETTER BACKEND STUFF WE CAN
IMPROVE THIS BY BETTER PREDICTING HOW THE METADATA WILL BE SENT
WHILE MAINTAINING BASIC PLAIN-TEXT PROCESSING. THE OTHER OPTION
IS TO TURN ALL BIOS INTO PLAIN-TEXT VIA A TREE-WALKER, AND THEN
PROCESS THE YAML AND LINKS AND EVERYTHING OURSELVES. THIS WOULD
BE INCREDIBLY COMPLICATED, AND IT WOULD BE A MILLION TIMES LESS
DIFFICULT IF MASTODON JUST GAVE US PLAIN-TEXT BIOS (WHICH QUITE
FRANKLY MAKES THE MOST SENSE SINCE THAT'S WHAT USERS PROVIDE IN
SETTINGS) TO BEGIN WITH AND LEFT ALL PROCESSING TO THE FRONTEND
TO HANDLE ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !
ANYWAY I KNOW WHAT NEEDS TO BE DONE REGARDING BACKEND STUFF BUT
I'M NOT SMART ENOUGH TO FIGURE OUT HOW TO ACTUALLY IMPLEMENT IT
SO FEEL FREE TO @ ME IF YOU NEED MY IDEAS REGARDING THAT. UNTIL
THEN WE'LL JUST HAVE TO MAKE DO WITH THIS MESSY AND UNFORTUNATE
HACKING ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
with love,
@kibi@glitch.social <3
*/
const NEW_LINE = /(?:^|\r?\n|<br\s*\/?>)/g;
const YAML_OPENER = /---/;
const YAML_CLOSER = /(?:---|\.\.\.)/;
const YAML_STRING = /(?:"(?:[^"\n]){1,32}"|'(?:[^'\n]){1,32}'|(?:[^'":\n]){1,32})/g;
const YAML_LINE = new RegExp('\\s*' + YAML_STRING.source + '\\s*:\\s*' + YAML_STRING.source + '\\s*', 'g');
const BIO_REGEX = new RegExp(NEW_LINE.source + '*' + YAML_OPENER.source + NEW_LINE.source + '+(?:' + YAML_LINE.source + NEW_LINE.source + '+){0,4}' + YAML_CLOSER.source + NEW_LINE.source + '*');
const processBio = (data) => {
let props = { text: data, metadata: [] };
let yaml = data.match(BIO_REGEX);
if (!yaml) return props;
else yaml = yaml[0];
let start = props.text.indexOf(yaml);
let end = start + yaml.length;
props.text = props.text.substr(0, start) + props.text.substr(end);
yaml = yaml.replace(NEW_LINE, '\n');
let metadata = (yaml ? yaml.match(YAML_LINE) : []) || [];
for (let i = 0; i < metadata.length; i++) {
let result = metadata[i].match(YAML_STRING);
if (result[0][0] === '"' || result[0][0] === '\'') result[0] = result[0].substr(1, result[0].length - 2);
if (result[1][0] === '"' || result[1][0] === '\'') result[0] = result[1].substr(1, result[1].length - 2);
props.metadata.push(result);
}
return props;
return mapStateToProps;
};
class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
isHovered: false,
};
handleMouseOver = () => {
if (this.state.isHovered) return;
this.setState({ isHovered: true });
}
handleMouseOut = () => {
if (!this.state.isHovered) return;
this.setState({ isHovered: false });
}
render () {
const { account, autoPlayGif } = this.props;
const { isHovered } = this.state;
return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) =>
<a // eslint-disable-line jsx-a11y/anchor-has-content
href={account.get('url')}
className='account__header__avatar'
target='_blank'
rel='noopener'
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}
/>
}
</Motion>
);
}
}
@connect(makeMapStateToProps)
@injectIntl
export default class Header extends ImmutablePureComponent {
@@ -73,6 +79,7 @@ export default class Header extends ImmutablePureComponent {
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
render () {
@@ -115,45 +122,21 @@ export default class Header extends ImmutablePureComponent {
lockedIcon = <i className='fa fa-lock' />;
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const { text, metadata } = processBio(account.get('note'));
const content = { __html: emojify(account.get('note')) };
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
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')} animate 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) }} />
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
{info}
{actionBtn}
</div>
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
<div className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}
</div>
{metadata.length && (
<div className='account__metadata'>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<div
className='account__metadata-item'
title={metadata[i][0] + ':' + metadata[i][1]}
key={i}
>
<span dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} />
<strong dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} />
</div>
);
}
return data;
})()}
</div>
) || null}
</div>
);
}

View File

@@ -38,7 +38,6 @@ export default class Header extends ImmutablePureComponent {
handleReport = () => {
this.props.onReport(this.props.account);
this.context.router.history.push('/report');
}
handleMute = () => {

View File

@@ -1,44 +1,144 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshCommunityTimeline,
expandCommunityTimeline,
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import Timeline from '../timeline';
import createStream from '../../stream';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
});
@connect(mapStateToProps)
@injectIntl
export default class CommunityTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('COMMUNITY', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshCommunityTimeline());
if (typeof this._subscription !== 'undefined') {
return;
}
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
connected () {
dispatch(connectTimeline('community'));
},
reconnected () {
dispatch(connectTimeline('community'));
},
disconnected () {
dispatch(disconnectTimeline('community'));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('community', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
}
componentWillUnmount () {
if (typeof this._subscription !== 'undefined') {
this._subscription.close();
this._subscription = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandCommunityTimeline());
}
render () {
const { intl, columnId, multiColumn } = this.props;
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Timeline
expand={expandCommunityTimeline}
refresh={refreshCommunityTimeline}
streamId='public:local'
columnName='COMMUNITY'
columnId={columnId}
mulitColumn={multiColumn}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
icon='users'
title={intl.formatMessage(messages.title)}
settings={<ColumnSettingsContainer />}
scrollName='community_timeline'
timelineId='community'
/>
<Column ref={this.setRef}>
<ColumnHeader
icon='users'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`community_timeline-${columnId}`}
timelineId='community'
loadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
/>
</Column>
);
}

View File

@@ -67,6 +67,12 @@ export default class ComposeForm extends ImmutablePureComponent {
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
this.props.onSubmit();
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column';
@@ -14,7 +15,9 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me']),
});
@connect(mapStateToProps)
@@ -23,8 +26,10 @@ export default class Favourites extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
loaded: PropTypes.bool,
intl: PropTypes.object.isRequired,
me: PropTypes.number.isRequired,
};
componentWillMount () {

View File

@@ -1,53 +1,138 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshHashtagTimeline,
expandHashtagTimeline,
updateTimeline,
deleteFromTimelines,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { FormattedMessage } from 'react-intl';
import Timeline from '../timeline';
import createStream from '../../stream';
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
});
@connect(mapStateToProps)
export default class HashtagTimeline extends React.PureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
columnId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentWillMount () {
const id = this.props.params.id;
this.expand = () => expandHashtagTimeline(id);
this.refresh = () => refreshHashtagTimeline(id);
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
_subscribe (dispatch, id) {
const { streamingAPIBaseURL, accessToken } = this.props;
this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
}
_unsubscribe () {
if (typeof this.subscription !== 'undefined') {
this.subscription.close();
this.subscription = null;
}
}
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(refreshHashtagTimeline(id));
this._subscribe(dispatch, id);
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.id !== this.props.params.id) {
const id = nextProps.params.id;
this.expand = () => expandHashtagTimeline(id);
this.refresh = () => refreshHashtagTimeline(id);
this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
this._unsubscribe();
this._subscribe(this.props.dispatch, nextProps.params.id);
}
}
componentWillUnmount () {
this._unsubscribe();
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandHashtagTimeline(this.props.params.id));
}
render () {
const { columnId, multiColumn } = this.props;
const { hasUnread, columnId, multiColumn } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
return (
<Timeline
expand={this.expand}
refresh={this.refresh}
streamId={`hashtag&tag=${id}`}
columnName='HASHTAG'
columnProps={{ id }}
columnId={columnId}
mulitColumn={multiColumn}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
icon='hashtag'
title={id}
scrollName='hashtag_timeline'
timelineId={`hashtag:${id}`}
/>
<Column ref={this.setRef}>
<ColumnHeader
icon='hashtag'
active={hasUnread}
title={id}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
/>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`hashtag_timeline-${columnId}`}
timelineId={`hashtag:${id}`}
loadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
/>
</Column>
);
}

View File

@@ -2,9 +2,12 @@ import React from 'react';
import { connect } from 'react-redux';
import { expandHomeTimeline } from '../../actions/timelines';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import Timeline from '../timeline';
import Link from 'react-router-dom/Link';
const messages = defineMessages({
@@ -12,6 +15,7 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
hasFollows: state.getIn(['accounts_counters', state.getIn(['meta', 'me']), 'following_count']) > 0,
});
@@ -20,14 +24,44 @@ const mapStateToProps = state => ({
export default class HomeTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
hasFollows: PropTypes.bool,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('HOME', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandHomeTimeline());
}
render () {
const { intl, hasFollows, columnId, multiColumn } = this.props;
const { intl, hasUnread, hasFollows, columnId, multiColumn } = this.props;
const pinned = !!columnId;
let emptyMessage;
@@ -38,18 +72,28 @@ export default class HomeTimeline extends React.PureComponent {
}
return (
<Timeline
expand={expandHomeTimeline}
columnName='HOME'
columnId={columnId}
mulitColumn={multiColumn}
emptyMessage={emptyMessage}
icon='home'
title={intl.formatMessage(messages.title)}
settings={<ColumnSettingsContainer />}
scrollName='home_timeline'
timelineId='home'
/>
<Column ref={this.setRef}>
<ColumnHeader
icon='home'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
loadMore={this.handleLoadMore}
timelineId='home'
emptyMessage={emptyMessage}
/>
</Column>
);
}

View File

@@ -13,6 +13,7 @@ import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect';
import Immutable from 'immutable';
import LoadMore from '../../components/load_more';
import { debounce } from 'lodash';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -50,19 +51,27 @@ export default class Notifications extends React.PureComponent {
trackScroll: true,
};
dispatchExpandNotifications = debounce(() => {
this.props.dispatch(expandNotifications());
}, 300, { leading: true });
dispatchScrollToTop = debounce((top) => {
this.props.dispatch(scrollTopNotifications(top));
}, 100);
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (250 > offset && !this.props.isLoading) {
if (this.props.hasMore) {
this.props.dispatch(expandNotifications());
}
} else if (scrollTop < 100) {
this.props.dispatch(scrollTopNotifications(true));
if (250 > offset && this.props.hasMore && !this.props.isLoading) {
this.dispatchExpandNotifications();
}
if (scrollTop < 100) {
this.dispatchScrollToTop(true);
} else {
this.props.dispatch(scrollTopNotifications(false));
this.dispatchScrollToTop(false);
}
}
@@ -74,7 +83,7 @@ export default class Notifications extends React.PureComponent {
handleLoadMore = (e) => {
e.preventDefault();
this.props.dispatch(expandNotifications());
this.dispatchExpandNotifications();
}
handlePin = () => {

View File

@@ -1,44 +1,144 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshPublicTimeline,
expandPublicTimeline,
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import Timeline from '../timeline';
import createStream from '../../stream';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Federated timeline' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
});
@connect(mapStateToProps)
@injectIntl
export default class PublicTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('PUBLIC', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshPublicTimeline());
if (typeof this._subscription !== 'undefined') {
return;
}
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
connected () {
dispatch(connectTimeline('public'));
},
reconnected () {
dispatch(connectTimeline('public'));
},
disconnected () {
dispatch(disconnectTimeline('public'));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('public', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
}
componentWillUnmount () {
if (typeof this._subscription !== 'undefined') {
this._subscription.close();
this._subscription = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandPublicTimeline());
}
render () {
const { intl, columnId, multiColumn } = this.props;
const { intl, columnId, hasUnread, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Timeline
expand={expandPublicTimeline}
refresh={refreshPublicTimeline}
streamId='public'
columnName='PUBLIC'
columnId={columnId}
mulitColumn={multiColumn}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
icon='globe'
title={intl.formatMessage(messages.title)}
settings={<ColumnSettingsContainer />}
scrollName='public_timeline'
timelineId='public'
/>
<Column ref={this.setRef}>
<ColumnHeader
icon='globe'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
timelineId='public'
loadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
/>
</Column>
);
}

View File

@@ -56,7 +56,6 @@ export default class ActionBar extends React.PureComponent {
handleReport = () => {
this.props.onReport(this.props.status);
this.context.router.history.push('/report');
}
render () {

View File

@@ -38,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
let media = '';
let mediaIcon = null;
let applicationLink = '';
if (status.get('media_attachments').size > 0) {
@@ -45,12 +46,12 @@ export default class DetailedStatus extends ImmutablePureComponent {
media = <AttachmentList media={status.get('media_attachments')} />;
} 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 />;
mediaIcon = 'video-camera';
} else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
mediaIcon = 'picture-o';
}
} else if (status.get('spoiler_text').length === 0) {
media = <CardContainer statusId={status.get('id')} />;
}
} else media = <CardContainer statusId={status.get('id')} />;
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>;
@@ -63,9 +64,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} />
</a>
<StatusContent status={status} />
{media}
<StatusContent status={status} mediaIcon={mediaIcon}>{media}</StatusContent>
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>

View File

@@ -1,179 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import createStream from '../../stream';
const mapStateToProps = (state, ownprops) => ({
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
hasUnread: state.getIn(['timelines', ownprops.timelineId, 'unread']) > 0,
});
@connect(mapStateToProps)
export default class Timeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
expand: PropTypes.func.isRequired,
refresh: PropTypes.func,
streamId: PropTypes.string,
hasUnread: PropTypes.bool,
columnName: PropTypes.string.isRequired,
columnProps: PropTypes.object,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
emptyMessage: PropTypes.oneOfType([
PropTypes.element,
PropTypes.string,
]),
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
settings: PropTypes.element,
scrollName: PropTypes.string.isRequired,
timelineId: PropTypes.string.isRequired,
};
handlePin = () => {
const { columnName, columnProps, columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn(columnName, columnProps || {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(this.props.expand());
}
_subscribe (dispatch, streamId, timelineId) {
const { streamingAPIBaseURL, accessToken } = this.props;
if (!streamId || !timelineId) return;
this.subscription = createStream(streamingAPIBaseURL, accessToken, streamId, {
connected () {
dispatch(connectTimeline(timelineId));
},
reconnected () {
dispatch(connectTimeline(timelineId));
},
disconnected () {
dispatch(disconnectTimeline(timelineId));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
}
_unsubscribe () {
if (typeof this.subscription !== 'undefined') {
this.subscription.close();
this.subscription = null;
}
}
componentDidMount () {
const { dispatch, refresh, streamId, timelineId } = this.props;
if (typeof refresh !== 'function') return;
dispatch(refresh());
this._subscribe(dispatch, streamId, timelineId);
}
componentWillReceiveProps (nextProps) {
if (nextProps.streamId !== this.props.streamId || nextProps.timelineId !== this.props.timelineId) {
if (typeof refresh !== 'function') return;
this.props.dispatch(this.props.refresh());
this._unsubscribe();
this._subscribe(this.props.dispatch, nextProps.streamId, nextProps.timelineId);
}
}
componentWillUnmount () {
this._unsubscribe();
}
render () {
const {
hasUnread,
columnId,
multiColumn,
emptyMessage,
icon,
title,
settings,
scrollName,
timelineId,
} = this.props;
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon={icon}
active={hasUnread}
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
{settings}
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`${scrollName}-${columnId}`}
loadMore={this.handleLoadMore}
timelineId={timelineId}
emptyMessage={emptyMessage}
/>
</Column>
);
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class ImageLoader extends React.PureComponent {
@@ -20,46 +21,121 @@ export default class ImageLoader extends React.PureComponent {
error: false,
}
componentWillMount() {
this._loadImage(this.props.src);
removers = [];
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentWillReceiveProps(props) {
this._loadImage(props.src);
componentDidMount () {
this.loadImage(this.props);
}
_loadImage(src) {
componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
this.loadPreviewCanvas(props),
this.loadOriginalImage(props),
])
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
})
image.onerror = () => this.setState({ loading: false, error: true });
image.onload = () => this.setState({ loading: false, error: false });
image.src = src;
this.setState({ loading: true });
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
render() {
const { alt, src, previewSrc, width, height } = this.props;
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
setCanvasRef = c => {
this.canvas = c;
}
render () {
const { alt, src, width, height } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
});
return (
<div className='image-loader'>
<img
alt={alt}
className='image-loader__img'
src={src}
<div className={className}>
<canvas
className='image-loader__preview-canvas'
width={width}
height={height}
ref={this.setCanvasRef}
/>
{loading &&
{!loading && (
<img
alt=''
src={previewSrc}
className='image-loader__preview-img'
alt={alt}
className='image-loader__img'
src={src}
width={width}
height={height}
/>
}
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import OnboardingModal from './onboarding_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal';
import ReportModal from './report_modal';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
@@ -14,6 +15,7 @@ const MODAL_COMPONENTS = {
'VIDEO': VideoModal,
'BOOST': BoostModal,
'CONFIRM': ConfirmationModal,
'REPORT': ReportModal,
};
export default class ModalRoot extends React.PureComponent {

View File

@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ReactSwipeable from 'react-swipeable';
import classNames from 'classnames';
import Permalink from '../../../components/permalink';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
@@ -274,7 +275,7 @@ export default class OnboardingModal extends React.PureComponent {
<div className='modal-root__modal onboarding-modal'>
<TransitionMotion styles={styles}>
{interpolatedStyles => (
<div className='onboarding-modal__pager'>
<ReactSwipeable onSwipedRight={this.handlePrev} onSwipedLeft={this.handleNext} className='onboarding-modal__pager'>
{interpolatedStyles.map(({ key, data, style }, i) => {
const className = classNames('onboarding-modal__page__wrapper', {
'onboarding-modal__page__wrapper--active': i === currentIndex,
@@ -283,7 +284,7 @@ export default class OnboardingModal extends React.PureComponent {
<div key={key} style={style} className={className}>{data}</div>
);
})}
</div>
</ReactSwipeable>
)}
</TransitionMotion>

View File

@@ -1,19 +1,17 @@
import React from 'react';
import { connect } from 'react-redux';
import { changeReportComment, submitReport } from '../../actions/reports';
import { refreshAccountTimeline } from '../../actions/timelines';
import { changeReportComment, submitReport } from '../../../actions/reports';
import { refreshAccountTimeline } from '../../../actions/timelines';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import Button from '../../components/button';
import { makeGetAccount } from '../../selectors';
import { makeGetAccount } from '../../../selectors';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import StatusCheckBox from './containers/status_check_box_container';
import StatusCheckBox from '../../report/containers/status_check_box_container';
import Immutable from 'immutable';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Button from '../../../components/button';
const messages = defineMessages({
heading: { id: 'report.heading', defaultMessage: 'New report' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' },
});
@@ -37,11 +35,7 @@ const makeMapStateToProps = () => {
@connect(makeMapStateToProps)
@injectIntl
export default class Report extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
export default class ReportModal extends ImmutablePureComponent {
static propTypes = {
isSubmitting: PropTypes.bool,
@@ -52,17 +46,15 @@ export default class Report extends React.PureComponent {
intl: PropTypes.object.isRequired,
};
componentWillMount () {
if (!this.props.account) {
this.context.router.history.replace('/');
}
handleCommentChange = (e) => {
this.props.dispatch(changeReportComment(e.target.value));
}
handleSubmit = () => {
this.props.dispatch(submitReport());
}
componentDidMount () {
if (!this.props.account) {
return;
}
this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
}
@@ -72,15 +64,6 @@ export default class Report extends React.PureComponent {
}
}
handleCommentChange = (e) => {
this.props.dispatch(changeReportComment(e.target.value));
}
handleSubmit = () => {
this.props.dispatch(submitReport());
this.context.router.history.replace('/');
}
render () {
const { account, comment, intl, statusIds, isSubmitting } = this.props;
@@ -89,36 +72,33 @@ export default class Report extends React.PureComponent {
}
return (
<Column heading={intl.formatMessage(messages.heading)} icon='flag'>
<ColumnBackButtonSlim />
<div className='modal-root__modal report-modal'>
<div className='report-modal__target'>
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
</div>
<div className='report scrollable'>
<div className='report__target'>
<FormattedMessage id='report.target' defaultMessage='Reporting' />
<strong>{account.get('acct')}</strong>
</div>
<div className='scrollable report__statuses'>
<div className='report-modal__container'>
<div className='report-modal__statuses'>
<div>
{statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
</div>
</div>
<div className='report__textarea-wrapper'>
<div className='report-modal__comment'>
<textarea
className='report__textarea'
className='setting-text light'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
disabled={isSubmitting}
/>
<div className='report__submit'>
<div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
</div>
</div>
</div>
</Column>
<div className='report-modal__action-bar'>
<Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
</div>
</div>
);
}

View File

@@ -15,7 +15,6 @@ import { refreshHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
import Status from '../../features/status';
import GettingStarted from '../../features/getting_started';
import PublicTimeline from '../../features/public_timeline';
@@ -35,7 +34,6 @@ import GenericNotFound from '../../features/generic_not_found';
import FavouritedStatuses from '../../features/favourited_statuses';
import Blocks from '../../features/blocks';
import Mutes from '../../features/mutes';
import Report from '../../features/report';
// Small wrapper to pass multiColumn to the route components
const WrappedSwitch = ({ multiColumn, children }) => (
@@ -206,7 +204,6 @@ export default class UI extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/report' component={Report} content={children} />
<WrappedRoute component={GenericNotFound} content={children} />
</WrappedSwitch>

View File

@@ -191,14 +191,6 @@
{
"defaultMessage": "{name} boosted",
"id": "status.reblogged_by"
},
{
"defaultMessage": "Collapse",
"id": "status.collapse"
},
{
"defaultMessage": "Uncollapse",
"id": "status.uncollapse"
}
],
"path": "app/javascript/mastodon/components/status.json"
@@ -1135,6 +1127,23 @@
],
"path": "app/javascript/mastodon/features/ui/components/onboarding_modal.json"
},
{
"descriptors": [
{
"defaultMessage": "Additional comments",
"id": "report.placeholder"
},
{
"defaultMessage": "Submit",
"id": "report.submit"
},
{
"defaultMessage": "Report {target}",
"id": "report.target"
}
],
"path": "app/javascript/mastodon/features/ui/components/report_modal.json"
},
{
"descriptors": [
{
@@ -1178,4 +1187,4 @@
],
"path": "app/javascript/mastodon/features/ui/components/video_modal.json"
}
]
]

View File

@@ -136,14 +136,13 @@
"privacy.unlisted.long": "Do not post to public timelines",
"privacy.unlisted.short": "Unlisted",
"reply_indicator.cancel": "Cancel",
"report.heading": "New report",
"report.heading": "Report {target}",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting",
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"status.cannot_reblog": "This post cannot be boosted",
"status.collapse": "Collapse",
"status.delete": "Delete",
"status.favourite": "Favourite",
"status.load_more": "Load more",
@@ -160,7 +159,6 @@
"status.sensitive_warning": "Sensitive content",
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.uncollapse": "Uncollapse",
"status.unmute_conversation": "Unmute conversation",
"tabs_bar.compose": "Compose",
"tabs_bar.federated_timeline": "Federated",

View File

@@ -27,8 +27,8 @@
"column.notifications": "Notifications",
"column.public": "Fil public global",
"column_back_button.label": "Retour",
"column_header.pin": "Pin",
"column_header.unpin": "Unpin",
"column_header.pin": "Épingler",
"column_header.unpin": "Retirer",
"column_subheading.navigation": "Navigation",
"column_subheading.settings": "Paramètres",
"compose_form.lock_disclaimer": "Votre compte n'est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
@@ -101,7 +101,7 @@
"notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
"notifications.column_settings.alert": "Notifications locales",
"notifications.column_settings.favourite": "Favoris :",
"notifications.column_settings.follow": "Nouveaux abonné⋅e⋅s :",
"notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅s :",
"notifications.column_settings.mention": "Mentions :",
"notifications.column_settings.reblog": "Partages :",
"notifications.column_settings.show": "Afficher dans la colonne",

View File

@@ -136,10 +136,10 @@
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
"privacy.unlisted.short": "Niewidoczne",
"reply_indicator.cancel": "Anuluj",
"report.heading": "Nowe zgłoszenie",
"report.heading": "Zgłoś {target}",
"report.placeholder": "Dodatkowe komentarze",
"report.submit": "Wyślij",
"report.target": "Zgłaszanie",
"report.target": "Zgłaszanie {target}",
"search.placeholder": "Szukaj",
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
"status.cannot_reblog": "Ten post nie może zostać podbity",

View File

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

View File

@@ -1,5 +1,5 @@
@mixin avatar-radius() {
border-radius: $ui-avatar-border-size;
border-radius: 4px;
background: transparent no-repeat;
background-position: 50%;
background-clip: padding-box;

View File

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

View File

@@ -46,16 +46,17 @@
}
.avatar {
@include avatar-size(120px);
width: 120px;
margin: 0 auto;
margin-bottom: 15px;
position: relative;
z-index: 2;
img {
@include avatar-radius();
@include avatar-size(120px);
width: 120px;
height: 120px;
display: block;
border-radius: 120px;
}
}
@@ -282,14 +283,16 @@
}
.avatar {
@include avatar-size(60px);
width: 60px;
height: 60px;
float: left;
margin-right: 15px;
img {
@include avatar-radius();
@include avatar-size(60px);
display: block;
width: 60px;
height: 60px;
border-radius: 60px;
}
}
@@ -356,14 +359,15 @@
}
& > div {
@include avatar-size(48px);
float: left;
margin-right: 10px;
width: 48px;
height: 48px;
}
.avatar {
@include avatar-radius();
display: block;
border-radius: 4px;
}
.display-name {

View File

@@ -129,6 +129,11 @@
color: $ui-primary-color;
}
}
.positive-hint {
color: $valid-value-color;
font-weight: 500;
}
}
.simple_form {

View File

@@ -58,37 +58,6 @@
position: relative;
}
.column-collapsable {
position: relative;
.column-collapsable__content {
overflow: auto;
transition: 300ms ease;
opacity: 1;
max-height: 70vh;
}
&.collapsed .column-collapsable__content {
height: 0 !important;
opacity: 0;
}
.column-collapsable__button {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
&:hover {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
}
}
&.collapsed .column-collapsable__button {
color: $ui-primary-color;
background: lighten($ui-base-color, 4%);
}
}
.column-icon {
background: lighten($ui-base-color, 4%);
color: $ui-primary-color;
@@ -456,11 +425,9 @@
.reply-indicator__content {
font-size: 15px;
line-height: 20px;
color: $primary-text-color;
word-wrap: break-word;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
overflow: visible;
white-space: pre-wrap;
.emojione {
@@ -503,19 +470,10 @@
}
}
.status__content__spoiler-link {
background: lighten($ui-base-color, 30%);
&:hover {
background: lighten($ui-base-color, 33%);
text-decoration: none;
}
}
.status__content__text {
.status__content__spoiler {
display: none;
&.status__content__text--visible {
&.status__content__spoiler--visible {
display: block;
}
}
@@ -524,7 +482,7 @@
.status__content__spoiler-link {
display: inline-block;
border-radius: 2px;
background: transparent;
background: lighten($ui-base-color, 30%);
border: 0;
color: lighten($ui-base-color, 8%);
font-weight: 500;
@@ -533,6 +491,21 @@
text-transform: uppercase;
line-height: inherit;
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 {
@@ -544,7 +517,6 @@
padding: 8px 10px;
padding-left: 68px;
position: relative;
height: auto;
min-height: 48px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: default;
@@ -601,14 +573,6 @@
}
}
}
&.status-collapsed {
height: 48px;
.status__content {
height: 20px;
}
}
}
.notification-favourite {
@@ -622,8 +586,8 @@
}
.status__relative-time {
margin-left: auto;
color: lighten($ui-base-color, 26%);
float: right;
font-size: 14px;
}
@@ -638,23 +602,19 @@
}
.status__info {
margin: 2px 0 0;
font-size: 15px;
line-height: 24px;
}
.status__collapse-button {
float: right;
}
.status-check-box {
border-bottom: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid $ui-secondary-color;
display: flex;
.status__content {
background: lighten($ui-base-color, 4%);
flex: 1 1 auto;
padding: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -837,12 +797,9 @@
padding: 10px;
}
.account__header__wrapper {
.account__header {
flex: 0 0 auto;
background: lighten($ui-base-color, 4%);
}
.account__header {
text-align: center;
background-size: cover;
background-position: center;
@@ -907,42 +864,6 @@
}
}
.account__metadata {
display: block;
font-size: 15px;
line-height: 36px;
overflow: hidden;
.account__metadata-item {
display: flex;
flex-direction: row;
border-top: 1px solid lighten($ui-base-color, 8%);
& > span, & > strong {
display: inline-block;
padding: 10px 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& > span {
flex: 0 0 auto;
width: 120px;
color: $ui-primary-color;
background: lighten($ui-base-color, 13%);
font-variant: small-caps;
}
& > strong {
flex: auto;
color: $primary-text-color;
background: $ui-base-color;
font-weight: bold;
}
}
}
.account__action-bar {
border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -1004,11 +925,12 @@
}
.account__header__avatar {
@include avatar-radius();
@include avatar-size(90px);
background-size: 90px 90px;
display: block;
height: 90px;
margin: 0 auto 10px;
overflow: hidden;
width: 90px;
}
.account-authorize {
@@ -1185,20 +1107,22 @@
.image-loader {
position: relative;
}
.image-loader__preview-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
}
&.image-loader--loading {
.image-loader__preview-canvas {
filter: blur(2px);
}
}
.media-modal img.image-loader__preview-img {
width: 100%;
height: 100%;
.image-loader__img {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
background-image: none;
}
}
.navigation-bar {
@@ -1914,6 +1838,17 @@
@media screen and (max-width: 600px) {
font-size: 16px;
}
&.light {
color: $ui-base-color;
border-bottom: 2px solid lighten($ui-base-color, 27%);
&:focus,
&:active {
color: $ui-base-color;
border-bottom-color: $ui-highlight-color;
}
}
}
@import 'boost';
@@ -2165,11 +2100,6 @@ button.icon-button.active i.fa-retweet {
transition: max-height 150ms ease-in-out, opacity 300ms linear;
opacity: 1;
& > div {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
&.collapsed {
max-height: 0;
opacity: 0.5;
@@ -2180,6 +2110,11 @@ button.icon-button.active i.fa-retweet {
}
}
.column-header__collapsible-inner {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
.column-header__setting-btn {
&:hover {
color: lighten($ui-primary-color, 4%);
@@ -2371,67 +2306,6 @@ button.icon-button.active i.fa-retweet {
vertical-align: middle;
}
.report.scrollable {
box-sizing: border-box;
display: flex;
flex-direction: column;
max-height: 100%;
}
.report__target {
border-bottom: 1px solid lighten($ui-base-color, 4%);
color: $ui-secondary-color;
flex: 0 0 auto;
padding: 10px;
strong {
display: block;
color: $primary-text-color;
font-weight: 500;
}
}
.report__statuses {
flex: 1 1 auto;
}
.report__textarea-wrapper {
flex: 0 0 100px;
padding: 10px;
}
.report__textarea {
background: transparent;
box-sizing: border-box;
border: 0;
border-bottom: 2px solid $ui-primary-color;
border-radius: 2px 2px 0 0;
color: $primary-text-color;
display: block;
font-family: inherit;
font-size: 14px;
margin-bottom: 10px;
outline: 0;
padding: 7px 4px;
resize: vertical;
width: 100%;
&:active,
&:focus {
border-bottom-color: $ui-highlight-color;
background: rgba($base-overlay-background, 0.1);
}
}
.report__submit {
margin-top: 10px;
overflow: hidden;
}
.report__submit-button {
float: right;
}
.empty-column-indicator {
color: lighten($ui-base-color, 20%);
background: $ui-base-color;
@@ -3019,6 +2893,7 @@ button.icon-button.active i.fa-retweet {
position: relative;
img,
canvas,
video {
max-width: 80vw;
max-height: 80vh;
@@ -3026,7 +2901,8 @@ button.icon-button.active i.fa-retweet {
height: auto;
}
img {
img,
canvas {
display: block;
background: url('../images/void.png') repeat;
}
@@ -3212,6 +3088,7 @@ button.icon-button.active i.fa-retweet {
@media screen and (max-width: 400px) {
.onboarding-modal__page-one {
flex-direction: column;
align-items: normal;
}
.onboarding-modal__page-one__elephant-friend {
@@ -3326,7 +3203,8 @@ button.icon-button.active i.fa-retweet {
}
.boost-modal,
.confirmation-modal {
.confirmation-modal,
.report-modal {
background: lighten($ui-secondary-color, 8%);
color: $ui-base-color;
border-radius: 8px;
@@ -3362,7 +3240,8 @@ button.icon-button.active i.fa-retweet {
}
.boost-modal__action-bar,
.confirmation-modal__action-bar {
.confirmation-modal__action-bar,
.report-modal__action-bar {
display: flex;
justify-content: space-between;
background: $ui-secondary-color;
@@ -3398,6 +3277,23 @@ button.icon-button.active i.fa-retweet {
}
}
.report-modal__statuses,
.report-modal__comment {
padding: 10px;
}
.report-modal__statuses {
min-height: 20vh;
overflow-y: auto;
overflow-x: hidden;
}
.report-modal__comment {
.setting-text {
margin-top: 10px;
}
}
.confirmation-modal__action-bar {
.confirmation-modal__cancel-button {
background-color: transparent;
@@ -3413,7 +3309,8 @@ button.icon-button.active i.fa-retweet {
}
}
.confirmation-modal__container {
.confirmation-modal__container,
.report-modal__target {
padding: 30px;
font-size: 16px;
text-align: center;
@@ -3534,10 +3431,15 @@ button.icon-button.active i.fa-retweet {
background-repeat: no-repeat;
background-size: cover;
cursor: zoom-in;
display: block;
height: 100%;
display: flex;
align-items: center;
text-decoration: none;
width: 100%;
height: 100%;
&,
img {
width: 100%;
}
}
.media-gallery__gifv {

View File

@@ -1,123 +0,0 @@
@import 'application';
@media screen and (min-width: 1300px) {
.column {
flex-grow: 1 !important;
max-width: 400px;
}
.drawer {
flex-grow: 1 !important;
flex-basis: 200px !important;
min-width: 268px;
max-width: 400px;
}
}
.muted {
.status__content p, .status__content a {
color: lighten($ui-base-color, 35%);
}
.status__display-name strong {
color: lighten($ui-base-color, 35%);
}
}
.status time:after,
.detailed-status__datetime span:after {
font: normal normal normal 14px/1 FontAwesome;
content: "\00a0\00a0\f08e";
}
.compose-form__buttons button.active:last-child {
color:$ui-secondary-color;
background-color: $ui-highlight-color;
border-radius:3px;
}
.about-body .mascot {
display:none;
}
.screenshot-with-signup {
min-height:300px;
}
.screenshot-with-signup .closed-registrations-message,
.screenshot-with-signup form {
background-color: rgba(0,0,0,0.7);
margin:auto;
}
.screenshot-with-signup .closed-registrations-message .clock {
font-size:150%;
}
.drawer .drawer__inner {
overflow: visible;
height:inherit;
}
.drawer__pager {
overflow-y:auto;
}
.column {
// trying to fix @mdhughes safari problem
max-height:100vh;
}
.media-gallery {
height:auto !important;
max-height:30vh;
position:relative;
margin-top:20px;
margin-left:-68px;
width: calc(100% + 80px);
}
.media-gallery:before{
content: "";
display: block;
padding-top: 100%;
}
.media-gallery__item,
.media-gallery .media-spoiler{
left: 0;
right: 0;
top: 0;
bottom: 0 !important;
position:absolute;
}
.media-spoiler-video:before {
content:"";
display:block;
padding-top:100%;
}
.media-spoiler-video,
.status__video-player,
.detailed-status > .media-spoiler,
.status > .media-spoiler {
height:auto !important;
max-height:30vh;
position:relative;
margin-top:20px;
margin-left:-68px;
width: calc(100% + 80px) !important;
}
.status__video-player-video {
transform:unset;
}
.detailed-status > .media-spoiler,
.status > .media-spoiler {
height:30vh !important;
vertical-align:middle;
}

View File

@@ -358,7 +358,6 @@ code {
}
.user_filtered_languages {
& > label {
font-family: inherit;
font-size: 16px;

View File

@@ -10,7 +10,6 @@
.recovery-codes {
list-style: none;
margin: 0 auto;
text-align: center;
li {
font-size: 125%;

View File

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

View File

@@ -42,6 +42,18 @@
strong {
font-weight: 500;
}
&.inline-table {
td,
th {
padding: 8px 0;
}
& > tbody > tr:nth-child(odd) > td,
& > tbody > tr:nth-child(odd) > th {
background: transparent;
}
}
}
samp {

View File

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

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class AdminMailer < ApplicationMailer
def new_report(recipient, report)
@report = report
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id)
end
end
end

View File

@@ -4,4 +4,12 @@ class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('SMTP_FROM_ADDRESS') { 'notifications@localhost' }
layout 'mailer'
helper :instance
protected
def locale_for_account(account)
I18n.with_locale(account.user_locale || I18n.default_locale) do
yield
end
end
end

View File

@@ -67,12 +67,4 @@ class NotificationMailer < ApplicationMailer
)
end
end
private
def locale_for_account(account)
I18n.with_locale(account.user_locale || I18n.default_locale) do
yield
end
end
end

View File

@@ -3,36 +3,78 @@
#
# Table name: session_activations
#
# id :integer not null, primary key
# user_id :integer not null
# session_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# user_id :integer not null
# session_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_agent :string default(""), not null
# ip :inet
# access_token_id :integer
#
class SessionActivation < ApplicationRecord
LIMIT = Rails.configuration.x.max_session_activations
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
def self.active?(id)
id && where(session_id: id).exists?
delegate :token,
to: :access_token,
allow_nil: true
def detection
@detection ||= Browser.new(user_agent)
end
def self.activate(id)
activation = create!(session_id: id)
purge_old
activation
def browser
detection.id
end
def self.deactivate(id)
return unless id
where(session_id: id).destroy_all
def platform
detection.platform.id
end
def self.purge_old
order('created_at desc').offset(LIMIT).destroy_all
before_create :assign_access_token
before_save :assign_user_agent
class << self
def active?(id)
id && where(session_id: id).exists?
end
def activate(options = {})
activation = create!(options)
purge_old
activation
end
def deactivate(id)
return unless id
where(session_id: id).destroy_all
end
def purge_old
order('created_at desc').offset(Rails.configuration.x.max_session_activations).destroy_all
end
def exclusive(id)
where('session_id != ?', id).destroy_all
end
end
def self.exclusive(id)
where('session_id != ?', id).destroy_all
private
def assign_user_agent
self.user_agent = '' if user_agent.nil?
end
def assign_access_token
superapp = Doorkeeper::Application.find_by(superapp: true)
return if superapp.nil?
self.access_token = Doorkeeper::AccessToken.create!(application_id: superapp.id,
resource_owner_id: user_id,
scopes: 'read write follow',
expires_in: Doorkeeper.configuration.access_token_expires_in,
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
end
end

View File

@@ -91,8 +91,10 @@ class User < ApplicationRecord
settings.auto_play_gif
end
def activate_session
session_activations.activate(SecureRandom.hex).session_id
def activate_session(request)
session_activations.activate(session_id: SecureRandom.hex,
user_agent: request.user_agent,
ip: request.ip).session_id
end
def exclusive_session(id)

View File

@@ -13,7 +13,8 @@ class SendInteractionService < BaseService
return if block_notification?
envelope = salmon.pack(@xml, @source_account.keypair)
salmon.post(@target_account.salmon_url, envelope)
delivery = salmon.post(@target_account.salmon_url, envelope)
raise "Delivery failed for #{target_account.salmon_url}: HTTP #{delivery.code}" unless delivery.code > 199 && delivery.code < 300
end
private

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class StatusLengthValidator < ActiveModel::Validator
MAX_CHARS = 512
MAX_CHARS = 500
def validate(status)
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('auth.login'), new_user_session_path
%li= link_to t('about.terms'), terms_path
%li= link_to t('about.source_code'), 'https://github.com/chronister/mastodon'
%li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'

View File

@@ -36,7 +36,7 @@
.info
= link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
·
= link_to t('about.other_instances'), 'https://instances.mastodon.xyz/'
= link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'
·
= link_to t('about.about_this'), about_more_path
@@ -82,6 +82,6 @@
·
= link_to t('about.apps'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md'
·
= link_to t('about.source_code'), 'https://github.com/chronister/mastodon'
= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
·
= link_to t('about.other_instances'), 'https://instances.mastodon.xyz/'
= link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'

View File

@@ -0,0 +1,5 @@
<%= display_name(@me) %>,
<%= raw t('admin_mailer.new_report.body', target: @report.target_account.acct, reporter: @report.account.acct) %>
<%= raw t('application_mailer.view')%> <%= admin_report_url(@report) %>

View File

@@ -0,0 +1,23 @@
%h6= t 'sessions.title'
%p.muted-hint= t 'sessions.explanation'
%table.table.inline-table
%thead
%tr
%th= t 'sessions.browser'
%th= t 'sessions.ip'
%th= t 'sessions.activity'
%tbody
- @sessions.each do |session|
%tr
%td
%span{ title: session.user_agent }= fa_icon session_device_icon(session)
= ' '
= t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
%td
%samp= session.ip
%td
- if request.session['auth_id'] == session.session_id
= t 'sessions.current_session'
- else
%time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)

View File

@@ -12,6 +12,10 @@
.actions
= f.button :button, t('generic.save_changes'), type: :submit
%hr/
= render 'sessions'
- if open_deletion?
%hr/

View File

@@ -1,7 +1,7 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
%p.hint= t('two_factor_authentication.recovery_instructions')
%p.hint= t('two_factor_authentication.recovery_instructions_html')
%ol.recovery-codes
- @recovery_codes.each do |code|

View File

@@ -1,26 +1,34 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
.simple_form
%p.hint
= t('two_factor_authentication.description_html')
- if current_user.otp_required_for_login
%p.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
%hr/
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
= f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt')
.actions
= f.button :button, t('two_factor_authentication.disable'), type: :submit
%hr/
%h6= t('two_factor_authentication.recovery_codes')
%p.muted-hint
= t('two_factor_authentication.lost_recovery_codes')
= link_to t('two_factor_authentication.generate_recovery_codes'),
settings_two_factor_authentication_recovery_codes_path,
data: { method: :post }
- else
.simple_form
%p.hint= t('two_factor_authentication.description_html')
- if current_user.otp_required_for_login
= link_to t('two_factor_authentication.disable'),
settings_two_factor_authentication_path,
data: { method: :delete },
class: 'block-button'
- else
= link_to t('two_factor_authentication.setup'),
settings_two_factor_authentication_path,
data: { method: :post },
class: 'block-button'
- if current_user.otp_required_for_login
.simple_form
%p.hint
= t('two_factor_authentication.lost_recovery_codes')
= link_to t('two_factor_authentication.generate_recovery_codes'),
settings_two_factor_authentication_recovery_codes_path,
data: { method: :post },
class: 'block-button'

View File

@@ -1,3 +1,3 @@
.media-spoiler
.media-spoiler><
%span= t('stream_entries.sensitive_content')
%span= t('stream_entries.click_to_show')

View File

@@ -12,21 +12,22 @@
%p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp;
%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?
- if status.media_attachments.first.video?
.video-player
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
%video.u-video{ src: status.media_attachments.first.file.url(:original), loop: true }
- else
.detailed-status__attachments
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
.status__attachments__inner
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }
- unless status.media_attachments.empty?
- if status.media_attachments.first.video?
.video-player><
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
%video.u-video{ src: status.media_attachments.first.file.url(:original), loop: true }
- else
.detailed-status__attachments><
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
.status__attachments__inner<
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }
.detailed-status__meta
%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
- unless media.image?
%video{ src: media.file.url(:original), autoplay: true, loop: true }/

View File

@@ -18,19 +18,20 @@
%p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp;
%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?
.status__attachments
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- if status.media_attachments.first.video?
.status__attachments__inner
.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
.video-item__play
= fa_icon('play')
- else
.status__attachments__inner
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }
- unless status.media_attachments.empty?
.status__attachments><
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- if status.media_attachments.first.video?
.status__attachments__inner<
.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
.video-item__play
= fa_icon('play')
- else
.status__attachments__inner<
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }

View File

@@ -1,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>We're contacting you to notify you that your password on Mastodon has been changed.</p>
<p>We're contacting you to notify you that your password on <%= @instance %> has been changed.</p>

View File

@@ -1,3 +1,3 @@
Hello <%= @resource.email %>!
We're contacting you to notify you that your password on Mastodon has been changed.
We're contacting you to notify you that your password on <%= @instance %> has been changed.

View File

@@ -1,3 +1,3 @@
<p>Witaj, <%= @resource.email %>!</p>
<p>Informujemy, że ostatnio zmieniono Twoje hasło Mastodona.</p>
<p>Informujemy, że ostatnio zmieniono Twoje hasło na <%= @instance %>.</p>

View File

@@ -1,3 +1,3 @@
Witaj, <%= @resource.email %>!
Informujemy, że ostatnio zmieniono Twoje hasło Mastodona.
Informujemy, że ostatnio zmieniono Twoje hasło na <%= @instance %>.

View File

@@ -1,6 +1,6 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password on Mastodon. You can do this through the link below.</p>
<p>Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>

View File

@@ -1,6 +1,6 @@
Hello <%= @resource.email %>!
Someone has requested a link to change your password on Mastodon. You can do this through the link below.
Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.
<%= edit_password_url(@resource, reset_password_token: @token) %>

View File

@@ -1,6 +1,7 @@
<p>Witaj, <%= @resource.email %>!</p>
<p>Ktoś próbował zmienić Twoje hasło na Mastodonie. Możesz zrobić to klikając w poniższy link.</p>
<p>Ktoś próbował zmienić Twoje hasło na <%= @instance %>. Możesz zrobić to klikając w
poniższy link.</p>
<p><%= link_to 'Zmień moje hasło', edit_password_url(@resource, reset_password_token: @token) %></p>

View File

@@ -1,6 +1,7 @@
Witaj, <%= @resource.email %>!
Ktoś próbował zmienić Twoje hasło na Mastodonie. Możesz zrobić to klikając w poniższy link.
Ktoś próbował zmienić Twoje hasło na <%= @instance %>. Możesz zrobić to klikając w
poniższy link.
<%= edit_password_url(@resource, reset_password_token: @token) %>

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'sidekiq-scheduler'
class Scheduler::DoorkeeperCleanupScheduler
include Sidekiq::Worker
def perform
Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
end
end

View File

@@ -67,17 +67,12 @@ module Mastodon
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
allow do
origins '*'
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 '/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

View File

@@ -7,6 +7,10 @@ default: &default
development:
<<: *default
database: mastodon_development
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASS'] %>
host: <%= ENV['DB_HOST'] %>
port: <%= ENV['DB_PORT'] %>
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
@@ -14,6 +18,10 @@ development:
test:
<<: *default
database: mastodon_test<%= ENV['TEST_ENV_NUMBER'] %>
username: <%= ENV['DB_USER'] %>
password: <%= ENV['DB_PASS'] %>
host: <%= ENV['DB_HOST'] %>
port: <%= ENV['DB_PORT'] %>
production:
<<: *default

View File

@@ -93,12 +93,9 @@ Rails.application.configure do
end
config.action_dispatch.default_headers = {
'Server' => 'Mastodon',
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'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'
'Server' => 'Mastodon',
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
}
end

View File

@@ -1,6 +1,6 @@
Warden::Manager.after_set_user except: :fetch do |user, warden|
SessionActivation.deactivate warden.raw_session['auth_id']
warden.raw_session['auth_id'] = user.activate_session
warden.raw_session['auth_id'] = user.activate_session(warden.request)
end
Warden::Manager.after_fetch do |user, warden|

View File

@@ -360,7 +360,7 @@ ca:
lost_recovery_codes: Els codis de recuperació et permeten recuperar l'accés al teu compte si perds el telèfon. Si has perdut els teus codis de recuperació els pots regenerar aquí. Els codis de recuperació anteriors seran anul·lats.
manual_instructions: 'Si no pots escanejar el codi QR code i necessites introduir-lo manualment, aquí tens el secret en text plà:'
recovery_codes_regenerated: Codis de recuperació regenerats amb èxit
recovery_instructions: Si alguna vegada perds l'accéss al telèfon pots utilitzar un dels codis de recuperació a continuació per recuperar l'accés al teu compte. Cal mantenir els codis de recuperació en lloc segur, per exemple imprimint-los i guardar-los amb altres documents importants.
recovery_instructions_html: Si alguna vegada perds l'accéss al telèfon pots utilitzar un dels codis de recuperació a continuació per recuperar l'accés al teu compte. Cal mantenir els codis de recuperació en lloc segur, per exemple imprimint-los i guardar-los amb altres documents importants.
setup: Establir
wrong_code: El codi introduït es invalid! Es correcta la hora del servidor i del dispositiu?
users:

View File

@@ -304,7 +304,7 @@ de:
lost_recovery_codes: Wiederherstellungscodes erlauben dir, wieder den Zugang zu deinem Konto zu erlangen, falls du dein Telefon verlierst. Wenn du deine Wiederherstellungscodes verloren hast, kannst du sie hier regenerieren. Deine alten Wiederherstellungscodes werden damit ungültig gemacht.
manual_instructions: 'Wenn du den QR-Code nicht einlesen kannst und ihn manuell eingeben musst, ist hier das Klartext-Geheimnis:'
recovery_codes_regenerated: Wiederherstellungscodes erfolgreich regeneriert
recovery_instructions: Wenn du jemals den Zugang zu deinem Telefon verlierst, kannst du einen der Wiederherstellungscodes unten benutzen, um wieder auf dein Konto zugreifen zu können. Bewahre die Wiederherstellungscodes sicher auf, indem du sie beispielsweise ausdruckst und sie zusammen mit anderen wichtigen Dokumenten lagerst.
recovery_instructions_html: Wenn du jemals den Zugang zu deinem Telefon verlierst, kannst du einen der Wiederherstellungscodes unten benutzen, um wieder auf dein Konto zugreifen zu können. Bewahre die Wiederherstellungscodes sicher auf, indem du sie beispielsweise ausdruckst und sie zusammen mit anderen wichtigen Dokumenten lagerst.
setup: Einrichten
wrong_code: Der eingegebene Code war ungültig! Sind die Server- und die Gerätezeit korrekt?
users:

View File

@@ -3,15 +3,15 @@ pl:
devise:
confirmations:
confirmed: Twój adres e-mail został poprawnie zweryfikowany.
send_instructions: W ciągu kilku minut otrzymasz wiadomosć e-mail z instrukcją jak potwierdzić Twój adres e-mail.
send_paranoid_instructions: Jeśli Twój adres e-mail już istnieje w naszej bazie danych, w ciągu kilku minut otrzymasz wiadomość e-mail z instrukcją jak potwierdzić Twój adres e-mail.
send_instructions: W ciągu kilku minut otrzymasz wiadomosć e-mail z instrukcją jak potwierdzić Twój adres e-mail. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
send_paranoid_instructions: Jeśli Twój adres e-mail już istnieje w naszej bazie danych, w ciągu kilku minut otrzymasz wiadomość e-mail z instrukcją jak potwierdzić Twój adres e-mail. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
failure:
already_authenticated: Jesteś już zalogowany/zalogowana.
inactive: Twoje konto nie zostało jeszcze aktywowane.
invalid: Błędne %{authentication_keys} lub hasło.
invalid: Nieprawidłowy %{authentication_keys} lub hasło.
last_attempt: Masz jeszcze jedną próbę; Twoje konto zostanie zablokowane jeśli się nie powiedzie.
locked: Twoje konto zostało zablokowane.
not_found_in_database: Błędne %{authentication_keys} lub hasło.
not_found_in_database: Nieprawidłowy %{authentication_keys} lub hasło.
timeout: Twoja sesja wygasła. Zaloguj się ponownie aby kontynuować..
unauthenticated: Zapisz się lub zaloguj aby kontynuować.
unconfirmed: Zweryfikuj adres e-mail aby kontynuować.
@@ -29,8 +29,8 @@ pl:
success: Uwierzytelnienie przez %{kind} powiodło się.
passwords:
no_token: Dostęp do tej strony możliwy jest wyłącznie za pomocą odnośnika z e-maila z instrukcjami ustawienia nowego hasła. Jeśli skorzystałeś/aś z takiego odnośnika, upewnij się, że został wykorzystany/skopiowany cały odnośnik.
send_instructions: W ciągu kilku minut otrzymasz wiadomość e-mail z instrukcją ustawienia nowego hasła.
send_paranoid_instructions: Jeśli Twój adres e-mail już istnieje w naszej bazie danych, w ciągu kilku minut otrzymasz wiadomość e-mail zawierającą odnośnik pozwalający na ustawienie nowego hasła.
send_instructions: W ciągu kilku minut otrzymasz wiadomość e-mail z instrukcją ustawienia nowego hasła. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
send_paranoid_instructions: Jeśli Twój adres e-mail już istnieje w naszej bazie danych, w ciągu kilku minut otrzymasz wiadomość e-mail zawierającą odnośnik pozwalający na ustawienie nowego hasła. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
updated: Twoje hasło zostało zmienione. Jesteś zalogowany/a.
updated_not_active: Twoje hasło zostało zmienione.
registrations:
@@ -38,16 +38,16 @@ pl:
signed_up: Twoje konto zostało utworzone. Witamy!
signed_up_but_inactive: Twoje konto zostało utworzone. Nie mogliśmy Cię jednak zalogować, ponieważ konto nie zostało jeszcze aktywowane.
signed_up_but_locked: Twoje konto zostało utworzone. Nie mogliśmy Cię jednak zalogować, ponieważ konto jest zablokowane.
signed_up_but_unconfirmed: Na Twój adres e-mail została wysłana wiadomosć z odnośnikiem potwierdzającym. Kliknij w odnośnik aby aktywować konto.
update_needs_confirmation: Konto zostało zaktualizowane, musimy jednak zweryfikować Twój nowy adres e-mail. Została na niego wysłana wiadomość z odnośnikiem potwierdzającym.
signed_up_but_unconfirmed: Na Twój adres e-mail została wysłana wiadomosć z odnośnikiem potwierdzającym. Kliknij w odnośnik aby aktywować konto. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
update_needs_confirmation: Konto zostało zaktualizowane, musimy jednak zweryfikować Twój nowy adres e-mail. Została na niego wysłana wiadomość z odnośnikiem potwierdzającym. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
updated: Konto zostało zaktualizowane.
sessions:
already_signed_out: Zostałeś/aś wylogowany/a.
signed_in: Zostałeś/aś zalogowany/a.
signed_out: Zostałeś/aś wylogowany/a.
unlocks:
send_instructions: W ciągu kilku minut otrzymasz wiadomość e-mail z instrukcjami odblokowania konta.
send_paranoid_instructions: Jeśli Twoje konto istnieje, instrukcje odblokowania go otrzymasz w wiadomości e-mail w ciągu kilku minut.
send_instructions: W ciągu kilku minut otrzymasz wiadomość e-mail z instrukcjami odblokowania konta. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
send_paranoid_instructions: Jeśli Twoje konto istnieje, instrukcje odblokowania go otrzymasz w wiadomości e-mail w ciągu kilku minut. Jeżeli nie otrzymano wiadomości, sprawdź folder ze spamem.
unlocked: Twoje konto zostało odblokowane. Zaloguj się aby kontynuować.
errors:
messages:

View File

@@ -193,6 +193,10 @@ en:
title: PubSubHubbub
topic: Topic
title: Administration
admin_mailer:
new_report:
body: "%{reporter} has reported %{target}"
subject: New report for %{instance} (#%{id})
application_mailer:
settings: 'Change e-mail preferences: %{link}'
signature: Mastodon notifications from %{instance}
@@ -200,7 +204,7 @@ en:
applications:
invalid_url: The provided URL is invalid
auth:
change_password: Credentials
change_password: Security
delete_account: Delete account
delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
didnt_get_confirmation: Didn't receive confirmation instructions?
@@ -320,6 +324,43 @@ en:
missing_resource: Could not find the required redirect URL for your account
proceed: Proceed to follow
prompt: 'You are going to follow:'
sessions:
activity: Last activity
browser: Browser
browsers:
alipay: Alipay
blackberry: Blackberry
chrome: Chrome
edge: Microsoft Edge
firefox: Firefox
generic: Unknown browser
ie: Internet Explorer
micro_messenger: MicroMessenger
nokia: Nokia S40 Ovi Browser
opera: Opera
phantom_js: PhantomJS
qq: QQ Browser
safari: Safari
uc_browser: UCBrowser
weibo: Weibo
current_session: Current session
description: "%{browser} on %{platform}"
explanation: These are the web browsers currently logged in to your Mastodon account.
ip: IP
platforms:
adobe_air: Adobe Air
android: Android
blackberry: Blackberry
chrome_os: ChromeOS
firefox_os: Firefox OS
ios: iOS
linux: Linux
mac: Mac
other: unknown platform
windows: Windows
windows_mobile: Windows Mobile
windows_phone: Windows Phone
title: Sessions
settings:
authorized_apps: Authorized apps
back: Back to Mastodon
@@ -354,13 +395,15 @@ en:
description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
disable: Disable
enable: Enable
enabled: Two-factor authentication is enabled
enabled_success: Two-factor authentication successfully enabled
generate_recovery_codes: Generate Recovery Codes
generate_recovery_codes: Generate recovery codes
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
recovery_codes: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated
recovery_instructions: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. (For example, you may print them and store them with other important documents.)
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct?
users:

View File

@@ -334,7 +334,7 @@ fa:
lost_recovery_codes: با کدهای بازیابی می‌توانید اگر تلفن خود را گم کردید به حساب خود دسترسی داشته باشید. اگر کدهای بازیابی خود را گم کردید، آن‌ها را این‌جا دوباره بسازید. کدهای بازیابی قبلی شما نامعتبر خواهند شد.
manual_instructions: 'اگر نمی‌توانید کدها را اسکن کنید و باید آن‌ها را دستی وارد کنید، متن کد امنیتی این‌جاست:'
recovery_codes_regenerated: کدهای بازیابی با موفقیت ساخته شدند
recovery_instructions: اگر تلفن خود را گم کردید، می‌توانید با یکی از کدهای بازیابی زیر کنترل حساب خود را به دست بگیرید. این کدها را در جای امنی نگه دارید، مثلاً آن‌ها را چاپ کنید و کنار سایر مدارک مهم خود قرار دهید
recovery_instructions_html: اگر تلفن خود را گم کردید، می‌توانید با یکی از کدهای بازیابی زیر کنترل حساب خود را به دست بگیرید. این کدها را در جای امنی نگه دارید، مثلاً آن‌ها را چاپ کنید و کنار سایر مدارک مهم خود قرار دهید
setup: راه اندازی
wrong_code: کدی که وارد کردید نامعتبر بود! آیا ساعت سرور و ساعت دستگاه شما درست تنظیم شده‌اند؟
users:

View File

@@ -238,7 +238,7 @@ fr:
mention: "%{name} vous a mentionné⋅e"
new_followers_summary:
one: Vous avez un⋅e nouvel⋅le abonné⋅e ! Youpi !
other: Vous avez %{count} nouveaux abonné⋅es ! Incroyable !
other: Vous avez %{count} nouveaux⋅elles abonné⋅es ! Incroyable !
subject:
one: "Une nouvelle notification depuis votre dernière visite \U0001F418"
other: "%{count} nouvelles notifications depuis votre dernière visite \U0001F418"
@@ -300,7 +300,7 @@ fr:
lost_recovery_codes: Les codes de récupération vous permettent de retrouver les accès à votre comptre si vous perdez votre téléphone. Si vous perdez vos codes de récupération, vous pouvez les générer à nouveau ici. Vos anciens codes de récupération seront invalidés.
manual_instructions: 'Si vous ne pouvez pas scanner ce QR code et devez l''entrer manuellement, voici le secret en clair :'
recovery_codes_regenerated: Codes de récupération régénérés avec succès
recovery_instructions: Si vous perdez l'accès à votre téléphone, vous pouvez utiliser un des codes de récupération ci-dessous pour récupérer l'accès à votre compte. Conservez les codes de récupération en toute sécurité, par exemple, en les imprimant et en les stockant avec vos autres documents importants.
recovery_instructions_html: Si vous perdez l'accès à votre téléphone, vous pouvez utiliser un des codes de récupération ci-dessous pour récupérer l'accès à votre compte. Conservez les codes de récupération en toute sécurité, par exemple, en les imprimant et en les stockant avec vos autres documents importants.
setup: Installer
wrong_code: Les codes entrés sont incorrects ! L'heure du serveur et celle de votre appareil sont-elles correctes ?
users:

View File

@@ -342,7 +342,7 @@ he:
lost_recovery_codes: קודי האחזור מאפשרים אחזור גישה לחשבון במידה ומכשירך אבד. במידה וקודי האחזור אבדו, ניתן לייצרם מחדש כאן. תוקף קודי האחזור הישנים יפוג.
manual_instructions: 'במידה ולא ניתן לסרוק את קוד ה-QR אלא יש צורך להקליד אותו ידנית, להלן סוד כמוס בלתי מוצפן:'
recovery_codes_regenerated: קודי האחזור יוצרו בהצלחה
recovery_instructions: במידה והגישה למכשירך תאבד, ניתן לייצר קודי אחזור למטה על מנת לאחזר גישה לחשבונך בכל עת. נא לשמור על קודי הגישה במקום בטוח )לדוגמא על ידי הדפסתם ושמירתם עם מסמכים חשובים אחרים, או שימוש בתוכנה ייעודית לניהול סיסמאות וסודות(
recovery_instructions_html: במידה והגישה למכשירך תאבד, ניתן לייצר קודי אחזור למטה על מנת לאחזר גישה לחשבונך בכל עת. נא לשמור על קודי הגישה במקום בטוח )לדוגמא על ידי הדפסתם ושמירתם עם מסמכים חשובים אחרים, או שימוש בתוכנה ייעודית לניהול סיסמאות וסודות(
setup: הכנה
wrong_code: הקוד שהוזן שגוי! האם הזמן בשרת והזמן במכשירך נכונים?
users:

View File

@@ -331,7 +331,7 @@ id:
lost_recovery_codes: Kode pemulihan bisa anda gunakan untuk mendapatkan kembali akses pada akun anda jika anda kehilangan handphone anda. Jika anda kehilangan kode pemulihan, anda bisa membuatnya ulang disini. Kode pemulihan anda yang lama tidak akan bisa digunakan lagi.
manual_instructions: 'Jika anda tidak bisa memindai kode QR dan harus memasukkannya secara manual, ini dia kode yang harus dimasukkan:'
recovery_codes_regenerated: Kode Pemulihan berhasil dibuat ulang
recovery_instructions: Jika anda kehilangan akses pada handphone anda, anda bisa menggunakan kode pemulihan dibawah ini untuk mendapatkan kembali akses pada akun anda. Simpan kode pemulihan anda baik-baik, misalnya dengan mencetaknya atau menyimpannya bersama dokumen penting lainnya.
recovery_instructions_html: Jika anda kehilangan akses pada handphone anda, anda bisa menggunakan kode pemulihan dibawah ini untuk mendapatkan kembali akses pada akun anda. Simpan kode pemulihan anda baik-baik, misalnya dengan mencetaknya atau menyimpannya bersama dokumen penting lainnya.
setup: Persiapan
wrong_code: Kode yang dimasukkan tidak cocok! Apa waktu server dan waktu di handphone sudah cocok?
users:

View File

@@ -303,7 +303,7 @@ io:
lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
recovery_codes_regenerated: Recovery codes successfully regenerated
recovery_instructions: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe, for example by printing them and storing them with other important documents.
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe, for example by printing them and storing them with other important documents.
setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct?
users:

View File

@@ -360,7 +360,7 @@ ja:
lost_recovery_codes: リカバリーコードを使用すると携帯電話を紛失した場合でもアカウントにアクセスできるようになります。 リカバリーコードを紛失した場合もここで再生成することができますが、古いリカバリーコードは無効になります。
manual_instructions: 'QRコードがスキャンできず、手動での登録を希望の場合はこのシークレットコードを利用してください。:'
recovery_codes_regenerated: リカバリーコードが再生成されました。
recovery_instructions: 携帯電話を紛失した場合、以下の内どれかのリカバリーコードを使用してアカウントへアクセスすることができます。 リカバリーコードは印刷して安全に保管してください。
recovery_instructions_html: 携帯電話を紛失した場合、以下の内どれかのリカバリーコードを使用してアカウントへアクセスすることができます。 リカバリーコードは印刷して安全に保管してください。
setup: 初期設定
wrong_code: コードが間違っています。サーバー上の時間とデバイス上の時間が一致していることを確認してください。
users:

View File

@@ -228,7 +228,7 @@ nl:
lost_recovery_codes: Met herstelcodes kun je toegang tot jouw account krijgen wanneer je jouw telefoon bent kwijtgeraakt. Wanneer je jouw herstelcodes bent kwijtgeraakt, kan je ze hier opnieuw genereren. Jouw oude herstelcodes zijn daarna ongeldig.
manual_instructions: 'Hieronder vind je de geheime code in platte tekst. Voor het geval je de QR-code niet kunt scannen en het handmatig moet invoeren.'
recovery_codes_regenerated: Opnieuw genereren herstelcodes geslaagd
recovery_instructions: Wanneer je ooit de toegang verliest tot jouw telefoon, kan je met behulp van een van de herstelcodes hieronder opnieuw toegang krijgen tot jouw account. Zorg ervoor dat je de herstelcodes op een veilige plek bewaard. (Je kunt ze bijvoorbeeld printen en ze samen met andere belangrijke documenten bewaren.)
recovery_instructions_html: Wanneer je ooit de toegang verliest tot jouw telefoon, kan je met behulp van een van de herstelcodes hieronder opnieuw toegang krijgen tot jouw account. Zorg ervoor dat je de herstelcodes op een veilige plek bewaard. (Je kunt ze bijvoorbeeld printen en ze samen met andere belangrijke documenten bewaren.)
setup: Instellen
wrong_code: De ingevoerde code is ongeldig! Klopt de systeemtijd van de server en die van jouw apparaat?
users:

View File

@@ -335,7 +335,7 @@
lost_recovery_codes: Gjenopprettingskoder lar deg gjenoppnå tilgang til din konto hvis du mister din telefon. Hvis du har mistet gjenopprettingskodene, kan du regenerere dem her. Dine gamle gjenopprettingskoder vil bli ugyldige.
manual_instructions: 'Hvis du ikke får scannet QR-koden må du skrive inn følgende kode manuelt:'
recovery_codes_regenerated: Generering av gjenopprettingskoder vellykket
recovery_instructions: Hvis du skulle miste tilgang til telefonen din, kan du bruke en av gjenopprettingskodene nedenfor til å gjenopprette tilgang til din konto. Oppbevar gjenopprettingskodene sikkert, for eksempel ved å skrive dem ut og lagre dem sammen med andre viktige dokumenter.
recovery_instructions_html: Hvis du skulle miste tilgang til telefonen din, kan du bruke en av gjenopprettingskodene nedenfor til å gjenopprette tilgang til din konto. Oppbevar gjenopprettingskodene sikkert, for eksempel ved å skrive dem ut og lagre dem sammen med andre viktige dokumenter.
setup: Sett opp
wrong_code: Den angitte koden var ugyldig! Stemmer instansens tid overalt med enhetens tid?
users:

View File

@@ -220,7 +220,7 @@ oc:
- dv
- ds
abbr_month_names:
-
-
- gen
- feb
- mar
@@ -246,7 +246,7 @@ oc:
long: Lo %B %d de %Y
short: "%b %d"
month_names:
-
-
- de genièr
- de febrièr
- de març
@@ -411,7 +411,7 @@ oc:
lost_recovery_codes: Los còdi de recuperacion vos permeton daccedir a vòstre compte se perdètz vòstre mobil. Savètz perdut vòstres còdis de recuperacion los podètz tornar generar aquí. Los ancians còdis seràn pas mai valides.
manual_instructions: 'Se podètz pas numerizar lo còdi QR e que vos cal picar lo còdi a la man, vaquí lo còdi en clar :'
recovery_codes_regenerated: Los còdis de recuperacion son ben estats tornats generar
recovery_instructions: Se vos arriba de perdre vòstre mobil, podètz utilizar un dels còdis de recuperacion cai-jos per poder tornar accedir a vòstre compte. Gardatz los còdis en seguretat, per exemple, imprimissètz los e gardatz los amb vòstres documents importants.
recovery_instructions_html: Se vos arriba de perdre vòstre mobil, podètz utilizar un dels còdis de recuperacion cai-jos per poder tornar accedir a vòstre compte. Gardatz los còdis en seguretat, per exemple, imprimissètz los e gardatz los amb vòstres documents importants.
setup: Paramètres
wrong_code: Lo còdi picat es invalid ! Lora es la bona sul servidor e lo mobil ?
users:

View File

@@ -193,14 +193,18 @@ pl:
title: PubSubHubbub
topic: Temat
title: Administracja
admin_mailer:
new_report:
body: "Użytkownik %{reporter} zgłosił %{target}"
subject: Nowe zgłoszenie na %{instance} (#%{id})
application_mailer:
settings: 'Zmień ustawienia powiadamiania: %{link}'
signature: Powiadomienie Mastodona, wysłane przez %{instance}
signature: Powiadomienie Mastodona z instancji %{instance}
view: 'Zobacz:'
applications:
invalid_url: Ten URL jest nieprawidłowy
auth:
change_password: Uwierzytelnienie
change_password: Bezpieczeństwo
delete_account: Usunięcie konta
delete_account_html: Jeżeli próbowałeś usunąć konto, <a href="%{path}">przejdź tutaj</a>. Otrzymasz prośbę o potwierdzenie.
didnt_get_confirmation: Nie otrzymałeś instrukcji weryfikacji?
@@ -323,7 +327,44 @@ pl:
acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić
missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny
proceed: Śledź
prompt: 'Śledzony będzie:'
prompt: 'Zamierzasz śledzić:'
sessions:
activity: Ostatnia aktywność
browser: Przeglądarka
browsers:
alipay: Alipay
blackberry: Blackberry
chrome: Chrome
edge: Microsoft Edge
firefox: Firefox
generic: nieznana przeglądarka
ie: Internet Explorer
micro_messenger: MicroMessenger
nokia: Nokia S40 Ovi Browser
opera: Opera
phantom_js: PhantomJS
qq: QQ Browser
safari: Safari
uc_browser: UCBrowser
weibo: Weibo
current_session: Obecna sesja
description: "%{browser} na %{platform}"
explanation: Przeglądarki z aktywną sesją Twojego konta.
ip: Adres IP
platforms:
adobe_air: Adobe Air
android: Android
blackberry: Blackberry
chrome_os: ChromeOS
firefox_os: Firefox OS
ios: iOS
linux: Linux
mac: macOS
other: nieznana platforma
windows: Windows
windows_mobile: Windows Mobile
windows_phone: Windows Phone
title: Sesje
settings:
authorized_apps: Uwierzytelnione aplikacje
back: Powrót do Mastodona
@@ -358,13 +399,15 @@ pl:
description_html: Jeśli włączysz <strong>uwierzytelnianie dwuetapowe</strong>, logowanie się będzie wymagało podania tokenu wyświetlonego na Twoim telefonie.
disable: Wyłącz
enable: Włącz
enabled: Uwierzytelnianie dwuetapowe jest włączone
enabled_success: Pomyślnie aktywowano uwierzytelnianie dwuetapowe
generate_recovery_codes: Generuj kody zapasowe
instructions_html: "<strong>Zeskanuj ten kod QR na swoim urządzeniu za pomocą Google Authenticator, FreeOTP lub podobnej aplikacji</strong>. Od teraz będzie ona generowała kody wymagane przy logowaniu."
lost_recovery_codes: Kody zapasowe pozwolą uzyskać dostęp do portalu, jeżeli utracisz dostęp do telefonu. Jeżeli utracisz dostęp do nich, możesz wygenerować je ponownie tutaj. Poprzednie zostaną unieważnione.
manual_instructions: 'Jeżeli nie możesz zeskanować kodu QR, musisz wprowadzić ten kod ręcznie:'
recovery_codes: Przywróć kody zapasowe
recovery_codes_regenerated: Pomyślnie wygenerowano ponownie kody zapasowe
recovery_instructions: Jeżeli kiedykolwiek utracisz dostęp do telefonu, możesz wykorzystać jeden z kodów zapasowych, aby odzyskać dostęp do konta. Trzymaj je w bezpiecznym miejscu. (Na przykład, wydrukuj je i przechowuj z ważnymu dokumentami.)
recovery_instructions_html: Jeżeli kiedykolwiek utracisz dostęp do telefonu, możesz wykorzystać jeden z kodów zapasowych, aby odzyskać dostęp do konta. <strong>Trzymaj je w bezpiecznym miejscu</strong>. Na przykład, wydrukuj je i przechowuj z ważnymu dokumentami.
setup: Skonfiguruj
wrong_code: Wprowadzony kod jest niepoprawny! Czy czas serwera i urządzenia jest poprawny?
users:

View File

@@ -333,7 +333,7 @@ pt-BR:
lost_recovery_codes: Códigos de recuperação permite que você recupere o acesso a sua conta se você perder seu telefone. Se você perder os códigos de recuperação, você pode regera-los aqui. Seus códigos antigos serão invalidados.
manual_instructions: 'Se você não puder scanear o código QR e precisa digita-los manualmente, aqui está o segredo em texto.:'
recovery_codes_regenerated: Códigos de recuperação foram gerados com sucesso
recovery_instructions: Se algum dia você perder o acesso ao seu telefone, você pode usar um dos códigos de abaixo para recupera o acesso a sua conta. Guarde os códigos de acesso em local seguro, por exemplo imprimindo ou guardados com documentos importantes.
recovery_instructions_html: Se algum dia você perder o acesso ao seu telefone, você pode usar um dos códigos de abaixo para recupera o acesso a sua conta. Guarde os códigos de acesso em local seguro, por exemplo imprimindo ou guardados com documentos importantes.
setup: Configurar
wrong_code: O código digitado é inválido! Os relógios do servidor e do dispositivo estão corretos?
users:

View File

@@ -332,7 +332,7 @@ ru:
lost_recovery_codes: Коды восстановления позволяют вернуть доступ к аккаунту в случае утери телефона. Если Вы потеряли Ваши коды восстановления, вы можете заново сгенерировать их здесь. Ваши старые коды восстановления будут аннулированы.
manual_instructions: 'Если Вы не можете отсканировать QR-код и хотите ввести его вручную, секрет представлен здесь открытым текстом:'
recovery_codes_regenerated: Коды восстановления успешно сгенерированы
recovery_instructions: В случае утери доступа к Вашему телефону Вы можете использовать один из кодов восстановления, указанных ниже, чтобы вернуть доступ к аккаунту. Держите коды восстановления в безопасности, например, распечатав их и храня с другими важными документами.
recovery_instructions_html: В случае утери доступа к Вашему телефону Вы можете использовать один из кодов восстановления, указанных ниже, чтобы вернуть доступ к аккаунту. Держите коды восстановления в безопасности, например, распечатав их и храня с другими важными документами.
setup: Настроить
wrong_code: Введенный код неверен! Правильно ли установлены серверное время и время устройства?
users:

Some files were not shown because too many files have changed in this diff Show More