about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/emails/confirmations_controller.rb17
-rw-r--r--app/controllers/concerns/signature_verification.rb1
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js49
-rw-r--r--app/javascript/flavours/glitch/util/counter.js2
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js2
-rw-r--r--app/javascript/flavours/glitch/util/url_regex.js222
-rw-r--r--app/javascript/mastodon/components/hashtag.js49
-rw-r--r--app/javascript/mastodon/features/compose/util/counter.js2
-rw-r--r--app/javascript/mastodon/features/compose/util/url_regex.js222
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js2
-rw-r--r--app/lib/extractor.rb8
-rw-r--r--app/lib/formatter.rb2
-rw-r--r--app/lib/validation_error_formatter.rb32
-rw-r--r--app/services/fetch_link_card_service.rb12
-rw-r--r--app/services/follow_service.rb7
-rw-r--r--app/services/resolve_account_service.rb5
-rw-r--r--app/validators/blacklisted_email_validator.rb4
-rw-r--r--app/validators/email_mx_validator.rb11
-rw-r--r--app/validators/note_length_validator.rb2
-rw-r--r--app/validators/status_length_validator.rb9
-rw-r--r--app/validators/unique_username_validator.rb2
-rw-r--r--app/validators/unreserved_username_validator.rb5
-rw-r--r--app/views/about/more.html.haml2
-rw-r--r--app/views/admin/account_actions/new.html.haml2
25 files changed, 230 insertions, 443 deletions
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 953874e1a..996f1b79b 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -27,6 +27,8 @@ class Api::V1::AccountsController < Api::BaseController
 
     self.response_body = Oj.dump(response.body)
     self.status        = response.status
+  rescue ActiveRecord::RecordInvalid => e
+    render json: ValidationErrorFormatter.new(e, :'account.username' => :username, :'invite_request.text' => :reason).as_json, status: :unprocessable_entity
   end
 
   def follow
diff --git a/app/controllers/api/v1/emails/confirmations_controller.rb b/app/controllers/api/v1/emails/confirmations_controller.rb
new file mode 100644
index 000000000..03ab5de8c
--- /dev/null
+++ b/app/controllers/api/v1/emails/confirmations_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::Emails::ConfirmationsController < Api::BaseController
+  before_action :doorkeeper_authorize!
+  before_action :require_user_owned_by_application!
+
+  def create
+    current_user.resend_confirmation_instructions if current_user.unconfirmed_email.present?
+    render_empty
+  end
+
+  private
+
+  def require_user_owned_by_application!
+    render json: { error: 'This method is only available to the application the user originally signed-up with' }, status: :forbidden unless current_user && current_user.created_by_application_id == doorkeeper_token.application_id
+  end
+end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index fc3978fbb..4dd0cac55 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -133,6 +133,7 @@ module SignatureVerification
 
   def verify_body_digest!
     return unless signed_headers.include?('digest')
+    raise SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
 
     digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
     sha256  = digests.assoc('sha-256')
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 639d87a1e..24c595ed7 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -2,10 +2,35 @@
 import React from 'react';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
 import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'flavours/glitch/components/short_number';
 
