Browse Source

Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/models/media_attachment.rb
pull/3/head
Thibaut Girka 4 months ago
parent
commit
33c80e0783
67 changed files with 898 additions and 290 deletions
  1. 39
    0
      CHANGELOG.md
  2. 5
    4
      Gemfile
  3. 20
    19
      Gemfile.lock
  4. 17
    5
      app/controllers/admin/domain_blocks_controller.rb
  5. 6
    0
      app/controllers/api/base_controller.rb
  6. 2
    0
      app/controllers/api/v1/custom_emojis_controller.rb
  7. 1
    0
      app/controllers/api/v1/instances/activity_controller.rb
  8. 1
    0
      app/controllers/api/v1/instances/peers_controller.rb
  9. 1
    0
      app/controllers/api/v1/instances_controller.rb
  10. 2
    1
      app/controllers/auth/registrations_controller.rb
  11. 1
    1
      app/controllers/settings/notifications_controller.rb
  12. 3
    1
      app/javascript/mastodon/actions/compose.js
  13. 1
    1
      app/javascript/mastodon/actions/timelines.js
  14. 69
    27
      app/javascript/mastodon/components/media_gallery.js
  15. 2
    1
      app/javascript/mastodon/components/status.js
  16. 10
    4
      app/javascript/mastodon/components/status_list.js
  17. 117
    37
      app/javascript/mastodon/features/account_gallery/components/media_item.js
  18. 45
    28
      app/javascript/mastodon/features/account_gallery/index.js
  19. 0
    2
      app/javascript/mastodon/features/compose/components/compose_form.js
  20. 3
    0
      app/javascript/mastodon/features/compose/components/upload_form.js
  21. 9
    31
      app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
  22. 10
    4
      app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
  23. 10
    4
      app/javascript/mastodon/features/notifications/index.js
  24. 1
    0
      app/javascript/mastodon/features/report/components/status_check_box.js
  25. 2
    4
      app/javascript/mastodon/features/status/components/detailed_status.js
  26. 14
    8
      app/javascript/mastodon/features/status/index.js
  27. 27
    5
      app/javascript/mastodon/features/ui/components/media_modal.js
  28. 43
    2
      app/javascript/mastodon/features/ui/components/video_modal.js
  29. 7
    2
      app/javascript/mastodon/features/ui/index.js
  30. 49
    12
      app/javascript/mastodon/features/video/index.js
  31. 2
    2
      app/javascript/mastodon/locales/hy.json
  32. 120
    61
      app/javascript/styles/mastodon/components.scss
  33. 11
    0
      app/javascript/styles/mastodon/forms.scss
  34. 6
    1
      app/lib/activitypub/activity/create.rb
  35. 1
    0
      app/lib/activitypub/adapter.rb
  36. 1
    0
      app/models/concerns/ldap_authenticable.rb
  37. 1
    0
      app/models/concerns/omniauthable.rb
  38. 1
    0
      app/models/concerns/pam_authenticable.rb
  39. 7
    0
      app/models/domain_block.rb
  40. 12
    3
      app/models/media_attachment.rb
  41. 8
    3
      app/models/user.rb
  42. 2
    2
      app/serializers/activitypub/note_serializer.rb
  43. 1
    1
      app/serializers/rest/media_attachment_serializer.rb
  44. 1
    0
      app/services/block_service.rb
  45. 4
    1
      app/validators/blacklisted_email_validator.rb
  46. 1
    1
      app/views/stream_entries/_detailed_status.html.haml
  47. 1
    1
      app/views/stream_entries/_simple_status.html.haml
  48. 2
    0
      app/workers/activitypub/processing_worker.rb
  49. 3
    1
      config/initializers/rack_attack_logging.rb
  50. 1
    0
      config/locales/co.yml
  51. 1
    0
      config/locales/en.yml
  52. 3
    3
      config/locales/fr.yml
  53. 4
    3
      config/locales/sk.yml
  54. 5
    0
      db/migrate/20190420025523_add_blurhash_to_media_attachments.rb
  55. 2
    1
      db/schema.rb
  56. 4
    0
      lib/cli.rb
  57. 6
    1
      lib/mastodon/accounts_cli.rb
  58. 19
    0
      lib/mastodon/cache_cli.rb
  59. 1
    1
      lib/mastodon/version.rb
  60. 16
    0
      lib/paperclip/blurhash_transcoder.rb
  61. 1
    0
      package.json
  62. 1
    0
      public/robots.txt
  63. 12
    1
      spec/controllers/admin/domain_blocks_controller_spec.rb
  64. 83
    0
      spec/controllers/auth/registrations_controller_spec.rb
  65. 31
    0
      spec/models/domain_block_spec.rb
  66. 1
    0
      spec/validators/blacklisted_email_validator_spec.rb
  67. 5
    0
      yarn.lock

