status_length_validator.rb 1.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. # frozen_string_literal: true
  2. class StatusLengthValidator < ActiveModel::Validator
  3. MAX_CHARS = 500
  4. URL_PLACEHOLDER_CHARS = 23
  5. URL_PLACEHOLDER = 'x' * 23
  6. def validate(status)
  7. return unless status.local? && !status.reblog?
  8. status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if too_long?(status)
  9. end
  10. private
  11. def too_long?(status)
  12. countable_length(combined_text(status)) > MAX_CHARS
  13. end
  14. def countable_length(str)
  15. str.mb_chars.grapheme_length
  16. end
  17. def combined_text(status)
  18. [status.spoiler_text, countable_text(status.text)].join
  19. end
  20. def countable_text(str)
  21. return '' if str.blank?
  22. # To ensure that we only give length concessions to entities that
  23. # will be correctly parsed during formatting, we go through full
  24. # entity extraction
  25. entities = Extractor.remove_overlapping_entities(Extractor.extract_urls_with_indices(str, extract_url_without_protocol: false) + Extractor.extract_mentions_or_lists_with_indices(str))
  26. rewrite_entities(str, entities) do |entity|
  27. if entity[:url]
  28. URL_PLACEHOLDER
  29. elsif entity[:screen_name]
  30. "@#{entity[:screen_name].split('@').first}"
  31. end
  32. end
  33. end
  34. def rewrite_entities(str, entities)
  35. entities.sort_by! { |entity| entity[:indices].first }
  36. result = ''.dup
  37. last_index = entities.reduce(0) do |index, entity|
  38. result << str[index...entity[:indices].first]
  39. result << yield(entity)
  40. entity[:indices].last
  41. end
  42. result << str[last_index..-1]
  43. result
  44. end
  45. end