+class SilentErrorBoundary extends React.Component {
+
+  static propTypes = {
+    children: PropTypes.node,
+  };
+
+  state = {
+    error: false,
+  };
+
+  componentDidCatch () {
+    this.setState({ error: true });
+  }
+
+  render () {
+    if (this.state.error) {
+      return null;
+    }
+
+    return this.props.children;
+  }
+
+}
+
 /**
  * Used to render counter of how much people are talking about hashtag
  *
@@ -51,17 +76,19 @@ const Hashtag = ({ hashtag }) => (
     </div>
 
     <div className='trends__item__sparkline'>
-      <Sparklines
-        width={50}
-        height={28}
-        data={hashtag
-          .get('history')
-          .reverse()
-          .map((day) => day.get('uses'))
-          .toArray()}
-      >
-        <SparklinesCurve style={{ fill: 'none' }} />
-      </Sparklines>
+      <SilentErrorBoundary>
+        <Sparklines
+          width={50}
+          height={28}
+          data={hashtag
+            .get('history')
+            .reverse()
+            .map((day) => day.get('uses'))
+            .toArray()}
+        >
+          <SparklinesCurve style={{ fill: 'none' }} />
+        </Sparklines>
+      </SilentErrorBoundary>
     </div>
   </div>
 );
diff --git a/app/javascript/flavours/glitch/util/counter.js b/app/javascript/flavours/glitch/util/counter.js
index 700ba2163..7aa9e87b1 100644
--- a/app/javascript/flavours/glitch/util/counter.js
+++ b/app/javascript/flavours/glitch/util/counter.js
@@ -1,6 +1,6 @@
 import { urlRegex } from './url_regex';
 
-const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
 
 export function countableText(inputText) {
   return inputText
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 43ff4661c..57c1d719a 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -11,7 +11,7 @@ const emojiFilenames = (emojis) => {
 };
 
 // Emoji requiring extra borders depending on theme
-const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲']);
 const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
 
 const emojiFilename = (filename) => {
diff --git a/app/javascript/flavours/glitch/util/url_regex.js b/app/javascript/flavours/glitch/util/url_regex.js
index faed22169..9c2005c53 100644
--- a/app/javascript/flavours/glitch/util/url_regex.js
+++ b/app/javascript/flavours/glitch/util/url_regex.js
@@ -1,196 +1,30 @@
-const regexen = {};
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars';
+import validDomain from 'twitter-text/dist/regexp/validDomain';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars';
 
-const regexSupplant = function(regex, flags) {
-  flags = flags || '';
-  if (typeof regex !== 'string') {
-    if (regex.global && flags.indexOf('g') < 0) {
-      flags += 'g';
-    }
-    if (regex.ignoreCase && flags.indexOf('i') < 0) {
-      flags += 'i';
-    }
-    if (regex.multiline && flags.indexOf('m') < 0) {
-      flags += 'm';
-    }
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
 
-    regex = regex.source;
-  }
-  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
-    var newRegex = regexen[name] || '';
-    if (typeof newRegex !== 'string') {
-      newRegex = newRegex.source;
-    }
-    return newRegex;
-  }), flags);
-};
-
-const stringSupplant = function(str, values) {
-  return str.replace(/#\{(\w+)\}/g, function(match, name) {
-    return values[name] || '';
-  });
-};
-
-export const urlRegex = (function() {
-  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
-  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
-  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
-  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
-  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
-  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
-  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
-  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
-  regexen.validGTLD = regexSupplant(RegExp(
-    '(?:(?:' +
-    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
-    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
-    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
-    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
-    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
-    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
-    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
-    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
-    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
-    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
-    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
-    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
-    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
-    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
-    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
-    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
-    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
-    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
-    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
-    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
-    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
-    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
-    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
-    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
-    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
-    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
-    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
-    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
-    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
-    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
-    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
-    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
-    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
-    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
-    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
-    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
-    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
-    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
-    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
-    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
-    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
-    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
-    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
-    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
-    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
-    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
-    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
-    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
-    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
-    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
-    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
-    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
-    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
-    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
-    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
-    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
-    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
-    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
-    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
-    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
-    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
-    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
-    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
-    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
-    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
-    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
-    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
-    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
-    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
-    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
-    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
-    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
-    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
-    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
-    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
-    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
-    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
-    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
-    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
-    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
-    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
-    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
-    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
-    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
-    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
-    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
-    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
-    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
-  ')(?=[^0-9a-zA-Z@]|$))'));
-  regexen.validCCTLD = regexSupplant(RegExp(
-    '(?:(?:' +
-      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
-      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
-      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
-      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
-      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
-      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
-      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
-      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
-      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
-      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
-      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
-  ')(?=[^0-9a-zA-Z@]|$))'));
-  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
-  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
-  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
-  regexen.validPortNumber = /[0-9]+/;
-  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
-  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
-  // Allow URL paths to contain up to two nested levels of balanced parens
-  //  1. Used in Wikipedia URLs like /Primer_(film)
-  //  2. Used in IIS sessions like /S(dfd346)/
-  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
-  regexen.validUrlBalancedParens = regexSupplant(
-    '\\('                                   +
-      '(?:'                                 +
-        '#{validGeneralUrlPathChars}+'      +
-        '|'                                 +
-        // allow one nested level of balanced parentheses
-        '(?:'                               +
-          '#{validGeneralUrlPathChars}*'    +
-          '\\('                             +
-            '#{validGeneralUrlPathChars}+'  +
-          '\\)'                             +
-          '#{validGeneralUrlPathChars}*'    +
-        ')'                                 +
-      ')'                                   +
-    '\\)'
-    , 'i');
-  // Valid end-of-path chracters (so /foo. does not gobble the period).
-  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
-  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
-  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
-  regexen.validUrlPath = regexSupplant('(?:' +
-    '(?:' +
-      '#{validGeneralUrlPathChars}*' +
-        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
-        '#{validUrlPathEndingChars}'+
-      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
-    ')', 'i');
-  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
-  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
-  regexen.validUrl = regexSupplant(
-    '('                                                          + // $1 URL
-      '(https?:\\/\\/)'                                          + // $2 Protocol
-      '(#{validDomain})'                                         + // $3 Domain(s)
-      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
-      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
-      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
-    ')'
-    , 'gi');
-  return regexen.validUrl;
-}());
+export const urlRegex = regexSupplant(
+  '('                                                          + // $1 URL
+    '(#{validUrlPrecedingChars})'                              + // $2
+    '(https?:\\/\\/)'                                          + // $3 Protocol
+    '(#{validDomain})'                                         + // $4 Domain(s)
+    '(?::(#{validPortNumber}))?'                               + // $5 Port number (optional)
+    '(\\/#{validUrlPath}*)?'                                   + // $6 URL Path
+    '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $7 Query String
+  ')',
+  {
+    validUrlPrecedingChars,
+    validDomain,
+    validPortNumber,
+    validUrlPath,
+    validUrlQueryChars,
+    validUrlQueryEndingChars,
+  },
+  'gi',
+);
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index d766ca90d..75fcf20e3 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -2,10 +2,35 @@
 import React from 'react';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
 import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'mastodon/components/short_number';
 
+class SilentErrorBoundary extends React.Component {
+
+  static propTypes = {
+    children: PropTypes.node,
+  };
+
+  state = {
+    error: false,
+  };
+
+  componentDidCatch () {
+    this.setState({ error: true });
+  }
+
+  render () {
+    if (this.state.error) {
+      return null;
+    }
+
+    return this.props.children;
+  }
+
+}
+
 /**
  * Used to render counter of how much people are talking about hashtag
  *
@@ -51,17 +76,19 @@ const Hashtag = ({ hashtag }) => (
     </div>
 
     <div className='trends__item__sparkline'>
-      <Sparklines
-        width={50}
-        height={28}
-        data={hashtag
-          .get('history')
-          .reverse()
-          .map((day) => day.get('uses'))
-          .toArray()}
-      >
-        <SparklinesCurve style={{ fill: 'none' }} />
-      </Sparklines>
+      <SilentErrorBoundary>
+        <Sparklines
+          width={50}
+          height={28}
+          data={hashtag
+            .get('history')
+            .reverse()
+            .map((day) => day.get('uses'))
+            .toArray()}
+        >
+          <SparklinesCurve style={{ fill: 'none' }} />
+        </Sparklines>
+      </SilentErrorBoundary>
     </div>
   </div>
 );
diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js
index 700ba2163..7aa9e87b1 100644
--- a/app/javascript/mastodon/features/compose/util/counter.js
+++ b/app/javascript/mastodon/features/compose/util/counter.js
@@ -1,6 +1,6 @@
 import { urlRegex } from './url_regex';
 
-const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
 
 export function countableText(inputText) {
   return inputText
diff --git a/app/javascript/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js
index 7f1e17620..9c2005c53 100644
--- a/app/javascript/mastodon/features/compose/util/url_regex.js
+++ b/app/javascript/mastodon/features/compose/util/url_regex.js
@@ -1,196 +1,30 @@
-const regexen = {};
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars';
+import validDomain from 'twitter-text/dist/regexp/validDomain';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars';
 
-const regexSupplant = function(regex, flags) {
-  flags = flags || '';
-  if (typeof regex !== 'string') {
-    if (regex.global && flags.indexOf('g') < 0) {
-      flags += 'g';
-    }
-    if (regex.ignoreCase && flags.indexOf('i') < 0) {
-      flags += 'i';
-    }
-    if (regex.multiline && flags.indexOf('m') < 0) {
-      flags += 'm';
-    }
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
 
-    regex = regex.source;
-  }
-  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
-    var newRegex = regexen[name] || '';
-    if (typeof newRegex !== 'string') {
-      newRegex = newRegex.source;
-    }
-    return newRegex;
-  }), flags);
-};
-
-const stringSupplant = function(str, values) {
-  return str.replace(/#\{(\w+)\}/g, function(match, name) {
-    return values[name] || '';
-  });
-};
-
-export const urlRegex = (function() {
-  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
-  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
-  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
-  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
-  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
-  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
-  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
-  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
-  regexen.validGTLD = regexSupplant(RegExp(
-    '(?:(?:' +
-      '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
-      '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
-      'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
-      'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
-      'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
-      'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
-      'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
-      'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
-      'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
-      'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
-      'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
-      'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
-      'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
-      'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
-      'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
-      'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
-      'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
-      'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
-      'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
-      'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
-      'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
-      'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
-      'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
-      'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
-      'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
-      'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
-      'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
-      'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
-      'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
-      'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
-      'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
-      'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
-      'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
-      'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
-      'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
-      'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
-      'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
-      'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
-      'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
-      'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
-      'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
-      'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
-      'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
-      'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
-      'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
-      'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
-      'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
-      'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
-      'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
-      'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
-      'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
-      'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
-      'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
-      'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
-      'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
-      'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
-      'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
-      'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
-      'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
-      'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
-      'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
-      'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
-      'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
-      'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
-      'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
-      'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
-      'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
-      'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
-      'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
-      'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
-      'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
-      'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
-      'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
-      'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
-      'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
-      'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
-      'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
-      'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
-      'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
-      'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
-      'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
-      'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
-      'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
-      'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
-      'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
-      'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
-      'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
-      'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
-    ')(?=[^0-9a-zA-Z@]|$))'));
-  regexen.validCCTLD = regexSupplant(RegExp(
-    '(?:(?:' +
-      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
-      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
-      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
-      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
-      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
-      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
-      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
-      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
-      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
-      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
-      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
-    ')(?=[^0-9a-zA-Z@]|$))'));
-  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
-  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
-  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
-  regexen.validPortNumber = /[0-9]+/;
-  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
-  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
-  // Allow URL paths to contain up to two nested levels of balanced parens
-  //  1. Used in Wikipedia URLs like /Primer_(film)
-  //  2. Used in IIS sessions like /S(dfd346)/
-  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
-  regexen.validUrlBalancedParens = regexSupplant(
-    '\\('                                   +
-      '(?:'                                 +
-        '#{validGeneralUrlPathChars}+'      +
-        '|'                                 +
-        // allow one nested level of balanced parentheses
-        '(?:'                               +
-          '#{validGeneralUrlPathChars}*'    +
-          '\\('                             +
-            '#{validGeneralUrlPathChars}+'  +
-          '\\)'                             +
-          '#{validGeneralUrlPathChars}*'    +
-        ')'                                 +
-      ')'                                   +
-    '\\)',
-    'i');
-  // Valid end-of-path characters (so /foo. does not gobble the period).
-  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
-  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
-  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
-  regexen.validUrlPath = regexSupplant('(?:' +
-    '(?:' +
-      '#{validGeneralUrlPathChars}*' +
-        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
-        '#{validUrlPathEndingChars}'+
-      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
-    ')', 'i');
-  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
-  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
-  regexen.validUrl = regexSupplant(
-    '('                                                          + // $1 URL
-      '(https?:\\/\\/)'                                          + // $2 Protocol
-      '(#{validDomain})'                                         + // $3 Domain(s)
-      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
-      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
-      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
-    ')',
-    'gi');
-  return regexen.validUrl;
-}());
+export const urlRegex = regexSupplant(
+  '('                                                          + // $1 URL
+    '(#{validUrlPrecedingChars})'                              + // $2
+    '(https?:\\/\\/)'                                          + // $3 Protocol
+    '(#{validDomain})'                                         + // $4 Domain(s)
+    '(?::(#{validPortNumber}))?'                               + // $5 Port number (optional)
+    '(\\/#{validUrlPath}*)?'                                   + // $6 URL Path
+    '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $7 Query String
+  ')',
+  {
+    validUrlPrecedingChars,
+    validDomain,
+    validPortNumber,
+    validUrlPath,
+    validUrlQueryChars,
+    validUrlQueryEndingChars,
+  },
+  'gi',
+);
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 4e37f3a80..3de79ac9b 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -11,7 +11,7 @@ const emojiFilenames = (emojis) => {
 };
 
 // Emoji requiring extra borders depending on theme
-const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲']);
 const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
 
 const emojiFilename = (filename) => {
diff --git a/app/lib/extractor.rb b/app/lib/extractor.rb
index 6076458ad..8020aa916 100644
--- a/app/lib/extractor.rb
+++ b/app/lib/extractor.rb
@@ -1,20 +1,20 @@
 # frozen_string_literal: true
 
 module Extractor
-  extend Twitter::Extractor
+  extend Twitter::TwitterText::Extractor
 
   module_function
 
   # :yields: username, list_slug, start, end
   def extract_mentions_or_lists_with_indices(text)
-    return [] unless Twitter::Regex[:at_signs].match?(text)
+    return [] unless Twitter::TwitterText::Regex[:at_signs].match?(text)
 
     possible_entries = []
 
     text.to_s.scan(Account::MENTION_RE) do |screen_name, _|
       match_data = $LAST_MATCH_INFO
       after = $'
-      unless Twitter::Regex[:end_mention_match].match?(after)
+      unless Twitter::TwitterText::Regex[:end_mention_match].match?(after)
         start_position = match_data.char_begin(1) - 1
         end_position = match_data.char_end(1)
         possible_entries << {
@@ -44,7 +44,7 @@ module Extractor
       if %r{\A://}.match?(after)
         hash_text.match(/(.+)(https?\Z)/) do |matched|
           hash_text = matched[1]
-          end_position -= matched[2].char_length
+          end_position -= matched[2].codepoint_length
         end
       end
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 1aeedac8a..9a3e63d46 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -348,7 +348,7 @@ class Formatter
 
     html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
 
-    Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
+    Twitter::TwitterText::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
   rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     encode(entity[:url])
   end
diff --git a/app/lib/validation_error_formatter.rb b/app/lib/validation_error_formatter.rb
new file mode 100644
index 000000000..3f964f739
--- /dev/null
+++ b/app/lib/validation_error_formatter.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class ValidationErrorFormatter
+  def initialize(error, aliases = {})
+    @error   = error
+    @aliases = aliases
+  end
+
+  def as_json
+    { error: @error.to_s, details: details }
+  end
+
+  private
+
+  def details
+    h = {}
+
+    errors.details.each_pair do |attribute_name, attribute_errors|
+      messages = errors.messages[attribute_name]
+
+      h[@aliases[attribute_name] || attribute_name] = attribute_errors.map.with_index do |error, index|
+        { error: 'ERR_' + error[:error].to_s.upcase, description: messages[index] }
+      end
+    end
+
+    h
+  end
+
+  def errors
+    @errors ||= @error.record.errors
+  end
+end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 74fe9a0a5..d4e4931e6 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -2,12 +2,12 @@
 
 class FetchLinkCardService < BaseService
   URL_PATTERN = %r{
-    (                                                                                                 #   $1 URL
-      (https?:\/\/)                                                                                   #   $2 Protocol (required)
-      (#{Twitter::Regex[:valid_domain]})                                                              #   $3 Domain(s)
-      (?::(#{Twitter::Regex[:valid_port_number]}))?                                                   #   $4 Port number (optional)
-      (/#{Twitter::Regex[:valid_url_path]}*)?                                                         #   $5 URL Path and anchor
-      (\?#{Twitter::Regex[:valid_url_query_chars]}*#{Twitter::Regex[:valid_url_query_ending_chars]})? #   $6 Query String
+    (                                                                                                                           #   $1 URL
+      (https?:\/\/)                                                                                                             #   $2 Protocol (required)
+      (#{Twitter::TwitterText::Regex[:valid_domain]})                                                                           #   $3 Domain(s)
+      (?::(#{Twitter::TwitterText::Regex[:valid_port_number]}))?                                                                #   $4 Port number (optional)
+      (/#{Twitter::TwitterText::Regex[:valid_url_path]}*)?                                                                      #   $5 URL Path and anchor
+      (\?#{Twitter::TwitterText::Regex[:valid_url_query_chars]}*#{Twitter::TwitterText::Regex[:valid_url_query_ending_chars]})? #   $6 Query String
     )
   }iox
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index b98f7011d..d3db07a74 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -3,10 +3,11 @@
 class FollowService < BaseService
   include Redisable
   include Payloadable
+  include DomainControlHelper
 
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
-  # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
+  # @param [Account] target_account Account to follow
   # @param [Hash] options
   # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
   # @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
@@ -15,7 +16,7 @@ class FollowService < BaseService
   # @option [Boolean] :with_rate_limit
   def call(source_account, target_account, options = {})
     @source_account = source_account
-    @target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
+    @target_account = target_account
     @options        = { bypass_locked: false, bypass_limit: false, with_rate_limit: false }.merge(options)
 
     raise ActiveRecord::RecordNotFound if following_not_possible?
@@ -43,7 +44,7 @@ class FollowService < BaseService
   end
 
   def following_not_allowed?
-    @target_account.blocking?(@source_account) || @source_account.blocking?(@target_account) || @target_account.moved? || (!@target_account.local? && @target_account.ostatus?) || @source_account.domain_blocking?(@target_account.domain)
+    domain_not_allowed?(@target_account.domain) || @target_account.blocking?(@source_account) || @source_account.blocking?(@target_account) || @target_account.moved? || (!@target_account.local? && @target_account.ostatus?) || @source_account.domain_blocking?(@target_account.domain)
   end
 
   def change_follow_options!
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 3301aaf51..b8ddeb2ad 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -10,7 +10,7 @@ class ResolveAccountService < BaseService
   # @param [String, Account] uri URI in the username@domain format or account record
   # @param [Hash] options
   # @option options [Boolean] :redirected Do not follow further Webfinger redirects
-  # @option options [Boolean] :skip_webfinger Do not attempt to refresh account data
+  # @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data
   # @return [Account]
   def call(uri, options = {})
     return if uri.blank?
@@ -120,8 +120,9 @@ class ResolveAccountService < BaseService
 
   def webfinger_update_due?
     return false if @options[:check_delivery_availability] && !DeliveryFailureTracker.available?(@domain)
+    return false if @options[:skip_webfinger]
 
-    @account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
+    @account.nil? || (@account.ostatus? && @account.possibly_stale?)
   end
 
   def activitypub_ready?
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index 20a1587cc..1ca73fdcc 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -2,11 +2,11 @@
 
 class BlacklistedEmailValidator < ActiveModel::Validator
   def validate(user)
-    return if user.valid_invitation?
+    return if user.valid_invitation? || user.email.blank?
 
     @email = user.email
 
-    user.errors.add(:email, I18n.t('users.blocked_email_provider')) if blocked_email?
+    user.errors.add(:email, :blocked) if blocked_email?
   end
 
   private
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index ef1554494..9f70a1469 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -4,16 +4,19 @@ require 'resolv'
 
 class EmailMxValidator < ActiveModel::Validator
   def validate(user)
+    return if user.email.blank?
+
     domain = get_domain(user.email)
 
-    if domain.nil?
-      user.errors.add(:email, I18n.t('users.invalid_email'))
+    if domain.blank?
+      user.errors.add(:email, :invalid)
     else
       ips, hostnames = resolve_mx(domain)
+
       if ips.empty?
-        user.errors.add(:email, I18n.t('users.invalid_email_mx'))
+        user.errors.add(:email, :unreachable)
       elsif on_blacklist?(hostnames + ips)
-        user.errors.add(:email, I18n.t('users.blocked_email_provider'))
+        user.errors.add(:email, :blocked)
       end
     end
   end
diff --git a/app/validators/note_length_validator.rb b/app/validators/note_length_validator.rb
index 5ff6df6df..7ea2bb3e5 100644
--- a/app/validators/note_length_validator.rb
+++ b/app/validators/note_length_validator.rb
@@ -2,7 +2,7 @@
 
 class NoteLengthValidator < ActiveModel::EachValidator
   def validate_each(record, attribute, value)
-    record.errors.add(attribute, I18n.t('statuses.over_character_limit', max: options[:maximum])) if too_long?(value)
+    record.errors.add(attribute, :too_long, message: I18n.t('statuses.over_character_limit', max: options[:maximum]), count: options[:maximum]) if too_long?(value)
   end
 
   private
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index 92ee5e643..dcbc4a41a 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -2,6 +2,13 @@
 
 class StatusLengthValidator < ActiveModel::Validator
   MAX_CHARS = (ENV['MAX_TOOT_CHARS'] || 500).to_i
+  URL_PATTERN = %r{
+    (?:
+      (#{Twitter::TwitterText::Regex[:valid_url_preceding_chars]})
+      (#{FetchLinkCardService::URL_PATTERN})
+    )
+  }iox
+  URL_PLACEHOLDER = "\1#{'x' * 23}"
 
   def validate(status)
     return unless status.local? && !status.reblog?
@@ -28,7 +35,7 @@ class StatusLengthValidator < ActiveModel::Validator
     return '' if @status.text.nil?
 
     @status.text.dup.tap do |new_text|
-      new_text.gsub!(FetchLinkCardService::URL_PATTERN, 'x' * 23)
+      new_text.gsub!(URL_PATTERN, URL_PLACEHOLDER)
       new_text.gsub!(Account::MENTION_RE, '@\2')
     end
   end
diff --git a/app/validators/unique_username_validator.rb b/app/validators/unique_username_validator.rb
index f87eb06ba..09c8fadb5 100644
--- a/app/validators/unique_username_validator.rb
+++ b/app/validators/unique_username_validator.rb
@@ -4,7 +4,7 @@
 
 class UniqueUsernameValidator < ActiveModel::Validator
   def validate(account)
-    return if account.username.nil?
+    return if account.username.blank?
 
     normalized_username = account.username.downcase
     normalized_domain = account.domain&.downcase
diff --git a/app/validators/unreserved_username_validator.rb b/app/validators/unreserved_username_validator.rb
index 634ceb06e..974f3ba62 100644
--- a/app/validators/unreserved_username_validator.rb
+++ b/app/validators/unreserved_username_validator.rb
@@ -3,9 +3,10 @@
 class UnreservedUsernameValidator < ActiveModel::Validator
   def validate(account)
     @username = account.username
-    return if @username.nil?
 
-    account.errors.add(:username, I18n.t('accounts.reserved_username')) if reserved_username?
+    return if @username.blank?
+
+    account.errors.add(:username, :reserved) if reserved_username?
   end
 
   private
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 45675224b..1cf194522 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -42,7 +42,7 @@
   .column-3
     = render 'application/flashes'
 
-    - if @contents.blank? && (!display_blocks? || @blocks&.empty?)
+    - if @contents.blank? && @rules.empty? && (!display_blocks? || @blocks&.empty?)
       = nothing_here
     - else
       .box-widget
diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml
index aa88b1448..ca4f9663f 100644
--- a/app/views/admin/account_actions/new.html.haml
+++ b/app/views/admin/account_actions/new.html.haml
@@ -5,7 +5,7 @@
   = f.input :report_id, as: :hidden
 
   .fields-group
-    = f.input :type, collection: Admin::AccountAction.types_for_account(@account), include_blank: false, wrapper: :with_block_label, label_method: ->(type) { I18n.t("simple_form.labels.admin_account_action.types.#{type}")}, hint: t('simple_form.hints.admin_account_action.type_html', acct: @account.acct)
+    = f.input :type, as: :radio_buttons, collection: Admin::AccountAction.types_for_account(@account), include_blank: false, wrapper: :with_block_label, label_method: ->(type) { safe_join([I18n.t("simple_form.labels.admin_account_action.types.#{type}"), content_tag(:span, I18n.t("simple_form.hints.admin_account_action.types.#{type}"), class: 'hint')])}, hint: t('simple_form.hints.admin_account_action.type_html', acct: @account.acct)
 
   - if @account.local?
     %hr.spacer/