diff options
96 files changed, 1446 insertions, 519 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 17626e027..9639aed08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,45 @@ Changelog All notable changes to this project will be documented in this file. +## [2.8.1] - 2019-05-04 +### Added + +- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663)) +- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676)) +- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603)) +- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600)) +- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666)) +- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) + +### Changed + +- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) +- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630)) +- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683)) +- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655)) +- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682)) +- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674)) + +### Fixed + +- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621)) +- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684)) +- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614)) +- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593)) +- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657)) +- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622)) +- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632)) +- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633)) +- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624)) +- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623)) +- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553)) +- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605)) +- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565)) +- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549)) +- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604)) +- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594)) +- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586)) + ## [2.8.0] - 2019-04-10 ### Added diff --git a/Gemfile b/Gemfile index eac7a3e43..9deb596c5 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' gem 'paperclip-av-transcoder', '~> 0.6' gem 'streamio-ffmpeg', '~> 3.0' +gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.6' @@ -66,7 +67,7 @@ gem 'ox', '~> 2.10' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' gem 'pundit', '~> 2.0' gem 'premailer-rails' -gem 'rack-attack', '~> 5.4' +gem 'rack-attack', '~> 6.0' gem 'rack-cors', '~> 1.0', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' @@ -124,14 +125,14 @@ group :development do gem 'annotate', '~> 2.7' gem 'better_errors', '~> 2.5' gem 'binding_of_caller', '~> 0.7' - gem 'bullet', '~> 5.9' + gem 'bullet', '~> 6.0' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' - gem 'rubocop', '~> 0.67', require: false + gem 'rubocop', '~> 0.68', require: false gem 'brakeman', '~> 4.5', require: false gem 'bundler-audit', '~> 0.6', require: false - gem 'scss_lint', '~> 0.57', require: false + gem 'scss_lint', '~> 0.58', require: false gem 'capistrano', '~> 3.11' gem 'capistrano-rails', '~> 1.4' diff --git a/Gemfile.lock b/Gemfile.lock index b4701868c..aab27ac93 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -66,8 +66,8 @@ GEM public_suffix (>= 2.0.2, < 4.0) airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) - annotate (2.7.4) - activerecord (>= 3.2, < 6.0) + annotate (2.7.5) + activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 13.0) arel (9.0.0) ast (2.4.0) @@ -99,12 +99,14 @@ GEM rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) - bootsnap (1.4.3) + blurhash (0.1.3) + ffi (~> 1.10.0) + bootsnap (1.4.4) msgpack (~> 1.0) brakeman (4.5.0) browser (2.5.3) builder (3.2.3) - bullet (5.9.0) + bullet (6.0.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) @@ -205,7 +207,7 @@ GEM et-orbi (1.1.6) tzinfo excon (0.62.0) - fabrication (2.20.1) + fabrication (2.20.2) faker (1.9.3) i18n (>= 0.7) faraday (0.15.0) @@ -348,7 +350,7 @@ GEM mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) - msgpack (1.2.9) + msgpack (1.2.10) multi_json (1.13.1) multipart-post (2.0.0) necromancer (0.4.0) @@ -395,7 +397,7 @@ GEM parallel (1.17.0) parallel_tests (2.28.0) parallel - parser (2.6.2.1) + parser (2.6.3.0) ast (~> 2.4.0) pastel (0.7.2) equatable (~> 0.5.0) @@ -420,14 +422,13 @@ GEM pry (~> 0.10) pry-rails (0.3.9) pry (>= 0.10.4) - psych (3.1.0) public_suffix (3.0.3) puma (3.12.1) pundit (2.0.1) activesupport (>= 3.0.0) raabro (1.1.6) rack (2.0.7) - rack-attack (5.4.2) + rack-attack (6.0.0) rack (>= 1.0, < 3) rack-cors (1.0.3) rack-protection (2.0.5) @@ -472,8 +473,8 @@ GEM rainbow (3.0.0) rake (12.3.2) rb-fsevent (0.10.3) - rb-inotify (0.9.10) - ffi (>= 0.5.0, < 2) + rb-inotify (0.10.0) + ffi (~> 1.0) rdf (3.0.9) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) @@ -528,11 +529,10 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.8.0) - rubocop (0.67.2) + rubocop (0.68.1) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.5, != 2.5.1.1) - psych (>= 3.1.0) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.6) @@ -546,12 +546,12 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) - sass (3.6.0) + sass (3.7.4) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - scss_lint (0.57.1) + scss_lint (0.58.0) rake (>= 0.9, < 13) sass (~> 3.5, >= 3.5.5) sidekiq (5.2.7) @@ -663,10 +663,11 @@ DEPENDENCIES aws-sdk-s3 (~> 1.36) better_errors (~> 2.5) binding_of_caller (~> 0.7) + blurhash (~> 0.1) bootsnap (~> 1.4) brakeman (~> 4.5) browser - bullet (~> 5.9) + bullet (~> 6.0) bundler-audit (~> 0.6) capistrano (~> 3.11) capistrano-rails (~> 1.4) @@ -737,7 +738,7 @@ DEPENDENCIES pry-rails (~> 0.3) puma (~> 3.12) pundit (~> 2.0) - rack-attack (~> 5.4) + rack-attack (~> 6.0) rack-cors (~> 1.0) rails (~> 5.2.3) rails-controller-testing (~> 1.0) @@ -750,9 +751,9 @@ DEPENDENCIES rqrcode (~> 0.10) rspec-rails (~> 3.8) rspec-sidekiq (~> 3.0) - rubocop (~> 0.67) + rubocop (~> 0.68) sanitize (~> 5.0) - scss_lint (~> 0.57) + scss_lint (~> 0.58) sidekiq (~> 5.2) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 5f307ddee..dd3f83389 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -13,13 +13,25 @@ module Admin authorize :domain_block, :create? @domain_block = DomainBlock.new(resource_params) + existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil - if @domain_block.save - DomainBlockWorker.perform_async(@domain_block.id) - log_action :create, @domain_block - redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') - else + if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) + @domain_block.save + flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety + @domain_block.errors[:domain].clear render :new + else + if existing_domain_block.present? + @domain_block = existing_domain_block + @domain_block.update(resource_params) + end + if @domain_block.save + DomainBlockWorker.perform_async(@domain_block.id) + log_action :create, @domain_block + redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + else + render :new + end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 3a92ee4e4..eca558f42 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController skip_before_action :store_current_location skip_before_action :check_user_permissions + before_action :set_cache_headers + protect_from_forgery with: :null_session rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| @@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController def authorize_if_got_token!(*scopes) doorkeeper_authorize!(*scopes) if doorkeeper_token end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end end diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 7bac27da4..1bb19a09d 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -3,6 +3,8 @@ class Api::V1::CustomEmojisController < Api::BaseController respond_to :json + skip_before_action :set_cache_headers + def index render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer) diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index e14e0aee8..09edfe365 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Instances::ActivityController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers respond_to :json diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 2070c487d..a8891d126 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Instances::PeersController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers respond_to :json diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 5686e8d7c..8c83a1801 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -2,6 +2,7 @@ class Api::V1::InstancesController < Api::BaseController respond_to :json + skip_before_action :set_cache_headers def show render_cached_json('api:v1:instances', expires_in: 5.minutes) do diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 84099bd96..c56728464 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_invite - @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil + invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil + @invite = invite&.valid_for_use? ? invite : nil end def determine_layout diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index 68ebddfc9..7d7e237fb 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController def user_settings_params params.require(:user).permit( - notification_emails: %i(follow follow_request reblog favourite mention digest report), + notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index f218ee06b..cca571583 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -97,7 +97,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index b7360bae4..194800d52 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; const messages = defineMessages({ hidden: { @@ -41,6 +42,7 @@ class Item extends React.PureComponent { letterbox: PropTypes.bool, onClick: PropTypes.func.isRequired, displayWidth: PropTypes.number, + visible: PropTypes.bool.isRequired, }; static defaultProps = { @@ -49,6 +51,10 @@ class Item extends React.PureComponent { size: 1, }; + state = { + loaded: false, + }; + handleMouseEnter = (e) => { if (this.hoverToPlay()) { e.target.play(); @@ -82,8 +88,40 @@ class Item extends React.PureComponent { e.stopPropagation(); } + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + render () { - const { attachment, index, size, standalone, letterbox, displayWidth } = this.props; + const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props; let width = 50; let height = 100; @@ -136,12 +174,20 @@ class Item extends React.PureComponent { let thumbnail = ''; - if (attachment.get('type') === 'image') { + if (attachment.get('type') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> + </a> + </div> + ); + } else 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 originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; @@ -168,6 +214,7 @@ class Item extends React.PureComponent { alt={attachment.get('description')} title={attachment.get('description')} style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }} + onLoad={this.handleImageLoad} /> </a> ); @@ -197,7 +244,8 @@ class Item extends React.PureComponent { return ( <div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> - {thumbnail} + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> + {visible && thumbnail} </div> ); } @@ -257,6 +305,7 @@ export default class MediaGallery extends React.PureComponent { this.node = node; if (node && node.offsetWidth && node.offsetWidth != this.state.width) { if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + this.setState({ width: node.offsetWidth, }); @@ -275,7 +324,7 @@ export default class MediaGallery extends React.PureComponent { const width = this.state.width || defaultWidth; - let children; + let children, spoilerButton; const style = {}; @@ -289,40 +338,32 @@ export default class MediaGallery extends React.PureComponent { return (<div className={computedClass} ref={this.handleRef}></div>); } - if (!visible) { - let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />; + if (this.isStandaloneEligible()) { + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />); + } - children = ( - <button className='media-spoiler' type='button' onClick={this.handleOpen}> - <span className='media-spoiler__warning'>{warning}</span> - <span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span> + if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> </button> ); - } else { - if (this.isStandaloneEligible()) { - children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />; - } else { - children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />); - } } return ( <div className={computedClass} style={style} ref={this.handleRef}> - {visible ? ( - <div className='sensitive-info'> - <IconButton - icon='eye' - onClick={this.handleOpen} - overlay - title={intl.formatMessage(messages.toggle_visible)} - /> - {sensitive ? ( - <span className='sensitive-marker'> - <FormattedMessage {...messages.sensitive} /> - </span> - ) : null} - </div> - ) : null} + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}> + {spoilerButton} + {visible && sensitive && ( + <span className='sensitive-marker'> + <FormattedMessage {...messages.sensitive} /> + </span> + )} + </div> {children} </div> diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 94297260b..5f10e0c52 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -479,6 +479,7 @@ export default class Status extends ImmutablePureComponent { <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > {Component => (<Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} inline diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js index 89778e123..026136b2c 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -1,69 +1,148 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Permalink from 'flavours/glitch/components/permalink'; -import { displayMedia } from 'flavours/glitch/util/initial_state'; +import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import classNames from 'classnames'; +import { decode } from 'blurhash'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; export default class MediaItem extends ImmutablePureComponent { static propTypes = { - media: ImmutablePropTypes.map.isRequired, + attachment: ImmutablePropTypes.map.isRequired, + displayWidth: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, }; state = { - visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + loaded: false, }; - handleClick = () => { - if (!this.state.visible) { - this.setState({ visible: true }); - return true; + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); } + } - return false; + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } } - render () { - const { media } = this.props; - const { visible } = this.state; - const status = media.get('status'); - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - const style = {}; - - let label, icon, title; - - if (media.get('type') === 'gifv') { - label = <span className='media-gallery__gifv__label'>GIF</span>; + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + handleMouseEnter = e => { + if (this.hoverToPlay()) { + e.target.play(); } + } + + handleMouseLeave = e => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } - if (visible) { - style.backgroundImage = `url(${media.get('preview_url')})`; - style.backgroundPosition = `${x}% ${y}%`; - title = media.get('description'); - } else { - icon = ( - <span className='account-gallery__item__icons'> - <i className='fa fa-eye-slash' /> - </span> + hoverToPlay () { + return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; + } + + handleClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (this.state.visible) { + this.props.onOpenMedia(this.props.attachment); + } else { + this.setState({ visible: true }); + } + } + } + + render () { + const { attachment, displayWidth } = this.props; + const { visible, loaded } = this.state; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + const title = status.get('spoiler_text') || attachment.get('description'); + + let thumbnail = ''; + + if (attachment.get('type') === 'unknown') { + // Skip + } else if (attachment.get('type') === 'image') { + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + title={attachment.get('description')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + <span className='media-gallery__gifv__label'>GIF</span> + </div> ); - title = status.get('spoiler_text') || media.get('description'); } + const icon = ( + <span className='account-gallery__item__icons'> + <i className='fa fa-eye-slash' /> + </span> + ); + return ( - <div className='account-gallery__item'> - <Permalink - to={`/statuses/${status.get('id')}`} - href={status.get('url')} - style={style} - title={title} - onInterceptClick={this.handleClick} - > - {icon} - {label} - </Permalink> + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> + {visible ? thumbnail : icon} + </a> </div> ); } diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js index 3b1af108f..3e4421306 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.js +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container import { ScrollContainer } from 'react-router-scroll-4'; import LoadMore from 'flavours/glitch/components/load_more'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import { openModal } from 'flavours/glitch/actions/modal'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', props.params.accountId]), - medias: getAccountGallery(state, props.params.accountId), + attachments: getAccountGallery(state, props.params.accountId), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), }); class LoadMoreMedia extends ImmutablePureComponent { @@ -50,12 +51,16 @@ export default class AccountGallery extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - medias: ImmutablePropTypes.list.isRequired, + attachments: ImmutablePropTypes.list.isRequired, isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, }; + state = { + width: 323, + }; + componentDidMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); @@ -74,11 +79,11 @@ export default class AccountGallery extends ImmutablePureComponent { handleScrollToBottom = () => { if (this.props.hasMore) { - this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined); + this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); } } - handleScroll = (e) => { + handleScroll = e => { const { scrollTop, scrollHeight, clientHeight } = e.target; const offset = scrollHeight - scrollTop - clientHeight; @@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent { this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); }; - handleLoadOlder = (e) => { + handleLoadOlder = e => { e.preventDefault(); this.handleScrollToBottom(); } @@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent { return !(location.state && location.state.mastodonModalOpen); } - setRef = c => { + setColumnRef = c => { this.column = c; } + handleOpenMedia = attachment => { + if (attachment.get('type') === 'video') { + this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + } + } + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + render () { - const { medias, isLoading, hasMore, isAccount } = this.props; + const { attachments, isLoading, hasMore, isAccount } = this.props; + const { width } = this.state; if (!isAccount) { return ( @@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent { ); } - let loadOlder = null; - - if (!medias && isLoading) { + if (!attachments && isLoading) { return ( <Column> <LoadingIndicator /> @@ -126,35 +147,31 @@ export default class AccountGallery extends ImmutablePureComponent { ); } - if (hasMore && !(isLoading && medias.size === 0)) { + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; } return ( - <Column ref={this.setRef}> + <Column ref={this.setColumnRef}> <ProfileColumnHeader onClick={this.handleHeaderClick} /> <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <HeaderContainer accountId={this.props.params.accountId} /> - <div role='feed' className='account-gallery__container'> - {medias.map((media, index) => media === null ? ( - <LoadMoreMedia - key={'more:' + medias.getIn(index + 1, 'id')} - maxId={index > 0 ? medias.getIn(index - 1, 'id') : null} - onLoadMore={this.handleLoadMore} - /> + <div role='feed' className='account-gallery__container' ref={this.handleRef}> + {attachments.map((attachment, index) => attachment === null ? ( + <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> ) : ( - <MediaItem - key={media.get('id')} - media={media} - /> + <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> ))} + {loadOlder} </div> - {isLoading && medias.size === 0 && ( + {isLoading && attachments.size === 0 && ( <div className='scrollable__append'> <LoadingIndicator /> </div> diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js index 8a760bd15..ee9730961 100644 --- a/app/javascript/flavours/glitch/features/compose/components/options.js +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -65,10 +65,6 @@ const messages = defineMessages({ defaultMessage: 'Public', id: 'privacy.public.short', }, - sensitive: { - defaultMessage: 'Mark media as sensitive', - id: 'compose_form.sensitive', - }, spoiler: { defaultMessage: 'Hide text behind warning', id: 'compose_form.spoiler', @@ -116,7 +112,6 @@ class ComposerOptions extends ImmutablePureComponent { hasPoll: PropTypes.bool, intl: PropTypes.object.isRequired, onChangeAdvancedOption: PropTypes.func, - onChangeSensitivity: PropTypes.func, onChangeVisibility: PropTypes.func, onTogglePoll: PropTypes.func, onDoodleOpen: PropTypes.func, @@ -126,7 +121,6 @@ class ComposerOptions extends ImmutablePureComponent { onUpload: PropTypes.func, privacy: PropTypes.string, resetFileKey: PropTypes.number, - sensitive: PropTypes.bool, spoiler: PropTypes.bool, }; @@ -175,7 +169,6 @@ class ComposerOptions extends ImmutablePureComponent { hasPoll, intl, onChangeAdvancedOption, - onChangeSensitivity, onChangeVisibility, onTogglePoll, onModalClose, @@ -183,7 +176,6 @@ class ComposerOptions extends ImmutablePureComponent { onToggleSpoiler, privacy, resetFileKey, - sensitive, spoiler, } = this.props; @@ -264,39 +256,6 @@ class ComposerOptions extends ImmutablePureComponent { title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} /> )} - <Motion - defaultStyle={{ scale: 0.87 }} - style={{ - scale: spring(hasMedia ? 1 : 0.87, { - stiffness: 200, - damping: 3, - }), - }} - > - {({ scale }) => ( - <div - style={{ - display: hasMedia ? null : 'none', - transform: `scale(${scale})`, - }} - > - <IconButton - active={sensitive} - className='sensitive' - disabled={spoiler} - icon={sensitive ? 'eye-slash' : 'eye'} - inverted - onClick={onChangeSensitivity} - size={18} - style={{ - height: null, - lineHeight: null, - }} - title={intl.formatMessage(messages.sensitive)} - /> - </div> - )} - </Motion> <hr /> <Dropdown disabled={disabled} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js index 4864043a8..35880ddcc 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import UploadProgressContainer from '../containers/upload_progress_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import UploadContainer from '../containers/upload_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; export default class UploadForm extends ImmutablePureComponent { static propTypes = { @@ -23,6 +24,8 @@ export default class UploadForm extends ImmutablePureComponent { ))} </div> )} + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} </div> ); } diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js index e846cfbd5..2ac7ab8d8 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -2,7 +2,6 @@ import { connect } from 'react-redux'; import Options from '../components/options'; import { changeComposeAdvancedOption, - changeComposeSensitivity, } from 'flavours/glitch/actions/compose'; import { addPoll, removePoll } from 'flavours/glitch/actions/compose'; import { closeModal, openModal } from 'flavours/glitch/actions/modal'; @@ -27,10 +26,6 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(changeComposeAdvancedOption(option, value)); }, - onChangeSensitivity() { - dispatch(changeComposeSensitivity()); - }, - onTogglePoll() { dispatch((_, getState) => { if (getState().getIn(['compose', 'poll'])) { diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..8f163979f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, + unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, +}); + +const mapStateToProps = state => { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const spoilerText = state.getIn(['compose', 'spoiler_text']); + return { + active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0), + disabled: state.getIn(['compose', 'spoiler']), + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { active, disabled, onClick, intl } = this.props; + + return ( + <div className='compose-form__sensitive-button'> + <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <Icon icon='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + </button> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js index d674eecf9..cc49042fc 100644 --- a/app/javascript/flavours/glitch/features/report/components/status_check_box.js +++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js @@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent { {Component => ( <Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={239} diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 8b35de0d4..0b162ed36 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -134,6 +134,7 @@ export default class DetailedStatus extends ImmutablePureComponent { media = ( <Video preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} inline diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index 1f3ac18ea..ce6660480 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import Video from 'flavours/glitch/features/video'; import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; import classNames from 'classnames'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from 'flavours/glitch/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; @@ -19,8 +19,13 @@ const messages = defineMessages({ @injectIntl export default class MediaModal extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { media: ImmutablePropTypes.list.isRequired, + status: ImmutablePropTypes.map, index: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -81,8 +86,15 @@ export default class MediaModal extends ImmutablePureComponent { })); }; + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, intl, onClose } = this.props; + const { media, status, intl, onClose } = this.props; const { navigationHidden } = this.state; const index = this.getIndex(); @@ -123,6 +135,7 @@ export default class MediaModal extends ImmutablePureComponent { return ( <Video preview={image.get('preview_url')} + blurhash={image.get('blurhash')} src={image.get('url')} width={image.get('width')} height={image.get('height')} @@ -185,10 +198,19 @@ export default class MediaModal extends ImmutablePureComponent { {content} </ReactSwipeableViews> </div> + <div className={navigationClassName}> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} {rightNav} + + {status && ( + <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> + <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} + <ul className='media-modal__pagination'> {pagination} </ul> diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index 69e0ee46e..3f742c260 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -3,26 +3,43 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'flavours/glitch/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; export default class VideoModal extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { media: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, time: PropTypes.number, onClose: PropTypes.func.isRequired, }; + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, time, onClose } = this.props; + const { media, status, time, onClose } = this.props; + + const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>; return ( <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} + blurhash={media.get('blurhash')} src={media.get('url')} startTime={time} onCloseVideo={onClose} + link={link} detailed alt={media.get('description')} /> diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index e3ed799c7..8291ff3c8 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -6,6 +6,7 @@ import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; import { displayMedia } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -104,6 +105,8 @@ export default class Video extends React.PureComponent { preventPlayback: PropTypes.bool, intl: PropTypes.object.isRequired, cacheWidth: PropTypes.func, + blurhash: PropTypes.string, + link: PropTypes.node, }; state = { @@ -147,6 +150,7 @@ export default class Video extends React.PureComponent { setVideoRef = c => { this.video = c; + if (this.video) { this.setState({ volume: this.video.volume, muted: this.video.muted }); } @@ -160,6 +164,10 @@ export default class Video extends React.PureComponent { this.volume = c; } + setCanvasRef = c => { + this.canvas = c; + } + handleMouseDownRoot = e => { e.preventDefault(); e.stopPropagation(); @@ -181,7 +189,6 @@ export default class Video extends React.PureComponent { } handleVolumeMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseVolSlide, true); document.addEventListener('mouseup', this.handleVolumeMouseUp, true); document.addEventListener('touchmove', this.handleMouseVolSlide, true); @@ -201,7 +208,6 @@ export default class Video extends React.PureComponent { } handleMouseVolSlide = throttle(e => { - const rect = this.volume.getBoundingClientRect(); const x = (e.clientX - rect.left) / this.volWidth; //x position within the element. @@ -272,6 +278,10 @@ export default class Video extends React.PureComponent { document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (this.props.blurhash) { + this._decode(); + } } componentWillUnmount () { @@ -293,6 +303,24 @@ export default class Video extends React.PureComponent { } } + componentDidUpdate (prevProps) { + if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { + this._decode(); + } + } + + _decode () { + const hash = this.props.blurhash; + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -337,6 +365,7 @@ export default class Video extends React.PureComponent { handleOpenVideo = () => { const { src, preview, width, height, alt } = this.props; + const media = fromJS({ type: 'video', url: src, @@ -356,7 +385,7 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -385,6 +414,7 @@ export default class Video extends React.PureComponent { } let preload; + if (startTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { @@ -403,7 +433,9 @@ export default class Video extends React.PureComponent { onMouseDown={this.handleMouseDownRoot} tabIndex={0} > - <video + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> + + {revealed && <video ref={this.setVideoRef} src={src} poster={preview} @@ -423,12 +455,13 @@ export default class Video extends React.PureComponent { onLoadedData={this.handleLoadedData} onProgress={this.handleProgress} onVolumeChange={this.handleVolumeChange} - /> + />} - <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> - <span className='video-player__spoiler__title'>{warning}</span> - <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </button> + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'>{warning}</span> + </button> + </div> <div className={classNames('video-player__controls', { active: paused || hovered })}> <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> @@ -455,17 +488,19 @@ export default class Video extends React.PureComponent { /> </div> - {(detailed || fullscreen) && + {(detailed || fullscreen) && ( <span> <span className='video-player__time-current'>{formatTime(currentTime)}</span> <span className='video-player__time-sep'>/</span> <span className='video-player__time-total'>{formatTime(duration)}</span> </span> - } + )} + + {link && <span className='video-player__link'>{link}</span>} </div> <div className='video-player__buttons right'> - {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>} + {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>} {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 05c7821e4..74f91599a 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -50,6 +50,7 @@ $content-width: 840px; color: $darker-text-color; text-decoration: none; transition: all 200ms linear; + transition-property: color, background-color; border-radius: 4px 0 0 4px; i.fa { @@ -60,6 +61,7 @@ $content-width: 840px; color: $primary-text-color; background-color: darken($ui-base-color, 5%); transition: all 100ms linear; + transition-property: color, background-color; } &.selected { diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index 00380c575..c0340e3f8 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -331,54 +331,18 @@ .account-gallery__container { display: flex; - justify-content: center; flex-wrap: wrap; - padding: 2px; + padding: 4px 2px; } .account-gallery__item { - flex-grow: 1; - width: 50%; - overflow: hidden; + border: none; + box-sizing: border-box; + display: block; position: relative; - - &::before { - content: ""; - display: block; - padding-top: 100%; - } - - a { - display: block; - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - top: 0; - left: 0; - background-color: $base-overlay-background; - background-size: cover; - background-position: center; - position: absolute; - color: $ui-primary-color; - text-decoration: none; - border-radius: 4px; - - &:hover, - &:active, - &:focus { - outline: 0; - color: $ui-secondary-color; - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - background: rgba($base-overlay-background, 0.3); - border-radius: 4px; - } - } - } + border-radius: 4px; + overflow: hidden; + margin: 2px; &__icons { position: absolute; diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index e5eb6e64f..86041da20 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -57,6 +57,16 @@ } } +.compose-form__sensitive-button { + padding: 10px; + padding-top: 0; + + .icon-button { + font-size: 14px; + font-weight: 500; + } +} + .composer--reply { margin: 0 0 10px; border-radius: 4px; diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss index 41c794790..9f426448f 100644 --- a/app/javascript/flavours/glitch/styles/components/drawer.scss +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -125,6 +125,7 @@ cursor: default; pointer-events: none; transition: all 100ms linear; + transition-property: color, transform, opacity; } .fa-search { diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index b098676b0..f12f8b7fa 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -25,6 +25,7 @@ text-decoration: none; text-overflow: ellipsis; transition: all 100ms ease-in; + transition-property: background-color; white-space: nowrap; width: auto; @@ -33,6 +34,7 @@ &:hover { background-color: lighten($ui-highlight-color, 7%); transition: all 200ms ease-out; + transition-property: background-color; } &--destructive { @@ -564,6 +566,7 @@ font-weight: 500; border-bottom: 2px solid lighten($ui-base-color, 8%); transition: all 200ms linear; + transition-property: background; .fa { font-weight: 400; @@ -581,6 +584,7 @@ @include multi-columns('screen and (min-width: 631px)') { background: lighten($ui-base-color, 14%); transition: all 100ms linear; + transition-property: background; } } @@ -664,7 +668,7 @@ padding: 0; border-radius: 30px; background-color: $ui-base-color; - transition: all 0.2s ease; + transition: background-color 0.2s ease; } .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { @@ -717,7 +721,6 @@ } .react-toggle-thumb { - transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; position: absolute; top: 1px; left: 1px; @@ -728,6 +731,7 @@ background-color: darken($simple-background-color, 2%); box-sizing: border-box; transition: all 0.25s ease; + transition-property: border-color, left; } .react-toggle--checked .react-toggle-thumb { @@ -1066,15 +1070,50 @@ } .spoiler-button { - display: none; - left: 4px; + top: 0; + left: 0; + width: 100%; + height: 100%; position: absolute; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; - top: 4px; z-index: 100; - &.spoiler-button--visible { + &--minified { + display: flex; + left: 4px; + top: 4px; + width: auto; + height: auto; + align-items: center; + } + + &--hidden { + display: none; + } + + &__overlay { display: block; + background: transparent; + width: 100%; + height: 100%; + border: 0; + + &__label { + display: inline-block; + background: rgba($base-overlay-background, 0.5); + border-radius: 8px; + padding: 8px 12px; + color: $primary-text-color; + font-weight: 500; + font-size: 14px; + } + + &:hover, + &:focus, + &:active { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.8); + } + } } } diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index fabef2a56..e5927057e 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -58,6 +58,7 @@ pointer-events: none; opacity: 0.9; transition: opacity 0.1s ease; + line-height: 18px; } .media-gallery__gifv { @@ -117,6 +118,8 @@ text-decoration: none; color: $secondary-text-color; line-height: 0; + position: relative; + z-index: 1; &, img { @@ -131,6 +134,21 @@ } } +.media-gallery__preview { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .media-gallery__gifv { height: 100%; overflow: hidden; @@ -252,6 +270,31 @@ pointer-events: none; } +.media-modal__meta { + text-align: center; + position: absolute; + left: 0; + bottom: 20px; + width: 100%; + pointer-events: none; + + &--shifted { + bottom: 62px; + } + + a { + text-decoration: none; + font-weight: 500; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + .media-modal__page-dot { display: inline-block; } @@ -501,6 +544,23 @@ } } + &__link { + padding: 2px 10px; + + a { + text-decoration: none; + font-size: 14px; + font-weight: 500; + color: $white; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + &__seek { cursor: pointer; height: 24px; diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss index 3746fbad2..f59ef019e 100644 --- a/app/javascript/flavours/glitch/styles/components/search.scss +++ b/app/javascript/flavours/glitch/styles/components/search.scss @@ -18,6 +18,7 @@ display: inline-block; opacity: 0; transition: all 100ms linear; + transition-property: transform, opacity; font-size: 18px; width: 18px; height: 18px; diff --git a/app/javascript/flavours/glitch/styles/components/sensitive.scss b/app/javascript/flavours/glitch/styles/components/sensitive.scss index b0a7dfe03..67b01c886 100644 --- a/app/javascript/flavours/glitch/styles/components/sensitive.scss +++ b/app/javascript/flavours/glitch/styles/components/sensitive.scss @@ -15,7 +15,7 @@ color: rgba($primary-text-color, 0.8); background: rgba($base-overlay-background, 0.5); font-size: 12px; - line-height: 15px; + line-height: 18px; text-transform: uppercase; opacity: .9; transition: opacity .1s ease; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index b656f0baf..fb031258f 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -705,7 +705,7 @@ & > div { background: rgba($base-shadow-color, 0.6); - border-radius: 4px; + border-radius: 8px; padding: 12px 9px; flex: 0 0 auto; display: flex; @@ -716,19 +716,18 @@ button, a { display: inline; - color: $primary-text-color; + color: $secondary-text-color; background: transparent; border: 0; - padding: 0 5px; + padding: 0 8px; text-decoration: none; - opacity: 0.6; font-size: 18px; line-height: 18px; &:hover, &:active, &:focus { - opacity: 1; + color: $primary-text-color; } } diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index 91888d305..2b8d7a682 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -533,6 +533,17 @@ code { color: $error-value-color; } + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + p { margin-bottom: 15px; } diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss index e736d7a7e..acaf5b024 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -4,7 +4,6 @@ &__img { width: 100%; - height: 167px; position: relative; overflow: hidden; border-radius: 4px 4px 0 0; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d65d41048..0ee663766 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -203,8 +203,8 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); - const total = Array.from(files).reduce((a, v) => a + v.size, 0); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); @@ -224,6 +224,8 @@ export function uploadCompose(files) { resizeImage(f).then(file => { const data = new FormData(); data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; return api(getState).post('/api/v1/media', data, { onUploadProgress: function({ loaded }){ diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index d92385e95..06c21b96b 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -96,7 +96,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a2bc95255..abd17647e 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; import { autoPlayGif, displayMedia } from '../initial_state'; +import { decode } from 'blurhash'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -21,6 +22,7 @@ class Item extends React.PureComponent { size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, displayWidth: PropTypes.number, + visible: PropTypes.bool.isRequired, }; static defaultProps = { @@ -29,6 +31,10 @@ class Item extends React.PureComponent { size: 1, }; + state = { + loaded: false, + }; + handleMouseEnter = (e) => { if (this.hoverToPlay()) { e.target.play(); @@ -62,8 +68,40 @@ class Item extends React.PureComponent { e.stopPropagation(); } + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + render () { - const { attachment, index, size, standalone, displayWidth } = this.props; + const { attachment, index, size, standalone, displayWidth, visible } = this.props; let width = 50; let height = 100; @@ -116,12 +154,20 @@ class Item extends React.PureComponent { let thumbnail = ''; - if (attachment.get('type') === 'image') { + if (attachment.get('type') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> + </a> + </div> + ); + } else 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 originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; @@ -147,6 +193,7 @@ class Item extends React.PureComponent { alt={attachment.get('description')} title={attachment.get('description')} style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} /> </a> ); @@ -176,7 +223,8 @@ class Item extends React.PureComponent { return ( <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> - {thumbnail} + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> + {visible && thumbnail} </div> ); } @@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent { if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + this.setState({ width: node.offsetWidth, }); @@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent { const width = this.state.width || defaultWidth; - let children; + let children, spoilerButton; const style = {}; @@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent { style.height = height; } - if (!visible) { - let warning; + const size = media.take(4).size; - if (sensitive) { - warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; - } else { - warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; - } + if (this.isStandaloneEligible()) { + children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />); + } - children = ( - <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> - <span className='media-spoiler__warning'>{warning}</span> - <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> </button> ); - } else { - const size = media.take(4).size; - - if (this.isStandaloneEligible()) { - children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />; - } else { - children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />); - } } return ( <div className='media-gallery' style={style} ref={this.handleRef}> - <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> - <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}> + {spoilerButton} </div> {children} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index cea9a0c2e..95ca4a548 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; } else if (status.get('media_attachments').size > 0) { - if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + if (this.props.muted) { media = ( <AttachmentList compact @@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent { {Component => ( <Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={this.props.cachedMediaWidth} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index e417f9a2b..745e6422d 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent { handleMoveUp = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } handleLoadOlder = debounce(() => { this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined); }, 300, { leading: true }) - _selectChild (index) { - const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 80ac9d9ec..5643e6449 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -1,62 +1,142 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import Permalink from '../../../components/permalink'; -import { displayMedia } from '../../../initial_state'; -import Icon from 'mastodon/components/icon'; +import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; +import classNames from 'classnames'; +import { decode } from 'blurhash'; +import { isIOS } from 'mastodon/is_mobile'; export default class MediaItem extends ImmutablePureComponent { static propTypes = { - media: ImmutablePropTypes.map.isRequired, + attachment: ImmutablePropTypes.map.isRequired, + displayWidth: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, }; state = { - visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + loaded: false, }; - handleClick = () => { - if (!this.state.visible) { - this.setState({ visible: true }); - return true; + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); } + } - return false; + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } } - render () { - const { media } = this.props; - const { visible } = this.state; - const status = media.get('status'); - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - const style = {}; - - let label, icon; - - if (media.get('type') === 'gifv') { - label = <span className='media-gallery__gifv__label'>GIF</span>; + _decode () { + const hash = this.props.attachment.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + handleMouseEnter = e => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = e => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; } + } + + hoverToPlay () { + return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; + } + + handleClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (this.state.visible) { + this.props.onOpenMedia(this.props.attachment); + } else { + this.setState({ visible: true }); + } + } + } + + render () { + const { attachment, displayWidth } = this.props; + const { visible, loaded } = this.state; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + + let thumbnail = ''; + + if (attachment.get('type') === 'unknown') { + // Skip + } else if (attachment.get('type') === 'image') { + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <img + src={attachment.get('preview_url')} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + ); + } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { + const autoPlay = !isIOS() && autoPlayGif; + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} + title={attachment.get('description')} + role='application' + src={attachment.get('url')} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> - if (visible) { - style.backgroundImage = `url(${media.get('preview_url')})`; - style.backgroundPosition = `${x}% ${y}%`; - } else { - icon = ( - <span className='account-gallery__item__icons'> - <Icon id='eye-slash' /> - </span> + <span className='media-gallery__gifv__label'>GIF</span> + </div> ); } return ( - <div className='account-gallery__item'> - <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}> - {icon} - {label} - </Permalink> + <div className='account-gallery__item' style={{ width, height }}> + <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} /> + {visible && thumbnail} + </a> </div> ); } diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 73be58d6a..5d6a53e18 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -2,24 +2,25 @@ import React from 'react'; import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { fetchAccount } from '../../actions/accounts'; +import { fetchAccount } from 'mastodon/actions/accounts'; import { expandAccountMediaTimeline } from '../../actions/timelines'; -import LoadingIndicator from '../../components/loading_indicator'; +import LoadingIndicator from 'mastodon/components/loading_indicator'; import Column from '../ui/components/column'; -import ColumnBackButton from '../../components/column_back_button'; +import ColumnBackButton from 'mastodon/components/column_back_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { getAccountGallery } from '../../selectors'; +import { getAccountGallery } from 'mastodon/selectors'; import MediaItem from './components/media_item'; import HeaderContainer from '../account_timeline/containers/header_container'; import { ScrollContainer } from 'react-router-scroll-4'; -import LoadMore from '../../components/load_more'; +import LoadMore from 'mastodon/components/load_more'; import MissingIndicator from 'mastodon/components/missing_indicator'; +import { openModal } from 'mastodon/actions/modal'; const mapStateToProps = (state, props) => ({ isAccount: !!state.getIn(['accounts', props.params.accountId]), - medias: getAccountGallery(state, props.params.accountId), + attachments: getAccountGallery(state, props.params.accountId), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), }); class LoadMoreMedia extends ImmutablePureComponent { @@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - medias: ImmutablePropTypes.list.isRequired, + attachments: ImmutablePropTypes.list.isRequired, isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, }; + state = { + width: 323, + }; + componentDidMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); @@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent { handleScrollToBottom = () => { if (this.props.hasMore) { - this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined); + this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); } } - handleScroll = (e) => { + handleScroll = e => { const { scrollTop, scrollHeight, clientHeight } = e.target; const offset = scrollHeight - scrollTop - clientHeight; @@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent { this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); }; - handleLoadOlder = (e) => { + handleLoadOlder = e => { e.preventDefault(); this.handleScrollToBottom(); } + handleOpenMedia = attachment => { + if (attachment.get('type') === 'video') { + this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else { + const media = attachment.getIn(['status', 'media_attachments']); + const index = media.findIndex(x => x.get('id') === attachment.get('id')); + + this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + } + } + + handleRef = c => { + if (c) { + this.setState({ width: c.offsetWidth }); + } + } + render () { - const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; + const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; + const { width } = this.state; if (!isAccount) { return ( @@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent { ); } - let loadOlder = null; - - if (!medias && isLoading) { + if (!attachments && isLoading) { return ( <Column> <LoadingIndicator /> @@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent { ); } - if (hasMore && !(isLoading && medias.size === 0)) { + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; } @@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent { <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <HeaderContainer accountId={this.props.params.accountId} /> - <div role='feed' className='account-gallery__container'> - {medias.map((media, index) => media === null ? ( - <LoadMoreMedia - key={'more:' + medias.getIn(index + 1, 'id')} - maxId={index > 0 ? medias.getIn(index - 1, 'id') : null} - onLoadMore={this.handleLoadMore} - /> + <div role='feed' className='account-gallery__container' ref={this.handleRef}> + {attachments.map((attachment, index) => attachment === null ? ( + <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> ) : ( - <MediaItem - key={media.get('id')} - media={media} - /> + <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> ))} + {loadOlder} </div> - {isLoading && medias.size === 0 && ( + {isLoading && attachments.size === 0 && ( <div className='scrollable__append'> <LoadingIndicator /> </div> diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 13514fa0f..03738f1de 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; -import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent { <UploadButtonContainer /> <PollButtonContainer /> <PrivacyDropdownContainer /> - <SensitiveButtonContainer /> <SpoilerButtonContainer /> </div> <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div> diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js index b7f112205..9ff2aa0fa 100644 --- a/app/javascript/mastodon/features/compose/components/upload_form.js +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import UploadProgressContainer from '../containers/upload_progress_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import UploadContainer from '../containers/upload_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; export default class UploadForm extends ImmutablePureComponent { @@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent { <UploadContainer id={id} key={id} /> ))} </div> + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} </div> ); } diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index 43de8f213..50612b086 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -2,11 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import IconButton from '../../../components/icon_button'; -import { changeComposeSensitivity } from '../../../actions/compose'; -import Motion from '../../ui/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import { injectIntl, defineMessages } from 'react-intl'; +import { changeComposeSensitivity } from 'mastodon/actions/compose'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import Icon from 'mastodon/components/icon'; const messages = defineMessages({ marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, @@ -14,7 +12,6 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - visible: state.getIn(['compose', 'media_attachments']).size > 0, active: state.getIn(['compose', 'sensitive']), disabled: state.getIn(['compose', 'spoiler']), }); @@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({ class SensitiveButton extends React.PureComponent { static propTypes = { - visible: PropTypes.bool, active: PropTypes.bool, disabled: PropTypes.bool, onClick: PropTypes.func.isRequired, @@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent { }; render () { - const { visible, active, disabled, onClick, intl } = this.props; + const { active, disabled, onClick, intl } = this.props; return ( - <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> - {({ scale }) => { - const icon = active ? 'eye-slash' : 'eye'; - const className = classNames('compose-form__sensitive-button', { - 'compose-form__sensitive-button--visible': visible, - }); - return ( - <div className={className} style={{ transform: `scale(${scale})` }}> - <IconButton - className='compose-form__sensitive-button__icon' - title={intl.formatMessage(active ? messages.marked : messages.unmarked)} - icon={icon} - onClick={onClick} - size={18} - active={active} - disabled={disabled} - style={{ lineHeight: null, height: null }} - inverted - /> - </div> - ); - }} - </Motion> + <div className='compose-form__sensitive-button'> + <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + </button> + </div> ); } diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js index 635c03c1d..8867bbd73 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js @@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent { handleMoveUp = id => { const elementIndex = this.getCurrentIndex(id) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = id => { const elementIndex = this.getCurrentIndex(id) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } - _selectChild (index) { - const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 9430b2050..006c45657 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -113,18 +113,24 @@ class Notifications extends React.PureComponent { handleMoveUp = id => { const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, true); } handleMoveDown = id => { const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; - this._selectChild(elementIndex); + this._selectChild(elementIndex, false); } - _selectChild (index) { - const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + _selectChild (index, align_top) { + const container = this.column.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index 2552d94d8..c29e517da 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent { {Component => ( <Component preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={239} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 5c79f9f19..84471f9a3 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; import StatusContent from '../../../components/status_content'; import MediaGallery from '../../../components/media_gallery'; -import AttachmentList from '../../../components/attachment_list'; import { Link } from 'react-router-dom'; import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; @@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; } else if (status.get('media_attachments').size > 0) { - if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { - media = <AttachmentList media={status.get('media_attachments')} />; - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const video = status.getIn(['media_attachments', 0]); media = ( <Video preview={video.get('preview_url')} + blurhash={video.get('blurhash')} src={video.get('url')} alt={video.get('description')} width={300} diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 567af6be9..6279bb468 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size - 1); + this._selectChild(ancestorsIds.size - 1, true); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index); + this._selectChild(ancestorsIds.size + index, true); } else { - this._selectChild(index - 1); + this._selectChild(index - 1, true); } } } @@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size + 1); + this._selectChild(ancestorsIds.size + 1, false); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index + 2); + this._selectChild(ancestorsIds.size + index + 2, false); } else { - this._selectChild(index + 1); + this._selectChild(index + 1, false); } } } - _selectChild (index) { - const element = this.node.querySelectorAll('.focusable')[index]; + _selectChild (index, align_top) { + const container = this.node; + const element = container.querySelectorAll('.focusable')[index]; if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } element.focus(); } } diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 2120746da..da2ac5f26 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -2,11 +2,11 @@ import React from 'react'; import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import Video from '../../video'; -import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import Video from 'mastodon/features/video'; +import ExtendedVideoPlayer from 'mastodon/components/extended_video_player'; import classNames from 'classnames'; -import { defineMessages, injectIntl } from 'react-intl'; -import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'mastodon/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; import Icon from 'mastodon/components/icon'; @@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, + status: ImmutablePropTypes.map, index: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, @@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent { componentDidMount () { window.addEventListener('keydown', this.handleKeyDown, false); + if (this.context.router) { const history = this.context.router.history; + history.push(history.location.pathname, previewState); + this.unlistenHistory = history.listen(() => { this.props.onClose(); }); @@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent { componentWillUnmount () { window.removeEventListener('keydown', this.handleKeyDown); + if (this.context.router) { this.unlistenHistory(); @@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent { })); }; + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, intl, onClose } = this.props; + const { media, status, intl, onClose } = this.props; const { navigationHidden } = this.state; const index = this.getIndex(); @@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent { return ( <Video preview={image.get('preview_url')} + blurhash={image.get('blurhash')} src={image.get('url')} width={image.get('width')} height={image.get('height')} @@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent { {content} </ReactSwipeableViews> </div> + <div className={navigationClassName}> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} {rightNav} + + {status && ( + <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> + <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> + </div> + )} + <ul className='media-modal__pagination'> {pagination} </ul> diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 7cf3eb4d4..213d31316 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -1,28 +1,69 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import Video from '../../video'; +import Video from 'mastodon/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; + +export const previewState = 'previewVideoModal'; export default class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, time: PropTypes.number, onClose: PropTypes.func.isRequired, }; + static contextTypes = { + router: PropTypes.object, + }; + + componentDidMount () { + if (this.context.router) { + const history = this.context.router.history; + + history.push(history.location.pathname, previewState); + + this.unlistenHistory = history.listen(() => { + this.props.onClose(); + }); + } + } + + componentWillUnmount () { + if (this.context.router) { + this.unlistenHistory(); + + if (this.context.router.history.location.state === previewState) { + this.context.router.history.goBack(); + } + } + } + + handleStatusClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + } + } + render () { - const { media, time, onClose } = this.props; + const { media, status, time, onClose } = this.props; + + const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>; return ( <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} + blurhash={media.get('blurhash')} src={media.get('url')} startTime={time} onCloseVideo={onClose} + link={link} detailed alt={media.get('description')} /> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 93e45678f..1fcea779d 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -47,7 +47,8 @@ import { Lists, } from './util/async-components'; import { me } from '../../initial_state'; -import { previewState } from './components/media_modal'; +import { previewState as previewMediaState } from './components/media_modal'; +import { previewState as previewVideoState } from './components/video_modal'; // Dummy import, to make sure that <Status /> ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. @@ -121,7 +122,7 @@ class SwitchingColumnsArea extends React.PureComponent { } shouldUpdateScroll (_, { location }) { - return location.state !== previewState; + return location.state !== previewMediaState && location.state !== previewVideoState; } handleResize = debounce(() => { @@ -367,11 +368,16 @@ class UI extends React.PureComponent { handleHotkeyFocusColumn = e => { const index = (e.key * 1) + 1; // First child is drawer, skip that const column = this.node.querySelector(`.column:nth-child(${index})`); + if (!column) return; + const container = column.querySelector('.scrollable'); - if (column) { - const status = column.querySelector('.focusable'); + if (container) { + const status = container.querySelector('.focusable'); if (status) { + if (container.scrollTop > status.offsetTop) { + status.scrollIntoView(true); + } status.focus(); } } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 55dd249e1..00a63a3d9 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; import { displayMedia } from '../../initial_state'; import Icon from 'mastodon/components/icon'; +import { decode } from 'blurhash'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -102,6 +103,8 @@ class Video extends React.PureComponent { inline: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, + blurhash: PropTypes.string, + link: PropTypes.node, }; state = { @@ -139,6 +142,7 @@ class Video extends React.PureComponent { setVideoRef = c => { this.video = c; + if (this.video) { this.setState({ volume: this.video.volume, muted: this.video.muted }); } @@ -152,6 +156,10 @@ class Video extends React.PureComponent { this.volume = c; } + setCanvasRef = c => { + this.canvas = c; + } + handleClickRoot = e => e.stopPropagation(); handlePlay = () => { @@ -170,7 +178,6 @@ class Video extends React.PureComponent { } handleVolumeMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseVolSlide, true); document.addEventListener('mouseup', this.handleVolumeMouseUp, true); document.addEventListener('touchmove', this.handleMouseVolSlide, true); @@ -190,7 +197,6 @@ class Video extends React.PureComponent { } handleMouseVolSlide = throttle(e => { - const rect = this.volume.getBoundingClientRect(); const x = (e.clientX - rect.left) / this.volWidth; //x position within the element. @@ -261,6 +267,10 @@ class Video extends React.PureComponent { document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (this.props.blurhash) { + this._decode(); + } } componentWillUnmount () { @@ -270,6 +280,24 @@ class Video extends React.PureComponent { document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); } + componentDidUpdate (prevProps) { + if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { + this._decode(); + } + } + + _decode () { + const hash = this.props.blurhash; + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); + } + } + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -314,6 +342,7 @@ class Video extends React.PureComponent { handleOpenVideo = () => { const { src, preview, width, height, alt } = this.props; + const media = fromJS({ type: 'video', url: src, @@ -333,7 +362,7 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; @@ -351,6 +380,7 @@ class Video extends React.PureComponent { } let preload; + if (startTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { @@ -360,6 +390,7 @@ class Video extends React.PureComponent { } let warning; + if (sensitive) { warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; } else { @@ -377,7 +408,9 @@ class Video extends React.PureComponent { onClick={this.handleClickRoot} tabIndex={0} > - <video + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> + + {revealed && <video ref={this.setVideoRef} src={src} poster={preview} @@ -397,12 +430,13 @@ class Video extends React.PureComponent { onLoadedData={this.handleLoadedData} onProgress={this.handleProgress} onVolumeChange={this.handleVolumeChange} - /> + />} - <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> - <span className='video-player__spoiler__title'>{warning}</span> - <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </button> + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> + <span className='spoiler-button__overlay__label'>{warning}</span> + </button> + </div> <div className={classNames('video-player__controls', { active: paused || hovered })}> <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> @@ -420,6 +454,7 @@ class Video extends React.PureComponent { <div className='video-player__buttons left'> <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> <span @@ -429,17 +464,19 @@ class Video extends React.PureComponent { /> </div> - {(detailed || fullscreen) && + {(detailed || fullscreen) && ( <span> <span className='video-player__time-current'>{formatTime(currentTime)}</span> <span className='video-player__time-sep'>/</span> <span className='video-player__time-total'>{formatTime(duration)}</span> </span> - } + )} + + {link && <span className='video-player__link'>{link}</span>} </div> <div className='video-player__buttons right'> - {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>} + {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index f98ea7f26..cbf303f3c 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -77,6 +77,7 @@ "compose_form.poll.remove_option": "Odstranit tuto volbu", "compose_form.publish": "Tootnout", "compose_form.publish_loud": "{publish}!", + "compose_form.sensitive.hide": "Označit média jako citlivá", "compose_form.sensitive.marked": "Média jsou označena jako citlivá", "compose_form.sensitive.unmarked": "Média nejsou označena jako citlivá", "compose_form.spoiler.marked": "Text je skrytý za varováním", @@ -209,6 +210,7 @@ "lightbox.close": "Zavřít", "lightbox.next": "Další", "lightbox.previous": "Předchozí", + "lightbox.view_context": "Zobrazit kontext", "lists.account.add": "Přidat do seznamu", "lists.account.remove": "Odebrat ze seznamu", "lists.delete": "Smazat seznam", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index d155619c9..ca7732d85 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -243,7 +243,7 @@ "navigation_bar.pins": "Ամրացված թթեր", "navigation_bar.preferences": "Նախապատվություններ", "navigation_bar.public_timeline": "Դաշնային հոսք", - "navigation_bar.security": "Security", + "navigation_bar.security": "Անվտանգություն", "notification.favourite": "{name} հավանեց թութդ", "notification.follow": "{name} սկսեց հետեւել քեզ", "notification.mention": "{name} նշեց քեզ", @@ -309,7 +309,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", "status.block": "Արգելափակել @{name}֊ին", diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 05c7821e4..74f91599a 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -50,6 +50,7 @@ $content-width: 840px; color: $darker-text-color; text-decoration: none; transition: all 200ms linear; + transition-property: color, background-color; border-radius: 4px 0 0 4px; i.fa { @@ -60,6 +61,7 @@ $content-width: 840px; color: $primary-text-color; background-color: darken($ui-base-color, 5%); transition: all 100ms linear; + transition-property: color, background-color; } &.selected { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 7ea4ad07c..1ba6fa5b1 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -264,6 +264,16 @@ .compose-form { padding: 10px; + &__sensitive-button { + padding: 10px; + padding-top: 0; + + .icon-button { + font-size: 14px; + font-weight: 500; + } + } + .compose-form__warning { color: $inverted-text-color; margin-bottom: 10px; @@ -1962,6 +1972,7 @@ a.account__display-name { font-weight: 500; border-bottom: 2px solid lighten($ui-base-color, 8%); transition: all 50ms linear; + transition-property: border-bottom, background, color; .fa { font-weight: 400; @@ -2127,7 +2138,7 @@ a.account__display-name { padding: 0; border-radius: 30px; background-color: $ui-base-color; - transition: all 0.2s ease; + transition: background-color 0.2s ease; } .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { @@ -2180,7 +2191,6 @@ a.account__display-name { } .react-toggle-thumb { - transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; position: absolute; top: 1px; left: 1px; @@ -2191,6 +2201,7 @@ a.account__display-name { background-color: darken($simple-background-color, 2%); box-sizing: border-box; transition: all 0.25s ease; + transition-property: border-color, left; } .react-toggle--checked .react-toggle-thumb { @@ -2412,7 +2423,7 @@ a.account__display-name { & > div { background: rgba($base-shadow-color, 0.6); - border-radius: 4px; + border-radius: 8px; padding: 12px 9px; flex: 0 0 auto; display: flex; @@ -2423,19 +2434,18 @@ a.account__display-name { button, a { display: inline; - color: $primary-text-color; + color: $secondary-text-color; background: transparent; border: 0; - padding: 0 5px; + padding: 0 8px; text-decoration: none; - opacity: 0.6; font-size: 18px; line-height: 18px; &:hover, &:active, &:focus { - opacity: 1; + color: $primary-text-color; } } @@ -2932,15 +2942,49 @@ a.status-card.compact:hover { } .spoiler-button { - display: none; - left: 4px; + top: 0; + left: 0; + width: 100%; + height: 100%; position: absolute; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; - top: 4px; z-index: 100; - &.spoiler-button--visible { + &--minified { display: block; + left: 4px; + top: 4px; + width: auto; + height: auto; + } + + &--hidden { + display: none; + } + + &__overlay { + display: block; + background: transparent; + width: 100%; + height: 100%; + border: 0; + + &__label { + display: inline-block; + background: rgba($base-overlay-background, 0.5); + border-radius: 8px; + padding: 8px 12px; + color: $primary-text-color; + font-weight: 500; + font-size: 14px; + } + + &:hover, + &:focus, + &:active { + .spoiler-button__overlay__label { + background: rgba($base-overlay-background, 0.8); + } + } } } @@ -3509,6 +3553,7 @@ a.status-card.compact:hover { display: inline-block; opacity: 0; transition: all 100ms linear; + transition-property: transform, opacity; font-size: 18px; width: 18px; height: 18px; @@ -3728,6 +3773,31 @@ a.status-card.compact:hover { pointer-events: none; } +.media-modal__meta { + text-align: center; + position: absolute; + left: 0; + bottom: 20px; + width: 100%; + pointer-events: none; + + &--shifted { + bottom: 62px; + } + + a { + text-decoration: none; + font-weight: 500; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} + .media-modal__page-dot { display: inline-block; } @@ -4200,6 +4270,7 @@ a.status-card.compact:hover { pointer-events: none; opacity: 0.9; transition: opacity 0.1s ease; + line-height: 18px; } .media-gallery__gifv { @@ -4313,6 +4384,8 @@ a.status-card.compact:hover { text-decoration: none; color: $secondary-text-color; line-height: 0; + position: relative; + z-index: 1; &, img { @@ -4325,6 +4398,21 @@ a.status-card.compact:hover { } } +.media-gallery__preview { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .media-gallery__gifv { height: 100%; overflow: hidden; @@ -4620,6 +4708,23 @@ a.status-card.compact:hover { } } + &__link { + padding: 2px 10px; + + a { + text-decoration: none; + font-size: 14px; + font-weight: 500; + color: $white; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } + } + &__seek { cursor: pointer; height: 24px; @@ -4712,62 +4817,18 @@ a.status-card.compact:hover { .account-gallery__container { display: flex; - justify-content: center; flex-wrap: wrap; - padding: 2px; + padding: 4px 2px; } .account-gallery__item { - flex-grow: 1; - width: 50%; - overflow: hidden; + border: none; + box-sizing: border-box; + display: block; position: relative; - - &::before { - content: ""; - display: block; - padding-top: 100%; - } - - a { - display: block; - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - top: 0; - left: 0; - background-color: $base-overlay-background; - background-size: cover; - background-position: center; - position: absolute; - color: $darker-text-color; - text-decoration: none; - border-radius: 4px; - - &:hover, - &:active, - &:focus { - outline: 0; - color: $secondary-text-color; - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - background: rgba($base-overlay-background, 0.3); - border-radius: 4px; - } - } - } - - &__icons { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 24px; - } + border-radius: 4px; + overflow: hidden; + margin: 2px; } .notification__filter-bar, diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 91888d305..2b8d7a682 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -533,6 +533,17 @@ code { color: $error-value-color; } + a { + display: inline-block; + color: $darker-text-color; + text-decoration: none; + + &:hover { + color: $primary-text-color; + text-decoration: underline; + } + } + p { margin-bottom: 15px; } diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index e736d7a7e..acaf5b024 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -4,7 +4,6 @@ &__img { width: 100%; - height: 167px; position: relative; overflow: hidden; border-radius: 4px 4px 0 0; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index dabdcbcf7..6b16c9986 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if attachment['url'].blank? href = Addressable::URI.parse(attachment['url']).normalize.to_s - media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint']) + media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) media_attachments << media_attachment next if unsupported_media_type?(attachment['mediaType']) || skip_download? @@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) end + def supported_blurhash?(blurhash) + components = blurhash.blank? ? nil : Blurhash.components(blurhash) + components.present? && components.none? { |comp| comp > 5 } + end + def skip_download? return @skip_download if defined?(@skip_download) @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 94eb2899c..c259c96f4 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' }, focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' }, + blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, }.freeze def self.default_key_transform diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb index e1b5e3832..84ff84c4b 100644 --- a/app/models/concerns/ldap_authenticable.rb +++ b/app/models/concerns/ldap_authenticable.rb @@ -6,6 +6,7 @@ module LdapAuthenticable def ldap_setup(_attributes) self.confirmed_at = Time.now.utc self.admin = false + self.external = true save! end diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 1b28b8162..283033083 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -66,6 +66,7 @@ module Omniauthable email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com", password: Devise.friendly_token[0, 20], agreement: true, + external: true, account_attributes: { username: ensure_unique_username(auth.uid), display_name: display_name, diff --git a/app/models/concerns/pam_authenticable.rb b/app/models/concerns/pam_authenticable.rb index 2f651c1a3..6169d4dfa 100644 --- a/app/models/concerns/pam_authenticable.rb +++ b/app/models/concerns/pam_authenticable.rb @@ -34,6 +34,7 @@ module PamAuthenticable self.confirmed_at = Time.now.utc self.admin = false self.account = account + self.external = true account.destroy! unless save end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 069cda367..0b12617c6 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord def self.blocked?(domain) where(domain: domain, severity: :suspend).exists? end + + def stricter_than?(other_block) + return true if suspend? + return false if other_block.suspend? && (silence? || noop?) + return false if other_block.silence? && noop? + (reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports) + end end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 81397a18e..65f00e1c8 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -18,6 +18,7 @@ # account_id :bigint(8) # description :text # scheduled_status_id :bigint(8) +# blurhash :string # class MediaAttachment < ApplicationRecord @@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze + BLURHASH_OPTIONS = { + x_comp: 4, + y_comp: 4, + }.freeze + IMAGE_STYLES = { original: { pixels: 1_638_400, # 1280x1280px @@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord small: { pixels: 160_000, # 400x400px file_geometry_parser: FastGeometryParser, + blurhash: BLURHASH_OPTIONS, }, }.freeze @@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord }, format: 'png', time: 0, + file_geometry_parser: FastGeometryParser, + blurhash: BLURHASH_OPTIONS, }, }.freeze @@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord def file_processors(f) if f.file_content_type == 'image/gif' - [:gif_transcoder] + [:gif_transcoder, :blurhash_transcoder] elsif VIDEO_MIME_TYPES.include? f.file_content_type - [:video_transcoder] + [:video_transcoder, :blurhash_transcoder] elsif AUDIO_MIME_TYPES.include? f.file_content_type [:audio_transcoder] else - [:lazy_thumbnail] + [:lazy_thumbnail, :blurhash_transcoder] end end end diff --git a/app/models/user.rb b/app/models/user.rb index b2fb820af..77e6a33b5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -78,7 +78,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? - validates_with BlacklistedEmailValidator, if: :email_changed? + validates_with BlacklistedEmailValidator, on: :create validates_with EmailMxValidator, if: :validate_email_dns? validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create @@ -107,13 +107,14 @@ class User < ApplicationRecord :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code + attr_writer :external def confirmed? confirmed_at.present? end def invited? - invite_id.present? + invite_id.present? && invite.valid_for_use? end def disable! @@ -273,13 +274,17 @@ class User < ApplicationRecord private def set_approved - self.approved = open_registrations? || invited? + self.approved = open_registrations? || invited? || external? end def open_registrations? Setting.registrations_mode == 'open' end + def external? + !!@external + end + def sanitize_languages return if chosen_languages.nil? chosen_languages.reject!(&:blank?) diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index d11cfa59a..67f596e78 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -2,7 +2,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer context_extensions :atom_uri, :conversation, :sensitive, - :hashtag, :emoji, :focal_point + :hashtag, :emoji, :focal_point, :blurhash attributes :id, :type, :summary, :in_reply_to, :published, :url, @@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer class MediaAttachmentSerializer < ActivityPub::Serializer include RoutingHelper - attributes :type, :media_type, :url, :name + attributes :type, :media_type, :url, :name, :blurhash attribute :focal_point, if: :focal_point? def type diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb index 51011788b..1b3498ea4 100644 --- a/app/serializers/rest/media_attachment_serializer.rb +++ b/app/serializers/rest/media_attachment_serializer.rb @@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer attributes :id, :type, :url, :preview_url, :remote_url, :text_url, :meta, - :description + :description, :blurhash def id object.id.to_s diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 140b238df..10ed470e0 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -6,6 +6,7 @@ class BlockService < BaseService UnfollowService.new.call(account, target_account) if account.following?(target_account) UnfollowService.new.call(target_account, account) if target_account.following?(account) + RejectFollowService.new.call(account, target_account) if target_account.requested?(account) block = account.block!(target_account) diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb index a2061fdd3..a288c20ef 100644 --- a/app/validators/blacklisted_email_validator.rb +++ b/app/validators/blacklisted_email_validator.rb @@ -2,7 +2,10 @@ class BlacklistedEmailValidator < ActiveModel::Validator def validate(user) + return if user.invited? + @email = user.email + user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email? end @@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator end def on_blacklist? - return true if EmailDomainBlock.block?(@email) + return true if EmailDomainBlock.block?(@email) return false if Rails.configuration.x.email_domains_blacklist.blank? domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.') diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 4459581d9..23f2920d8 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -28,7 +28,7 @@ - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do + = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 6d2a408ea..4df1a0cdf 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -33,7 +33,7 @@ - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do + = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb index a3abe72cf..05139f616 100644 --- a/app/workers/activitypub/processing_worker.rb +++ b/app/workers/activitypub/processing_worker.rb @@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker def perform(account_id, body, delivered_to_account_id = nil) ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.debug "Error processing incoming ActivityPub object: #{e}" end end diff --git a/config/initializers/rack_attack_logging.rb b/config/initializers/rack_attack_logging.rb index 2ddbfb99c..c30bd8a64 100644 --- a/config/initializers/rack_attack_logging.rb +++ b/config/initializers/rack_attack_logging.rb @@ -1,4 +1,6 @@ -ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req| +ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _request_id, payload| + req = payload[:request] + next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type'] Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}") end diff --git a/config/locales/co.yml b/config/locales/co.yml index aa68336f1..8c1a13e54 100644 --- a/config/locales/co.yml +++ b/config/locales/co.yml @@ -269,6 +269,7 @@ co: created_msg: U blucchime di u duminiu hè attivu destroyed_msg: U blucchime di u duminiu ùn hè più attivu domain: Duminiu + existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu. new: create: Creà un blucchime hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati. diff --git a/config/locales/cs.yml b/config/locales/cs.yml index ca456b7ef..5d05a13d6 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -273,6 +273,7 @@ cs: created_msg: Blokace domény se právě vyřizuje destroyed_msg: Blokace domény byla zrušena domain: Doména + existing_domain_block_html: Pro účet %{name} jste již nastavil/a přísnější omezení, musíte jej nejdříve <a href="%{unblock_url}">odblokovat</a>. new: create: Vytvořit blokaci hint: Blokace domény nezakáže vytváření záznamů účtů v databázi, ale bude na tyto účty zpětně a automaticky aplikovat specifické metody moderování. diff --git a/config/locales/en.yml b/config/locales/en.yml index 20a75e60c..5f87b34d6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -270,6 +270,7 @@ en: created_msg: Domain block is now being processed destroyed_msg: Domain block has been undone domain: Domain + existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first. new: create: Create block hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts. diff --git a/config/locales/fr.yml b/config/locales/fr.yml index a6c806de3..d588b239f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -260,10 +260,10 @@ fr: title: Nouveau blocage de domaine reject_media: Fichiers média rejetés reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions - reject_reports: Rapports de rejet - reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions + reject_reports: Rejeter les signalements + reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions rejecting_media: rejet des fichiers multimédia - rejecting_reports: rejet de rapports + rejecting_reports: rejet des signalements severity: silence: silencié suspend: suspendu diff --git a/config/locales/sk.yml b/config/locales/sk.yml index d3580c981..d859d16b1 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -268,10 +268,11 @@ sk: week_users_active: aktívni tento týždeň week_users_new: užívateľov počas tohto týždňa domain_blocks: - add_new: Pridaj nové doménové blokovanie + add_new: Blokuj novú doménu created_msg: Doména je v štádiu blokovania destroyed_msg: Blokovanie domény bolo zrušené domain: Doména + existing_domain_block_html: Pre účet %{name} si už nahodil/a přísnejšie obmedzenie, najskôr ho teda musíš <a href="%{unblock_url}">odblokovať</a>. new: create: Vytvor blokovanie domény hint: Blokovanie domény stále dovolí vytvárať nové účty v databázi, ale tieto budú spätne automaticky moderované. @@ -299,7 +300,7 @@ sk: silence: Zruš stíšenie všetkých existujúcich účtov z tejto domény suspend: Zruš suspendáciu všetkých existujúcich účtov z tejto domény title: Zruš blokovanie domény %{domain} - undo: Vrátiť späť + undo: Vráť späť undo: Odvolaj blokovanie domény email_domain_blocks: add_new: Pridaj nový @@ -527,16 +528,17 @@ sk: login: Prihlás sa logout: Odhlás sa migrate_account: Presúvam sa na iný účet - migrate_account_html: Pokiaľ si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>. - or_log_in_with: Alebo prihlásiť z + migrate_account_html: Ak si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>. + or_log_in_with: Alebo prihlás s providers: cas: CAS saml: SAML register: Zaregistruj sa - resend_confirmation: Poslať potvrdzujúce pokyny znovu + resend_confirmation: Zašli potvrdzujúce pokyny znovu reset_password: Obnov heslo security: Zabezpečenie set_new_password: Nastav nové heslo + trouble_logging_in: Problém s prihlásením? authorize_follow: already_following: Tento účet už následuješ error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu diff --git a/db/migrate/20190420025523_add_blurhash_to_media_attachments.rb b/db/migrate/20190420025523_add_blurhash_to_media_attachments.rb new file mode 100644 index 000000000..f2bbe0a85 --- /dev/null +++ b/db/migrate/20190420025523_add_blurhash_to_media_attachments.rb @@ -0,0 +1,5 @@ +class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2] + def change + add_column :media_attachments, :blurhash, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 5ffec228d..26bbe30fe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_04_09_054914) do +ActiveRecord::Schema.define(version: 2019_04_20_025523) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -373,6 +373,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do t.bigint "account_id" t.text "description" t.bigint "scheduled_status_id" + t.string "blurhash" t.index ["account_id"], name: "index_media_attachments_on_account_id" t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id" t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true diff --git a/lib/cli.rb b/lib/cli.rb index b56c6e76f..5780e3e87 100644 --- a/lib/cli.rb +++ b/lib/cli.rb @@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli' require_relative 'mastodon/settings_cli' require_relative 'mastodon/statuses_cli' require_relative 'mastodon/domains_cli' +require_relative 'mastodon/cache_cli' require_relative 'mastodon/version' module Mastodon @@ -41,6 +42,9 @@ module Mastodon desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains' subcommand 'domains', Mastodon::DomainsCLI + desc 'cache SUBCOMMAND ...ARGS', 'Manage cache' + subcommand 'cache', Mastodon::CacheCLI + option :dry_run, type: :boolean desc 'self-destruct', 'Erase the server from the federation' long_desc <<~LONG_DESC diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb index 9dc84f1b5..3131647f3 100644 --- a/lib/mastodon/accounts_cli.rb +++ b/lib/mastodon/accounts_cli.rb @@ -73,7 +73,7 @@ module Mastodon def create(username) account = Account.new(username: username) password = SecureRandom.hex - user = User.new(email: options[:email], password: password, agreement: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil) + user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil) if options[:reattach] account = Account.find_local(username) || Account.new(username: username) @@ -115,6 +115,7 @@ module Mastodon option :enable, type: :boolean option :disable, type: :boolean option :disable_2fa, type: :boolean + option :approve, type: :boolean desc 'modify USERNAME', 'Modify a user' long_desc <<-LONG_DESC Modify a user account. @@ -128,6 +129,9 @@ module Mastodon With the --disable option, lock the user out of their account. The --enable option is the opposite. + With the --approve option, the account will be approved, if it was + previously not due to not having open registrations. + With the --disable-2fa option, the two-factor authentication requirement for the user can be removed. LONG_DESC @@ -147,6 +151,7 @@ module Mastodon user.email = options[:email] if options[:email] user.disabled = false if options[:enable] user.disabled = true if options[:disable] + user.approved = true if options[:approve] user.otp_required_for_login = false if options[:disable_2fa] user.confirm if options[:confirm] diff --git a/lib/mastodon/cache_cli.rb b/lib/mastodon/cache_cli.rb new file mode 100644 index 000000000..e9b6667b3 --- /dev/null +++ b/lib/mastodon/cache_cli.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative '../../config/boot' +require_relative '../../config/environment' +require_relative 'cli_helper' + +module Mastodon + class CacheCLI < Thor + def self.exit_on_failure? + true + end + + desc 'clear', 'Clear out the cache storage' + def clear + Rails.cache.clear + say('OK', :green) + end + end +end diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 8172aeb94..5ea569b29 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 0 + 1 end def pre @@ -46,7 +46,7 @@ module Mastodon # specify git tag or commit hash here def source_tag - nil + ENV.fetch('SOURCE_TAG') { nil } end def source_url diff --git a/lib/paperclip/blurhash_transcoder.rb b/lib/paperclip/blurhash_transcoder.rb new file mode 100644 index 000000000..08925a6dd --- /dev/null +++ b/lib/paperclip/blurhash_transcoder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Paperclip + class BlurhashTranscoder < Paperclip::Processor + def make + return @file unless options[:style] == :small + + pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*') + geometry = options.fetch(:file_geometry_parser).from_file(@file) + + attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {}) + + @file + end + end +end diff --git a/package.json b/package.json index b5963acd4..8262c73aa 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "babel-plugin-react-intl": "^3.0.1", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "babel-runtime": "^6.26.0", + "blurhash": "^1.0.0", "classnames": "^2.2.5", "compression-webpack-plugin": "^2.0.0", "cross-env": "^5.1.4", diff --git a/public/robots.txt b/public/robots.txt index d93648bee..771bf2160 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -2,3 +2,4 @@ User-agent: * Disallow: /media_proxy/ +Disallow: /interact/ diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb index 129bf8883..2a8675c21 100644 --- a/spec/controllers/admin/domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/domain_blocks_controller_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do end it 'renders new when failed to save' do - Fabricate(:domain_block, domain: 'example.com') + Fabricate(:domain_block, domain: 'example.com', severity: 'suspend') allow(DomainBlockWorker).to receive(:perform_async).and_return(true) post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } } @@ -45,6 +45,17 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do expect(DomainBlockWorker).not_to have_received(:perform_async) expect(response).to render_template :new end + + it 'allows upgrading a block' do + Fabricate(:domain_block, domain: 'example.com', severity: 'silence') + allow(DomainBlockWorker).to receive(:perform_async).and_return(true) + + post :create, params: { domain_block: { domain: 'example.com', severity: 'silence', reject_media: true, reject_reports: true } } + + expect(DomainBlockWorker).to have_received(:perform_async) + expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg') + expect(response).to redirect_to(admin_instances_path(limited: '1')) + end end describe 'DELETE #destroy' do diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index 1095df034..a4337039e 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -107,6 +107,89 @@ RSpec.describe Auth::RegistrationsController, type: :controller do end end + context 'approval-based registrations without invite' do + around do |example| + registrations_mode = Setting.registrations_mode + example.run + Setting.registrations_mode = registrations_mode + end + + subject do + Setting.registrations_mode = 'approved' + request.headers["Accept-Language"] = accept_language + post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } } + end + + it 'redirects to login page' do + subject + expect(response).to redirect_to new_user_session_path + end + + it 'creates user' do + subject + user = User.find_by(email: 'test@example.com') + expect(user).to_not be_nil + expect(user.locale).to eq(accept_language) + expect(user.approved).to eq(false) + end + end + + context 'approval-based registrations with expired invite' do + around do |example| + registrations_mode = Setting.registrations_mode + example.run + Setting.registrations_mode = registrations_mode + end + + subject do + Setting.registrations_mode = 'approved' + request.headers["Accept-Language"] = accept_language + invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago) + post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } } + end + + it 'redirects to login page' do + subject + expect(response).to redirect_to new_user_session_path + end + + it 'creates user' do + subject + user = User.find_by(email: 'test@example.com') + expect(user).to_not be_nil + expect(user.locale).to eq(accept_language) + expect(user.approved).to eq(false) + end + end + + context 'approval-based registrations with valid invite' do + around do |example| + registrations_mode = Setting.registrations_mode + example.run + Setting.registrations_mode = registrations_mode + end + + subject do + Setting.registrations_mode = 'approved' + request.headers["Accept-Language"] = accept_language + invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now) + post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } } + end + + it 'redirects to login page' do + subject + expect(response).to redirect_to new_user_session_path + end + + it 'creates user' do + subject + user = User.find_by(email: 'test@example.com') + expect(user).to_not be_nil + expect(user.locale).to eq(accept_language) + expect(user.approved).to eq(true) + end + end + it 'does nothing if user already exists' do Fabricate(:user, account: Fabricate(:account, username: 'test')) subject diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb index 89cadccfe..0035fd0ff 100644 --- a/spec/models/domain_block_spec.rb +++ b/spec/models/domain_block_spec.rb @@ -36,4 +36,35 @@ RSpec.describe DomainBlock, type: :model do expect(DomainBlock.blocked?('domain')).to eq false end end + + describe 'stricter_than?' do + it 'returns true if the new block has suspend severity while the old has lower severity' do + suspend = DomainBlock.new(domain: 'domain', severity: :suspend) + silence = DomainBlock.new(domain: 'domain', severity: :silence) + noop = DomainBlock.new(domain: 'domain', severity: :noop) + expect(suspend.stricter_than?(silence)).to be true + expect(suspend.stricter_than?(noop)).to be true + end + + it 'returns false if the new block has lower severity than the old one' do + suspend = DomainBlock.new(domain: 'domain', severity: :suspend) + silence = DomainBlock.new(domain: 'domain', severity: :silence) + noop = DomainBlock.new(domain: 'domain', severity: :noop) + expect(silence.stricter_than?(suspend)).to be false + expect(noop.stricter_than?(suspend)).to be false + expect(noop.stricter_than?(silence)).to be false + end + + it 'returns false if the new block does is less strict regarding reports' do + older = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: true) + newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: false) + expect(newer.stricter_than?(older)).to be false + end + + it 'returns false if the new block does is less strict regarding media' do + older = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: true) + newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: false) + expect(newer.stricter_than?(older)).to be false + end + end end diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb index d2e442f4a..84b0107dd 100644 --- a/spec/validators/blacklisted_email_validator_spec.rb +++ b/spec/validators/blacklisted_email_validator_spec.rb @@ -8,6 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do let(:errors) { double(add: nil) } before do + allow(user).to receive(:invited?) { false } allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email } described_class.new.validate(user) end diff --git a/yarn.lock b/yarn.lock index 11fe49fa6..377a3523d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1747,6 +1747,11 @@ bluebird@^3.5.1, bluebird@^3.5.3: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw== +blurhash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e" + integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" |