+ 39
- 0
CHANGELOG.md View File

@@ -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


+ 5
- 4
Gemfile View File

@@ -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'

+ 20
- 19
Gemfile.lock View File

@@ -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.2)
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)

+ 17
- 5
app/controllers/admin/domain_blocks_controller.rb View File

@@ -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


+ 6
- 0
app/controllers/api/base_controller.rb View File

@@ -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

+ 2
- 0
app/controllers/api/v1/custom_emojis_controller.rb View File

@@ -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)

+ 1
- 0
app/controllers/api/v1/instances/activity_controller.rb View File

@@ -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


+ 1
- 0
app/controllers/api/v1/instances/peers_controller.rb View File

@@ -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


+ 1
- 0
app/controllers/api/v1/instances_controller.rb View File

@@ -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

+ 2
- 1
app/controllers/auth/registrations_controller.rb View File

@@ -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

+ 1
- 1
app/controllers/settings/notifications_controller.rb View File

@@ -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

+ 3
- 1
app/javascript/mastodon/actions/compose.js View File

@@ -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 }){

+ 1
- 1
app/javascript/mastodon/actions/timelines.js View File

@@ -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}`, {

+ 69
- 27
app/javascript/mastodon/components/media_gallery.js View File

@@ -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}

+ 2
- 1
app/javascript/mastodon/components/status.js View File

@@ -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}

+ 10
- 4
app/javascript/mastodon/components/status_list.js View File

@@ -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();
}
}

+ 117
- 37
app/javascript/mastodon/features/account_gallery/components/media_item.js View File

@@ -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' style={{ cursor: 'pointer' }} 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>
);
}

+ 45
- 28
app/javascript/mastodon/features/account_gallery/index.js View File

@@ -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>

+ 0
- 2
app/javascript/mastodon/features/compose/components/compose_form.js View File

@@ -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>

+ 3
- 0
app/javascript/mastodon/features/compose/components/upload_form.js View File

@@ -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>
);
}

+ 9
- 31
app/javascript/mastodon/features/compose/containers/sensitive_button_container.js View File

@@ -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>
);
}


+ 10
- 4
app/javascript/mastodon/features/direct_timeline/components/conversations_list.js View File

@@ -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();
}
}

+ 10
- 4
app/javascript/mastodon/features/notifications/index.js View File

@@ -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();
}
}

+ 1
- 0
app/javascript/mastodon/features/report/components/status_check_box.js View File

@@ -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}

+ 2
- 4
app/javascript/mastodon/features/status/components/detailed_status.js View File

@@ -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}

+ 14
- 8
app/javascript/mastodon/features/status/index.js View File

@@ -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();
}
}

+ 27
- 5
app/javascript/mastodon/features/ui/components/media_modal.js View File

@@ -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>

+ 43
- 2
app/javascript/mastodon/features/ui/components/video_modal.js View File

@@ -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')}
/>

+ 7
- 2
app/javascript/mastodon/features/ui/index.js View File

@@ -367,11 +367,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();
}
}

+ 49
- 12
app/javascript/mastodon/features/video/index.js View File

@@ -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>

+ 2
- 2
app/javascript/mastodon/locales/hy.json View File

@@ -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}֊ին",

+ 120
- 61
app/javascript/styles/mastodon/components.scss View File

@@ -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;
@@ -2412,7 +2422,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 +2433,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 +2941,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);
}
}
}
}

@@ -3728,6 +3771,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 +4268,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 +4382,8 @@ a.status-card.compact:hover {
text-decoration: none;
color: $secondary-text-color;
line-height: 0;
position: relative;
z-index: 1;

&,
img {
@@ -4325,6 +4396,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 +4706,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 +4815,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,

+ 11
- 0
app/javascript/styles/mastodon/forms.scss View File

@@ -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;
}

+ 6
- 1
app/lib/activitypub/activity/create.rb View File

@@ -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?

+ 1
- 0
app/lib/activitypub/adapter.rb View File

@@ -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

+ 1
- 0
app/models/concerns/ldap_authenticable.rb View File

@@ -6,6 +6,7 @@ module LdapAuthenticable
def ldap_setup(_attributes)
self.confirmed_at = Time.now.utc
self.admin = false
self.external = true

save!
end

+ 1
- 0
app/models/concerns/omniauthable.rb View File

@@ -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,

+ 1
- 0
app/models/concerns/pam_authenticable.rb View File

@@ -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

+ 7
- 0
app/models/domain_block.rb View File

@@ -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

+ 12
- 3
app/models/media_attachment.rb View File

@@ -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