', i)) {
- // avoid emojifying on invisible text
- invisible = 1;
- tagChars = tagCharsWithoutEmojis;
- }
- }
- }
- i = rend;
- } else { // matched to unicode emoji
- const { filename, shortCode } = unicodeMapping[match];
- const title = shortCode ? `:${shortCode}:` : '';
- replacement = ` `;
- rend = i + match.length;
- }
- rtn += str.slice(0, i) + replacement;
- str = str.slice(rend);
- }
- return rtn + str;
-};
-
-export default emojify;
-
-export const buildCustomEmojis = (customEmojis) => {
- const emojis = [];
-
- customEmojis.forEach(emoji => {
- const shortcode = emoji.get('shortcode');
- const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
- const name = shortcode.replace(':', '');
-
- emojis.push({
- id: name,
- name,
- short_names: [name],
- text: '',
- emoticons: [],
- keywords: [name],
- imageUrl: url,
- custom: true,
- });
- });
-
- return emojis;
-};
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
deleted file mode 100644
index e5b834a74..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_compressed.js
+++ /dev/null
@@ -1,93 +0,0 @@
-// @preval
-// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
-// This file contains the compressed version of the emoji data from
-// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
-// It's designed to be emitted in an array format to take up less space
-// over the wire.
-
-const { unicodeToFilename } = require('./unicode_to_filename');
-const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
-const emojiMap = require('./emoji_map.json');
-const { emojiIndex } = require('emoji-mart');
-const { default: emojiMartData } = require('emoji-mart/dist/data');
-
-const excluded = ['®', '©', '™'];
-const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
-const shortcodeMap = {};
-
-const shortCodesToEmojiData = {};
-const emojisWithoutShortCodes = [];
-
-Object.keys(emojiIndex.emojis).forEach(key => {
- shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
-});
-
-const stripModifiers = unicode => {
- skins.forEach(tone => {
- unicode = unicode.replace(tone, '');
- });
-
- return unicode;
-};
-
-Object.keys(emojiMap).forEach(key => {
- if (excluded.includes(key)) {
- delete emojiMap[key];
- return;
- }
-
- const normalizedKey = stripModifiers(key);
- let shortcode = shortcodeMap[normalizedKey];
-
- if (!shortcode) {
- shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
- }
-
- const filename = emojiMap[key];
-
- const filenameData = [key];
-
- if (unicodeToFilename(key) !== filename) {
- // filename can't be derived using unicodeToFilename
- filenameData.push(filename);
- }
-
- if (typeof shortcode === 'undefined') {
- emojisWithoutShortCodes.push(filenameData);
- } else {
- if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
- shortCodesToEmojiData[shortcode] = [[]];
- }
- shortCodesToEmojiData[shortcode][0].push(filenameData);
- }
-});
-
-Object.keys(emojiIndex.emojis).forEach(key => {
- const { native } = emojiIndex.emojis[key];
- let { short_names, search, unified } = emojiMartData.emojis[key];
- if (short_names[0] !== key) {
- throw new Error('The compresser expects the first short_code to be the ' +
- 'key. It may need to be rewritten if the emoji change such that this ' +
- 'is no longer the case.');
- }
-
- short_names = short_names.slice(1); // first short name can be inferred from the key
-
- const searchData = [native, short_names, search];
- if (unicodeToUnifiedName(native) !== unified) {
- // unified name can't be derived from unicodeToUnifiedName
- searchData.push(unified);
- }
-
- shortCodesToEmojiData[key].push(searchData);
-});
-
-// JSON.parse/stringify is to emulate what @preval is doing and avoid any
-// inconsistent behavior in dev mode
-module.exports = JSON.parse(JSON.stringify([
- shortCodesToEmojiData,
- emojiMartData.skins,
- emojiMartData.categories,
- emojiMartData.short_names,
- emojisWithoutShortCodes,
-]));
diff --git a/app/javascript/mastodon/features/emoji/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json
deleted file mode 100644
index 13753ba84..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_map.json
+++ /dev/null
@@ -1 +0,0 @@
-{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","👱♀":"1f471-200d-2640-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","👁🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🏳️🌈":"1f3f3-fe0f-200d-1f308","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","👁️🗨️":"1f441-200d-1f5e8","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"}
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
deleted file mode 100644
index 45086fc4c..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// The output of this module is designed to mimic emoji-mart's
-// "data" object, such that we can use it for a light version of emoji-mart's
-// emojiIndex.search functionality.
-const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
-const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
-
-const emojis = {};
-
-// decompress
-Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
- let [
- filenameData, // eslint-disable-line no-unused-vars
- searchData,
- ] = shortCodesToEmojiData[shortCode];
- let [
- native,
- short_names,
- search,
- unified,
- ] = searchData;
-
- if (!unified) {
- // unified name can be derived from unicodeToUnifiedName
- unified = unicodeToUnifiedName(native);
- }
-
- short_names = [shortCode].concat(short_names);
- emojis[shortCode] = {
- native,
- search,
- short_names,
- unified,
- };
-});
-
-module.exports = {
- emojis,
- skins,
- categories,
- short_names,
-};
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
deleted file mode 100644
index 5755bf1c4..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
+++ /dev/null
@@ -1,157 +0,0 @@
-// This code is largely borrowed from:
-// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
-
-import data from './emoji_mart_data_light';
-import { getData, getSanitizedData, intersect } from './emoji_utils';
-
-let originalPool = {};
-let index = {};
-let emojisList = {};
-let emoticonsList = {};
-
-for (let emoji in data.emojis) {
- let emojiData = data.emojis[emoji];
- let { short_names, emoticons } = emojiData;
- let id = short_names[0];
-
- if (emoticons) {
- emoticons.forEach(emoticon => {
- if (emoticonsList[emoticon]) {
- return;
- }
-
- emoticonsList[emoticon] = id;
- });
- }
-
- emojisList[id] = getSanitizedData(id);
- originalPool[id] = emojiData;
-}
-
-function addCustomToPool(custom, pool) {
- custom.forEach((emoji) => {
- let emojiId = emoji.id || emoji.short_names[0];
-
- if (emojiId && !pool[emojiId]) {
- pool[emojiId] = getData(emoji);
- emojisList[emojiId] = getSanitizedData(emoji);
- }
- });
-}
-
-function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
- addCustomToPool(custom, originalPool);
-
- maxResults = maxResults || 75;
- include = include || [];
- exclude = exclude || [];
-
- let results = null,
- pool = originalPool;
-
- if (value.length) {
- if (value === '-' || value === '-1') {
- return [emojisList['-1']];
- }
-
- let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
- allResults = [];
-
- if (values.length > 2) {
- values = [values[0], values[1]];
- }
-
- if (include.length || exclude.length) {
- pool = {};
-
- data.categories.forEach(category => {
- let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
- let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
- if (!isIncluded || isExcluded) {
- return;
- }
-
- category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
- });
-
- if (custom.length) {
- let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
- let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
- if (customIsIncluded && !customIsExcluded) {
- addCustomToPool(custom, pool);
- }
- }
- }
-
- allResults = values.map((value) => {
- let aPool = pool,
- aIndex = index,
- length = 0;
-
- for (let charIndex = 0; charIndex < value.length; charIndex++) {
- const char = value[charIndex];
- length++;
-
- aIndex[char] = aIndex[char] || {};
- aIndex = aIndex[char];
-
- if (!aIndex.results) {
- let scores = {};
-
- aIndex.results = [];
- aIndex.pool = {};
-
- for (let id in aPool) {
- let emoji = aPool[id],
- { search } = emoji,
- sub = value.substr(0, length),
- subIndex = search.indexOf(sub);
-
- if (subIndex !== -1) {
- let score = subIndex + 1;
- if (sub === id) score = 0;
-
- aIndex.results.push(emojisList[id]);
- aIndex.pool[id] = emoji;
-
- scores[id] = score;
- }
- }
-
- aIndex.results.sort((a, b) => {
- let aScore = scores[a.id],
- bScore = scores[b.id];
-
- return aScore - bScore;
- });
- }
-
- aPool = aIndex.pool;
- }
-
- return aIndex.results;
- }).filter(a => a);
-
- if (allResults.length > 1) {
- results = intersect.apply(null, allResults);
- } else if (allResults.length) {
- results = allResults[0];
- } else {
- results = [];
- }
- }
-
- if (results) {
- if (emojisToShowFilter) {
- results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
- }
-
- if (results && results.length > maxResults) {
- results = results.slice(0, maxResults);
- }
- }
-
- return results;
-}
-
-export { search };
diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.js b/app/javascript/mastodon/features/emoji/emoji_picker.js
deleted file mode 100644
index 7e145381e..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_picker.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Picker from 'emoji-mart/dist-es/components/picker';
-import Emoji from 'emoji-mart/dist-es/components/emoji';
-
-export {
- Picker,
- Emoji,
-};
diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
deleted file mode 100644
index 918684c31..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// A mapping of unicode strings to an object containing the filename
-// (i.e. the svg filename) and a shortCode intended to be shown
-// as a "title" attribute in an HTML element (aka tooltip).
-
-const [
- shortCodesToEmojiData,
- skins, // eslint-disable-line no-unused-vars
- categories, // eslint-disable-line no-unused-vars
- short_names, // eslint-disable-line no-unused-vars
- emojisWithoutShortCodes,
-] = require('./emoji_compressed');
-const { unicodeToFilename } = require('./unicode_to_filename');
-
-// decompress
-const unicodeMapping = {};
-
-function processEmojiMapData(emojiMapData, shortCode) {
- let [ native, filename ] = emojiMapData;
- if (!filename) {
- // filename name can be derived from unicodeToFilename
- filename = unicodeToFilename(native);
- }
- unicodeMapping[native] = {
- shortCode: shortCode,
- filename: filename,
- };
-}
-
-Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
- let [ filenameData ] = shortCodesToEmojiData[shortCode];
- filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
-});
-emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
-
-module.exports = unicodeMapping;
diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js
deleted file mode 100644
index dbf725c1f..000000000
--- a/app/javascript/mastodon/features/emoji/emoji_utils.js
+++ /dev/null
@@ -1,258 +0,0 @@
-// This code is largely borrowed from:
-// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
-
-import data from './emoji_mart_data_light';
-
-const buildSearch = (data) => {
- const search = [];
-
- let addToSearch = (strings, split) => {
- if (!strings) {
- return;
- }
-
- (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
- (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
- s = s.toLowerCase();
-
- if (search.indexOf(s) === -1) {
- search.push(s);
- }
- });
- });
- };
-
- addToSearch(data.short_names, true);
- addToSearch(data.name, true);
- addToSearch(data.keywords, false);
- addToSearch(data.emoticons, false);
-
- return search.join(',');
-};
-
-const _String = String;
-
-const stringFromCodePoint = _String.fromCodePoint || function () {
- let MAX_SIZE = 0x4000;
- let codeUnits = [];
- let highSurrogate;
- let lowSurrogate;
- let index = -1;
- let length = arguments.length;
- if (!length) {
- return '';
- }
- let result = '';
- while (++index < length) {
- let codePoint = Number(arguments[index]);
- if (
- !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
- codePoint < 0 || // not a valid Unicode code point
- codePoint > 0x10FFFF || // not a valid Unicode code point
- Math.floor(codePoint) !== codePoint // not an integer
- ) {
- throw RangeError('Invalid code point: ' + codePoint);
- }
- if (codePoint <= 0xFFFF) { // BMP code point
- codeUnits.push(codePoint);
- } else { // Astral code point; split in surrogate halves
- // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
- codePoint -= 0x10000;
- highSurrogate = (codePoint >> 10) + 0xD800;
- lowSurrogate = (codePoint % 0x400) + 0xDC00;
- codeUnits.push(highSurrogate, lowSurrogate);
- }
- if (index + 1 === length || codeUnits.length > MAX_SIZE) {
- result += String.fromCharCode.apply(null, codeUnits);
- codeUnits.length = 0;
- }
- }
- return result;
-};
-
-
-const _JSON = JSON;
-
-const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
-const SKINS = [
- '1F3FA', '1F3FB', '1F3FC',
- '1F3FD', '1F3FE', '1F3FF',
-];
-
-function unifiedToNative(unified) {
- let unicodes = unified.split('-'),
- codePoints = unicodes.map((u) => `0x${u}`);
-
- return stringFromCodePoint.apply(null, codePoints);
-}
-
-function sanitize(emoji) {
- let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
- id = emoji.id || short_names[0],
- colons = `:${id}:`;
-
- if (custom) {
- return {
- id,
- name,
- colons,
- emoticons,
- custom,
- imageUrl,
- };
- }
-
- if (skin_tone) {
- colons += `:skin-tone-${skin_tone}:`;
- }
-
- return {
- id,
- name,
- colons,
- emoticons,
- unified: unified.toLowerCase(),
- skin: skin_tone || (skin_variations ? 1 : null),
- native: unifiedToNative(unified),
- };
-}
-
-function getSanitizedData() {
- return sanitize(getData(...arguments));
-}
-
-function getData(emoji, skin, set) {
- let emojiData = {};
-
- if (typeof emoji === 'string') {
- let matches = emoji.match(COLONS_REGEX);
-
- if (matches) {
- emoji = matches[1];
-
- if (matches[2]) {
- skin = parseInt(matches[2]);
- }
- }
-
- if (data.short_names.hasOwnProperty(emoji)) {
- emoji = data.short_names[emoji];
- }
-
- if (data.emojis.hasOwnProperty(emoji)) {
- emojiData = data.emojis[emoji];
- }
- } else if (emoji.id) {
- if (data.short_names.hasOwnProperty(emoji.id)) {
- emoji.id = data.short_names[emoji.id];
- }
-
- if (data.emojis.hasOwnProperty(emoji.id)) {
- emojiData = data.emojis[emoji.id];
- skin = skin || emoji.skin;
- }
- }
-
- if (!Object.keys(emojiData).length) {
- emojiData = emoji;
- emojiData.custom = true;
-
- if (!emojiData.search) {
- emojiData.search = buildSearch(emoji);
- }
- }
-
- emojiData.emoticons = emojiData.emoticons || [];
- emojiData.variations = emojiData.variations || [];
-
- if (emojiData.skin_variations && skin > 1 && set) {
- emojiData = JSON.parse(_JSON.stringify(emojiData));
-
- let skinKey = SKINS[skin - 1],
- variationData = emojiData.skin_variations[skinKey];
-
- if (!variationData.variations && emojiData.variations) {
- delete emojiData.variations;
- }
-
- if (variationData[`has_img_${set}`]) {
- emojiData.skin_tone = skin;
-
- for (let k in variationData) {
- let v = variationData[k];
- emojiData[k] = v;
- }
- }
- }
-
- if (emojiData.variations && emojiData.variations.length) {
- emojiData = JSON.parse(_JSON.stringify(emojiData));
- emojiData.unified = emojiData.variations.shift();
- }
-
- return emojiData;
-}
-
-function uniq(arr) {
- return arr.reduce((acc, item) => {
- if (acc.indexOf(item) === -1) {
- acc.push(item);
- }
- return acc;
- }, []);
-}
-
-function intersect(a, b) {
- const uniqA = uniq(a);
- const uniqB = uniq(b);
-
- return uniqA.filter(item => uniqB.indexOf(item) >= 0);
-}
-
-function deepMerge(a, b) {
- let o = {};
-
- for (let key in a) {
- let originalValue = a[key],
- value = originalValue;
-
- if (b.hasOwnProperty(key)) {
- value = b[key];
- }
-
- if (typeof value === 'object') {
- value = deepMerge(originalValue, value);
- }
-
- o[key] = value;
- }
-
- return o;
-}
-
-// https://github.com/sonicdoe/measure-scrollbar
-function measureScrollbar() {
- const div = document.createElement('div');
-
- div.style.width = '100px';
- div.style.height = '100px';
- div.style.overflow = 'scroll';
- div.style.position = 'absolute';
- div.style.top = '-9999px';
-
- document.body.appendChild(div);
- const scrollbarWidth = div.offsetWidth - div.clientWidth;
- document.body.removeChild(div);
-
- return scrollbarWidth;
-}
-
-export {
- getData,
- getSanitizedData,
- uniq,
- intersect,
- deepMerge,
- unifiedToNative,
- measureScrollbar,
-};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
deleted file mode 100644
index c75c4cd7d..000000000
--- a/app/javascript/mastodon/features/emoji/unicode_to_filename.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// taken from:
-// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
-exports.unicodeToFilename = (str) => {
- let result = '';
- let charCode = 0;
- let p = 0;
- let i = 0;
- while (i < str.length) {
- charCode = str.charCodeAt(i++);
- if (p) {
- if (result.length > 0) {
- result += '-';
- }
- result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
- p = 0;
- } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
- p = charCode;
- } else {
- if (result.length > 0) {
- result += '-';
- }
- result += charCode.toString(16);
- }
- }
- return result;
-};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
deleted file mode 100644
index 808ac197e..000000000
--- a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
+++ /dev/null
@@ -1,17 +0,0 @@
-function padLeft(str, num) {
- while (str.length < num) {
- str = '0' + str;
- }
- return str;
-}
-
-exports.unicodeToUnifiedName = (str) => {
- let output = '';
- for (let i = 0; i < str.length; i += 2) {
- if (i > 0) {
- output += '-';
- }
- output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
- }
- return output;
-};
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
deleted file mode 100644
index 8135527c9..000000000
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
-import Column from '../ui/components/column';
-import ColumnHeader from '../../components/column_header';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import StatusList from '../../components/status_list';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
-});
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'favourites', 'items']),
- hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class Favourites extends ImmutablePureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- statusIds: ImmutablePropTypes.list.isRequired,
- intl: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- multiColumn: PropTypes.bool,
- hasMore: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchFavouritedStatuses());
- }
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('FAVOURITES', {}));
- }
- }
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- handleScrollToBottom = () => {
- this.props.dispatch(expandFavouritedStatuses());
- }
-
- render () {
- const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
deleted file mode 100644
index 6f113beb4..000000000
--- a/app/javascript/mastodon/features/favourites/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import { fetchFavourites } from '../../actions/interactions';
-import { ScrollContainer } from 'react-router-scroll-4';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import ColumnBackButton from '../../components/column_back_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
-});
-
-@connect(mapStateToProps)
-export default class Favourites extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchFavourites(this.props.params.statusId));
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
- this.props.dispatch(fetchFavourites(nextProps.params.statusId));
- }
- }
-
- render () {
- const { accountIds } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {accountIds.map(id =>
)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
deleted file mode 100644
index 4fc5638d9..000000000
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Permalink from '../../../components/permalink';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
- reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
-});
-
-@injectIntl
-export default class AccountAuthorize extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- onAuthorize: PropTypes.func.isRequired,
- onReject: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { intl, account, onAuthorize, onReject } = this.props;
- const content = { __html: account.get('note_emojified') };
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
deleted file mode 100644
index 8db471f73..000000000
--- a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { connect } from 'react-redux';
-import { makeGetAccount } from '../../../selectors';
-import AccountAuthorize from '../components/account_authorize';
-import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, props) => ({
- account: getAccount(state, props.id),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { id }) => ({
- onAuthorize () {
- dispatch(authorizeFollowRequest(id));
- },
-
- onReject () {
- dispatch(rejectFollowRequest(id));
- },
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
deleted file mode 100644
index 1fa52d511..000000000
--- a/app/javascript/mastodon/features/follow_requests/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import { ScrollContainer } from 'react-router-scroll-4';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import AccountAuthorizeContainer from './containers/account_authorize_container';
-import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class FollowRequests extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchFollowRequests());
- }
-
- handleScroll = (e) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
-
- if (scrollTop === scrollHeight - clientHeight) {
- this.props.dispatch(expandFollowRequests());
- }
- }
-
- render () {
- const { intl, accountIds } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {accountIds.map(id =>
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
deleted file mode 100644
index f64ed7948..000000000
--- a/app/javascript/mastodon/features/followers/index.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import {
- fetchAccount,
- fetchFollowers,
- expandFollowers,
-} from '../../actions/accounts';
-import { ScrollContainer } from 'react-router-scroll-4';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import HeaderContainer from '../account_timeline/containers/header_container';
-import LoadMore from '../../components/load_more';
-import ColumnBackButton from '../../components/column_back_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
- hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
-});
-
-@connect(mapStateToProps)
-export default class Followers extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- hasMore: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchAccount(this.props.params.accountId));
- this.props.dispatch(fetchFollowers(this.props.params.accountId));
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
- this.props.dispatch(fetchAccount(nextProps.params.accountId));
- this.props.dispatch(fetchFollowers(nextProps.params.accountId));
- }
- }
-
- handleScroll = (e) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
-
- if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
- this.props.dispatch(expandFollowers(this.props.params.accountId));
- }
- }
-
- handleLoadMore = (e) => {
- e.preventDefault();
- this.props.dispatch(expandFollowers(this.props.params.accountId));
- }
-
- render () {
- const { accountIds, hasMore } = this.props;
-
- let loadMore = null;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- if (hasMore) {
- loadMore = ;
- }
-
- return (
-
-
-
-
-
-
-
- {accountIds.map(id =>
)}
- {loadMore}
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
deleted file mode 100644
index a0c0fac05..000000000
--- a/app/javascript/mastodon/features/following/index.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import {
- fetchAccount,
- fetchFollowing,
- expandFollowing,
-} from '../../actions/accounts';
-import { ScrollContainer } from 'react-router-scroll-4';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import HeaderContainer from '../account_timeline/containers/header_container';
-import LoadMore from '../../components/load_more';
-import ColumnBackButton from '../../components/column_back_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
- hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
-});
-
-@connect(mapStateToProps)
-export default class Following extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- hasMore: PropTypes.bool,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchAccount(this.props.params.accountId));
- this.props.dispatch(fetchFollowing(this.props.params.accountId));
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
- this.props.dispatch(fetchAccount(nextProps.params.accountId));
- this.props.dispatch(fetchFollowing(nextProps.params.accountId));
- }
- }
-
- handleScroll = (e) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
-
- if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
- this.props.dispatch(expandFollowing(this.props.params.accountId));
- }
- }
-
- handleLoadMore = (e) => {
- e.preventDefault();
- this.props.dispatch(expandFollowing(this.props.params.accountId));
- }
-
- render () {
- const { accountIds, hasMore } = this.props;
-
- let loadMore = null;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- if (hasMore) {
- loadMore = ;
- }
-
- return (
-
-
-
-
-
-
-
- {accountIds.map(id =>
)}
- {loadMore}
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js
deleted file mode 100644
index 0290be47f..000000000
--- a/app/javascript/mastodon/features/generic_not_found/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-import Column from '../ui/components/column';
-import MissingIndicator from '../../components/missing_indicator';
-
-const GenericNotFound = () => (
-
-
-
-);
-
-export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
deleted file mode 100644
index 2f7d9281e..000000000
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import React from 'react';
-import Column from '../ui/components/column';
-import ColumnLink from '../ui/components/column_link';
-import ColumnSubheading from '../ui/components/column_subheading';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { connect } from 'react-redux';
-import { openModal } from '../../actions/modal';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from '../../initial_state';
-
-const messages = defineMessages({
- heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
- home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
- notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
- public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
- navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
- settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
- community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
- direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
- preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
- settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
- follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
- sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
- favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
- blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
- mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
- info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
- show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
- pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
-});
-
-const mapStateToProps = state => ({
- myAccount: state.getIn(['accounts', me]),
- columns: state.getIn(['settings', 'columns']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class GettingStarted extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- myAccount: ImmutablePropTypes.map.isRequired,
- columns: ImmutablePropTypes.list,
- multiColumn: PropTypes.bool,
- dispatch: PropTypes.func.isRequired,
- };
-
- openSettings = () => {
- this.props.dispatch(openModal('SETTINGS', {}));
- }
-
- openOnboardingModal = (e) => {
- e.preventDefault();
- this.props.dispatch(openModal('ONBOARDING'));
- }
-
- render () {
- const { intl, myAccount, columns, multiColumn } = this.props;
-
- let navItems = [];
-
- if (multiColumn) {
- if (!columns.find(item => item.get('id') === 'HOME')) {
- navItems.push( );
- }
-
- if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
- navItems.push( );
- }
-
- if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
- navItems.push( );
- }
-
- if (!columns.find(item => item.get('id') === 'PUBLIC')) {
- navItems.push( );
- }
- }
-
- if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
- navItems.push( );
- }
-
- navItems = navItems.concat([
- ,
- ,
- ]);
-
- if (myAccount.get('locked')) {
- navItems.push( );
- }
-
- navItems = navItems.concat([
- ,
- ,
- ]);
-
- return (
-
-
-
-
- {navItems}
-
-
-
-
-
-
-
-
-
-
-
-
-
- •
-
-
- •
-
-
-
-
-
- glitch-soc/mastodon,
- Mastodon: Mastodon ,
- }}
- />
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
deleted file mode 100644
index 2077b7cdf..000000000
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import {
- refreshHashtagTimeline,
- expandHashtagTimeline,
-} from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { FormattedMessage } from 'react-intl';
-import { connectHashtagStream } from '../../actions/streaming';
-
-const mapStateToProps = (state, props) => ({
- hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
-});
-
-@connect(mapStateToProps)
-export default class HashtagTimeline extends React.PureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- dispatch: PropTypes.func.isRequired,
- hasUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
- }
- }
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- _subscribe (dispatch, id) {
- this.disconnect = dispatch(connectHashtagStream(id));
- }
-
- _unsubscribe () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- componentDidMount () {
- const { dispatch } = this.props;
- const { id } = this.props.params;
-
- dispatch(refreshHashtagTimeline(id));
- this._subscribe(dispatch, id);
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.id !== this.props.params.id) {
- this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
- this._unsubscribe();
- this._subscribe(this.props.dispatch, nextProps.params.id);
- }
- }
-
- componentWillUnmount () {
- this._unsubscribe();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- handleLoadMore = () => {
- this.props.dispatch(expandHashtagTimeline(this.props.params.id));
- }
-
- render () {
- const { hasUnread, columnId, multiColumn } = this.props;
- const { id } = this.props.params;
- const pinned = !!columnId;
-
- return (
-
-
-
- }
- />
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
deleted file mode 100644
index 43172bd25..000000000
--- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import SettingToggle from '../../notifications/components/setting_toggle';
-import SettingText from '../../../components/setting_text';
-
-const messages = defineMessages({
- filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
- settings: { id: 'home.settings', defaultMessage: 'Column settings' },
-});
-
-@injectIntl
-export default class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { settings, onChange, intl } = this.props;
-
- return (
-
-
-
-
- } />
-
-
-
- } />
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
deleted file mode 100644
index fd8a39298..000000000
--- a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connect } from 'react-redux';
-import ColumnSettings from '../components/column_settings';
-import { changeSetting, saveSettings } from '../../../actions/settings';
-
-const mapStateToProps = state => ({
- settings: state.getIn(['settings', 'home']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onChange (key, checked) {
- dispatch(changeSetting(['home', ...key], checked));
- },
-
- onSave () {
- dispatch(saveSettings());
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
deleted file mode 100644
index b35347ba6..000000000
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { expandHomeTimeline } from '../../actions/timelines';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { Link } from 'react-router-dom';
-
-const messages = defineMessages({
- title: { id: 'column.home', defaultMessage: 'Home' },
-});
-
-const mapStateToProps = state => ({
- hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class HomeTimeline extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- hasUnread: PropTypes.bool,
- columnId: PropTypes.string,
- multiColumn: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('HOME', {}));
- }
- }
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- handleLoadMore = () => {
- this.props.dispatch(expandHomeTimeline());
- }
-
- render () {
- const { intl, hasUnread, columnId, multiColumn } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
-
-
- }} />}
- />
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
deleted file mode 100644
index ae6ec343f..000000000
--- a/app/javascript/mastodon/features/mutes/index.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import { ScrollContainer } from 'react-router-scroll-4';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import AccountContainer from '../../containers/account_container';
-import { fetchMutes, expandMutes } from '../../actions/mutes';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
-});
-
-const mapStateToProps = state => ({
- accountIds: state.getIn(['user_lists', 'mutes', 'items']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class Mutes extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchMutes());
- }
-
- handleScroll = (e) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
-
- if (scrollTop === scrollHeight - clientHeight) {
- this.props.dispatch(expandMutes());
- }
- }
-
- render () {
- const { intl, accountIds } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
- {accountIds.map(id =>
-
- )}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
deleted file mode 100644
index 22a10753f..000000000
--- a/app/javascript/mastodon/features/notifications/components/clear_column_button.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-
-export default class ClearColumnButton extends React.Component {
-
- static propTypes = {
- onClick: PropTypes.func.isRequired,
- };
-
- render () {
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
deleted file mode 100644
index 88a29d4d3..000000000
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import ClearColumnButton from './clear_column_button';
-import SettingToggle from './setting_toggle';
-
-export default class ColumnSettings extends React.PureComponent {
-
- static propTypes = {
- settings: ImmutablePropTypes.map.isRequired,
- pushSettings: ImmutablePropTypes.map.isRequired,
- onChange: PropTypes.func.isRequired,
- onSave: PropTypes.func.isRequired,
- onClear: PropTypes.func.isRequired,
- };
-
- onPushChange = (key, checked) => {
- this.props.onChange(['push', ...key], checked);
- }
-
- render () {
- const { settings, pushSettings, onChange, onClear } = this.props;
-
- const alertStr = ;
- const showStr = ;
- const soundStr = ;
-
- const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
- const pushStr = showPushSettings && ;
- const pushMeta = showPushSettings && ;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
-
-
-
-
-
- {showPushSettings && }
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
deleted file mode 100644
index 903526822..000000000
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ /dev/null
@@ -1,155 +0,0 @@
-// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
-// SEE INSTEAD : glitch/components/notification
-
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import StatusContainer from '../../../containers/status_container';
-import AccountContainer from '../../../containers/account_container';
-import { FormattedMessage } from 'react-intl';
-import Permalink from '../../../components/permalink';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { HotKeys } from 'react-hotkeys';
-
-export default class Notification extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- notification: ImmutablePropTypes.map.isRequired,
- hidden: PropTypes.bool,
- onMoveUp: PropTypes.func.isRequired,
- onMoveDown: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- };
-
- handleMoveUp = () => {
- const { notification, onMoveUp } = this.props;
- onMoveUp(notification.get('id'));
- }
-
- handleMoveDown = () => {
- const { notification, onMoveDown } = this.props;
- onMoveDown(notification.get('id'));
- }
-
- handleOpen = () => {
- const { notification } = this.props;
-
- if (notification.get('status')) {
- this.context.router.history.push(`/statuses/${notification.get('status')}`);
- } else {
- this.handleOpenProfile();
- }
- }
-
- handleOpenProfile = () => {
- const { notification } = this.props;
- this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
- }
-
- handleMention = e => {
- e.preventDefault();
-
- const { notification, onMention } = this.props;
- onMention(notification.get('account'), this.context.router.history);
- }
-
- getHandlers () {
- return {
- moveUp: this.handleMoveUp,
- moveDown: this.handleMoveDown,
- open: this.handleOpen,
- openProfile: this.handleOpenProfile,
- mention: this.handleMention,
- reply: this.handleMention,
- };
- }
-
- renderFollow (account, link) {
- return (
-
-
-
- );
- }
-
- renderMention (notification) {
- return (
-
- );
- }
-
- renderFavourite (notification, link) {
- return (
-
-
-
- );
- }
-
- renderReblog (notification, link) {
- return (
-
-
-
- );
- }
-
- render () {
- const { notification } = this.props;
- const account = notification.get('account');
- const displayNameHtml = { __html: account.get('display_name_html') };
- const link = ;
-
- switch(notification.get('type')) {
- case 'follow':
- return this.renderFollow(account, link);
- case 'mention':
- return this.renderMention(notification);
- case 'favourite':
- return this.renderFavourite(notification, link);
- case 'reblog':
- return this.renderReblog(notification, link);
- }
-
- return null;
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
deleted file mode 100644
index 281359d2a..000000000
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Toggle from 'react-toggle';
-
-export default class SettingToggle extends React.PureComponent {
-
- static propTypes = {
- prefix: PropTypes.string,
- settings: ImmutablePropTypes.map.isRequired,
- settingKey: PropTypes.array.isRequired,
- label: PropTypes.node.isRequired,
- meta: PropTypes.node,
- onChange: PropTypes.func.isRequired,
- }
-
- onChange = ({ target }) => {
- this.props.onChange(this.props.settingKey, target.checked);
- }
-
- render () {
- const { prefix, settings, settingKey, label, meta } = this.props;
- const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
-
- return (
-
-
- {label}
- {meta && {meta} }
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
deleted file mode 100644
index d4ead7881..000000000
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { connect } from 'react-redux';
-import { defineMessages, injectIntl } from 'react-intl';
-import ColumnSettings from '../components/column_settings';
-import { changeSetting, saveSettings } from '../../../actions/settings';
-import { clearNotifications } from '../../../actions/notifications';
-import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
-import { openModal } from '../../../actions/modal';
-
-const messages = defineMessages({
- clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
- clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
-});
-
-const mapStateToProps = state => ({
- settings: state.getIn(['settings', 'notifications']),
- pushSettings: state.get('push_notifications'),
-});
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
-
- onChange (key, checked) {
- if (key[0] === 'push') {
- dispatch(changePushNotifications(key.slice(1), checked));
- } else {
- dispatch(changeSetting(['notifications', ...key], checked));
- }
- },
-
- onSave () {
- dispatch(saveSettings());
- dispatch(savePushNotificationSettings());
- },
-
- onClear () {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.clearMessage),
- confirm: intl.formatMessage(messages.clearConfirm),
- onConfirm: () => dispatch(clearNotifications()),
- }));
- },
-
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
deleted file mode 100644
index fd16c4331..000000000
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
-// SEE INSTEAD : glitch/components/notification/container
-
-import { connect } from 'react-redux';
-import { makeGetNotification } from '../../../selectors';
-import Notification from '../components/notification';
-import { mentionCompose } from '../../../actions/compose';
-
-const makeMapStateToProps = () => {
- const getNotification = makeGetNotification();
-
- const mapStateToProps = (state, props) => ({
- notification: getNotification(state, props.notification, props.accountId),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
- onMention: (account, router) => {
- dispatch(mentionCompose(account, router));
- },
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
deleted file mode 100644
index 9c6802482..000000000
--- a/app/javascript/mastodon/features/notifications/index.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import {
- enterNotificationClearingMode,
- expandNotifications,
- scrollTopNotifications,
-} from '../../actions/notifications';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import NotificationContainer from '../../../glitch/components/notification/container';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
-import { debounce } from 'lodash';
-import ScrollableList from '../../components/scrollable_list';
-
-const messages = defineMessages({
- title: { id: 'column.notifications', defaultMessage: 'Notifications' },
-});
-
-const getNotifications = createSelector([
- state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
- state => state.getIn(['notifications', 'items']),
-], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
-
-const mapStateToProps = state => ({
- notifications: getNotifications(state),
- localSettings: state.get('local_settings'),
- isLoading: state.getIn(['notifications', 'isLoading'], true),
- isUnread: state.getIn(['notifications', 'unread']) > 0,
- hasMore: !!state.getIn(['notifications', 'next']),
- notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
-});
-
-/* glitch */
-const mapDispatchToProps = dispatch => ({
- onEnterCleaningMode(yes) {
- dispatch(enterNotificationClearingMode(yes));
- },
- dispatch,
-});
-
-@connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-export default class Notifications extends React.PureComponent {
-
- static propTypes = {
- columnId: PropTypes.string,
- notifications: ImmutablePropTypes.list.isRequired,
- dispatch: PropTypes.func.isRequired,
- shouldUpdateScroll: PropTypes.func,
- intl: PropTypes.object.isRequired,
- isLoading: PropTypes.bool,
- isUnread: PropTypes.bool,
- multiColumn: PropTypes.bool,
- hasMore: PropTypes.bool,
- localSettings: ImmutablePropTypes.map,
- notifCleaningActive: PropTypes.bool,
- onEnterCleaningMode: PropTypes.func,
- };
-
- static defaultProps = {
- trackScroll: true,
- };
-
- handleScrollToBottom = debounce(() => {
- this.props.dispatch(scrollTopNotifications(false));
- this.props.dispatch(expandNotifications());
- }, 300, { leading: true });
-
- handleScrollToTop = debounce(() => {
- this.props.dispatch(scrollTopNotifications(true));
- }, 100);
-
- handleScroll = debounce(() => {
- this.props.dispatch(scrollTopNotifications(false));
- }, 100);
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('NOTIFICATIONS', {}));
- }
- }
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setColumnRef = c => {
- this.column = c;
- }
-
- handleMoveUp = id => {
- const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
- this._selectChild(elementIndex);
- }
-
- handleMoveDown = id => {
- const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
- this._selectChild(elementIndex);
- }
-
- _selectChild (index) {
- const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
-
- if (element) {
- element.focus();
- }
- }
-
- render () {
- const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
- const pinned = !!columnId;
- const emptyMessage = ;
-
- let scrollableContent = null;
-
- if (isLoading && this.scrollableContent) {
- scrollableContent = this.scrollableContent;
- } else if (notifications.size > 0 || hasMore) {
- scrollableContent = notifications.map((item) => (
-
- ));
- } else {
- scrollableContent = null;
- }
-
- this.scrollableContent = scrollableContent;
-
- const scrollContainer = (
-
- {scrollableContent}
-
- );
-
- return (
-
-
-
-
-
- {scrollContainer}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js
deleted file mode 100644
index b4a6c1e52..000000000
--- a/app/javascript/mastodon/features/pinned_statuses/index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { fetchPinnedStatuses } from '../../actions/pin_statuses';
-import Column from '../ui/components/column';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
-import StatusList from '../../components/status_list';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
-});
-
-const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'pins', 'items']),
- hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class PinnedStatuses extends ImmutablePureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- statusIds: ImmutablePropTypes.list.isRequired,
- intl: PropTypes.object.isRequired,
- hasMore: PropTypes.bool.isRequired,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchPinnedStatuses());
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- render () {
- const { intl, statusIds, hasMore } = this.props;
-
- return (
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js
deleted file mode 100644
index 203e1da92..000000000
--- a/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { connect } from 'react-redux';
-import ColumnSettings from '../../community_timeline/components/column_settings';
-import { changeSetting } from '../../../actions/settings';
-
-const mapStateToProps = state => ({
- settings: state.getIn(['settings', 'public']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onChange (key, checked) {
- dispatch(changeSetting(['public', ...key], checked));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
deleted file mode 100644
index 1821bc448..000000000
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../ui/containers/status_list_container';
-import Column from '../../components/column';
-import ColumnHeader from '../../components/column_header';
-import {
- refreshPublicTimeline,
- expandPublicTimeline,
-} from '../../actions/timelines';
-import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
-import { connectPublicStream } from '../../actions/streaming';
-
-const messages = defineMessages({
- title: { id: 'column.public', defaultMessage: 'Federated timeline' },
-});
-
-const mapStateToProps = state => ({
- hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class PublicTimeline extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- columnId: PropTypes.string,
- multiColumn: PropTypes.bool,
- hasUnread: PropTypes.bool,
- };
-
- handlePin = () => {
- const { columnId, dispatch } = this.props;
-
- if (columnId) {
- dispatch(removeColumn(columnId));
- } else {
- dispatch(addColumn('PUBLIC', {}));
- }
- }
-
- handleMove = (dir) => {
- const { columnId, dispatch } = this.props;
- dispatch(moveColumn(columnId, dir));
- }
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- componentDidMount () {
- const { dispatch } = this.props;
-
- dispatch(refreshPublicTimeline());
- this.disconnect = dispatch(connectPublicStream());
- }
-
- componentWillUnmount () {
- if (this.disconnect) {
- this.disconnect();
- this.disconnect = null;
- }
- }
-
- setRef = c => {
- this.column = c;
- }
-
- handleLoadMore = () => {
- this.props.dispatch(expandPublicTimeline());
- }
-
- render () {
- const { intl, columnId, hasUnread, multiColumn } = this.props;
- const pinned = !!columnId;
-
- return (
-
-
-
-
-
- }
- />
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
deleted file mode 100644
index 579d6aaa0..000000000
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
-import { fetchReblogs } from '../../actions/interactions';
-import { ScrollContainer } from 'react-router-scroll-4';
-import AccountContainer from '../../containers/account_container';
-import Column from '../ui/components/column';
-import ColumnBackButton from '../../components/column_back_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const mapStateToProps = (state, props) => ({
- accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
-});
-
-@connect(mapStateToProps)
-export default class Reblogs extends ImmutablePureComponent {
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- accountIds: ImmutablePropTypes.list,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchReblogs(this.props.params.statusId));
- }
-
- componentWillReceiveProps(nextProps) {
- if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
- this.props.dispatch(fetchReblogs(nextProps.params.statusId));
- }
- }
-
- render () {
- const { accountIds } = this.props;
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {accountIds.map(id =>
)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
deleted file mode 100644
index cc9232201..000000000
--- a/app/javascript/mastodon/features/report/components/status_check_box.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Toggle from 'react-toggle';
-
-export default class StatusCheckBox extends React.PureComponent {
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- checked: PropTypes.bool,
- onToggle: PropTypes.func.isRequired,
- disabled: PropTypes.bool,
- };
-
- render () {
- const { status, checked, onToggle, disabled } = this.props;
- const content = { __html: status.get('contentHtml') };
-
- if (status.get('reblog')) {
- return null;
- }
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
deleted file mode 100644
index 48cd0319b..000000000
--- a/app/javascript/mastodon/features/report/containers/status_check_box_container.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connect } from 'react-redux';
-import StatusCheckBox from '../components/status_check_box';
-import { toggleStatusReport } from '../../../actions/reports';
-import { Set as ImmutableSet } from 'immutable';
-
-const mapStateToProps = (state, { id }) => ({
- status: state.getIn(['statuses', id]),
- checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id),
-});
-
-const mapDispatchToProps = (dispatch, { id }) => ({
-
- onToggle (e) {
- dispatch(toggleStatusReport(id, e.target.checked));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
deleted file mode 100644
index 0d764575f..000000000
--- a/app/javascript/mastodon/features/standalone/compose/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import ComposeFormContainer from '../../compose/containers/compose_form_container';
-import NotificationsContainer from '../../ui/containers/notifications_container';
-import LoadingBarContainer from '../../ui/containers/loading_bar_container';
-import ModalContainer from '../../ui/containers/modal_container';
-
-export default class Compose extends React.PureComponent {
-
- render () {
- return (
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
deleted file mode 100644
index f15fbb2f4..000000000
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
-import {
- refreshHashtagTimeline,
- expandHashtagTimeline,
-} from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-
-@connect()
-export default class HashtagTimeline extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- hashtag: PropTypes.string.isRequired,
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- componentDidMount () {
- const { dispatch, hashtag } = this.props;
-
- dispatch(refreshHashtagTimeline(hashtag));
-
- this.polling = setInterval(() => {
- dispatch(refreshHashtagTimeline(hashtag));
- }, 10000);
- }
-
- componentWillUnmount () {
- if (typeof this.polling !== 'undefined') {
- clearInterval(this.polling);
- this.polling = null;
- }
- }
-
- handleLoadMore = () => {
- this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
- }
-
- render () {
- const { hashtag } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js
deleted file mode 100644
index de4b5320a..000000000
--- a/app/javascript/mastodon/features/standalone/public_timeline/index.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
-import {
- refreshPublicTimeline,
- expandPublicTimeline,
-} from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-import { defineMessages, injectIntl } from 'react-intl';
-
-const messages = defineMessages({
- title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
-});
-
-@connect()
-@injectIntl
-export default class PublicTimeline extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
- componentDidMount () {
- const { dispatch } = this.props;
-
- dispatch(refreshPublicTimeline());
-
- this.polling = setInterval(() => {
- dispatch(refreshPublicTimeline());
- }, 3000);
- }
-
- componentWillUnmount () {
- if (typeof this.polling !== 'undefined') {
- clearInterval(this.polling);
- this.polling = null;
- }
- }
-
- handleLoadMore = () => {
- this.props.dispatch(expandPublicTimeline());
- }
-
- render () {
- const { intl } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
deleted file mode 100644
index 8c6994a07..000000000
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
-import { defineMessages, injectIntl } from 'react-intl';
-import { me } from '../../../initial_state';
-
-const messages = defineMessages({
- delete: { id: 'status.delete', defaultMessage: 'Delete' },
- mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
- reply: { id: 'status.reply', defaultMessage: 'Reply' },
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
- cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
- favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- report: { id: 'status.report', defaultMessage: 'Report @{name}' },
- share: { id: 'status.share', defaultMessage: 'Share' },
- pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
- unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
- embed: { id: 'status.embed', defaultMessage: 'Embed' },
-});
-
-@injectIntl
-export default class ActionBar extends React.PureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- onReply: PropTypes.func.isRequired,
- onReblog: PropTypes.func.isRequired,
- onFavourite: PropTypes.func.isRequired,
- onDelete: PropTypes.func.isRequired,
- onMention: PropTypes.func.isRequired,
- onReport: PropTypes.func,
- onPin: PropTypes.func,
- onEmbed: PropTypes.func,
- intl: PropTypes.object.isRequired,
- };
-
- handleReplyClick = () => {
- this.props.onReply(this.props.status);
- }
-
- handleReblogClick = (e) => {
- this.props.onReblog(this.props.status, e);
- }
-
- handleFavouriteClick = () => {
- this.props.onFavourite(this.props.status);
- }
-
- handleDeleteClick = () => {
- this.props.onDelete(this.props.status);
- }
-
- handleMentionClick = () => {
- this.props.onMention(this.props.status.get('account'), this.context.router.history);
- }
-
- handleReport = () => {
- this.props.onReport(this.props.status);
- }
-
- handlePinClick = () => {
- this.props.onPin(this.props.status);
- }
-
- handleShare = () => {
- navigator.share({
- text: this.props.status.get('search_index'),
- url: this.props.status.get('url'),
- });
- }
-
- handleEmbed = () => {
- this.props.onEmbed(this.props.status);
- }
-
- render () {
- const { status, intl } = this.props;
-
- const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
-
- let menu = [];
-
- if (publicStatus) {
- menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
- }
-
- if (me === status.getIn(['account', 'id'])) {
- if (publicStatus) {
- menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
- }
-
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
- }
-
- const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
-
- );
-
- let reblogIcon = 'retweet';
- //if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
- // else if (status.get('visibility') === 'private') reblogIcon = 'lock';
-
- let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
-
- return (
-
-
-
-
- {shareButton}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
deleted file mode 100644
index bb83374b9..000000000
--- a/app/javascript/mastodon/features/status/components/card.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import punycode from 'punycode';
-import classnames from 'classnames';
-
-const IDNA_PREFIX = 'xn--';
-
-const decodeIDNA = domain => {
- return domain
- .split('.')
- .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
- .join('.');
-};
-
-const getHostname = url => {
- const parser = document.createElement('a');
- parser.href = url;
- return parser.hostname;
-};
-
-export default class Card extends React.PureComponent {
-
- static propTypes = {
- card: ImmutablePropTypes.map,
- maxDescription: PropTypes.number,
- };
-
- static defaultProps = {
- maxDescription: 50,
- };
-
- state = {
- width: 0,
- };
-
- renderLink () {
- const { card, maxDescription } = this.props;
-
- let image = '';
- let provider = card.get('provider_name');
-
- if (card.get('image')) {
- image = (
-
-
-
- );
- }
-
- if (provider.length < 1) {
- provider = decodeIDNA(getHostname(card.get('url')));
- }
-
- const className = classnames('status-card', {
- 'horizontal': card.get('width') > card.get('height'),
- });
-
- return (
-
- {image}
-
-
-
{card.get('title')}
-
{(card.get('description') || '').substring(0, maxDescription)}
-
{provider}
-
-
- );
- }
-
- renderPhoto () {
- const { card } = this.props;
-
- return (
-
-
-
- );
- }
-
- setRef = c => {
- if (c) {
- this.setState({ width: c.offsetWidth });
- }
- }
-
- renderVideo () {
- const { card } = this.props;
- const content = { __html: card.get('html') };
- const { width } = this.state;
- const ratio = card.get('width') / card.get('height');
- const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
-
- return (
-
- );
- }
-
- render () {
- const { card } = this.props;
-
- if (card === null) {
- return null;
- }
-
- switch(card.get('type')) {
- case 'link':
- return this.renderLink();
- case 'photo':
- return this.renderPhoto();
- case 'video':
- return this.renderVideo();
- case 'rich':
- default:
- return null;
- }
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
deleted file mode 100644
index 85a030ea8..000000000
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Avatar from '../../../components/avatar';
-import DisplayName from '../../../components/display_name';
-import StatusContent from '../../../../glitch/components/status/content';
-import StatusGallery from '../../../../glitch/components/status/gallery';
-import StatusPlayer from '../../../../glitch/components/status/player';
-import AttachmentList from '../../../components/attachment_list';
-import { Link } from 'react-router-dom';
-import { FormattedDate, FormattedNumber } from 'react-intl';
-import CardContainer from '../containers/card_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-// import Video from '../../video';
-import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
-
-export default class DetailedStatus extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- settings: ImmutablePropTypes.map.isRequired,
- onOpenMedia: PropTypes.func.isRequired,
- onOpenVideo: PropTypes.func.isRequired,
- };
-
- handleAccountClick = (e) => {
- if (e.button === 0) {
- e.preventDefault();
- this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
- }
-
- e.stopPropagation();
- }
-
- // handleOpenVideo = startTime => {
- // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
- // }
-
- render () {
- const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
- const { settings } = this.props;
-
- let media = '';
- let mediaIcon = null;
- let applicationLink = '';
- let reblogLink = '';
- let reblogIcon = 'retweet';
-
- if (status.get('media_attachments').size > 0) {
- if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
- media = ;
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- media = (
-
- );
- mediaIcon = 'video-camera';
- } else {
- media = (
-
- );
- mediaIcon = 'picture-o';
- }
- } else media = ;
-
- if (status.get('application')) {
- applicationLink = · {status.getIn(['application', 'name'])} ;
- }
-
- if (status.get('visibility') === 'direct') {
- reblogIcon = 'envelope';
- } else if (status.get('visibility') === 'private') {
- reblogIcon = 'lock';
- }
-
- if (status.get('visibility') === 'private') {
- reblogLink = ;
- } else {
- reblogLink = (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- {applicationLink} · {reblogLink} ·
-
-
-
-
- ·
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
deleted file mode 100644
index a97404de1..000000000
--- a/app/javascript/mastodon/features/status/containers/card_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import Card from '../components/card';
-
-const mapStateToProps = (state, { statusId }) => ({
- card: state.getIn(['cards', statusId], null),
-});
-
-export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
deleted file mode 100644
index e7ea046dd..000000000
--- a/app/javascript/mastodon/features/status/index.js
+++ /dev/null
@@ -1,343 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { fetchStatus } from '../../actions/statuses';
-import MissingIndicator from '../../components/missing_indicator';
-import DetailedStatus from './components/detailed_status';
-import ActionBar from './components/action_bar';
-import Column from '../ui/components/column';
-import {
- favourite,
- unfavourite,
- reblog,
- unreblog,
- pin,
- unpin,
-} from '../../actions/interactions';
-import {
- replyCompose,
- mentionCompose,
-} from '../../actions/compose';
-import { deleteStatus } from '../../actions/statuses';
-import { initReport } from '../../actions/reports';
-import { makeGetStatus } from '../../selectors';
-import { ScrollContainer } from 'react-router-scroll-4';
-import ColumnBackButton from '../../components/column_back_button';
-import StatusContainer from '../../../glitch/components/status/container';
-import { openModal } from '../../actions/modal';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { HotKeys } from 'react-hotkeys';
-import { boostModal, deleteModal } from '../../initial_state';
-import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
-
-const messages = defineMessages({
- deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
- deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
-});
-
-const makeMapStateToProps = () => {
- const getStatus = makeGetStatus();
-
- const mapStateToProps = (state, props) => ({
- status: getStatus(state, props.params.statusId),
- settings: state.get('local_settings'),
- ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
- descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
- });
-
- return mapStateToProps;
-};
-
-@injectIntl
-@connect(makeMapStateToProps)
-export default class Status extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- status: ImmutablePropTypes.map,
- settings: ImmutablePropTypes.map.isRequired,
- ancestorsIds: ImmutablePropTypes.list,
- descendantsIds: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- fullscreen: false,
- };
-
- componentWillMount () {
- this.props.dispatch(fetchStatus(this.props.params.statusId));
- }
-
- componentDidMount () {
- attachFullscreenListener(this.onFullScreenChange);
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
- this._scrolledIntoView = false;
- this.props.dispatch(fetchStatus(nextProps.params.statusId));
- }
- }
-
- handleFavouriteClick = (status) => {
- if (status.get('favourited')) {
- this.props.dispatch(unfavourite(status));
- } else {
- this.props.dispatch(favourite(status));
- }
- }
-
- handlePin = (status) => {
- if (status.get('pinned')) {
- this.props.dispatch(unpin(status));
- } else {
- this.props.dispatch(pin(status));
- }
- }
-
- handleReplyClick = (status) => {
- this.props.dispatch(replyCompose(status, this.context.router.history));
- }
-
- handleModalReblog = (status) => {
- this.props.dispatch(reblog(status));
- }
-
- handleReblogClick = (status, e) => {
- if (status.get('reblogged')) {
- this.props.dispatch(unreblog(status));
- } else {
- if (e.shiftKey || !boostModal) {
- this.handleModalReblog(status);
- } else {
- this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
- }
- }
- }
-
- handleDeleteClick = (status) => {
- const { dispatch, intl } = this.props;
-
- if (!deleteModal) {
- dispatch(deleteStatus(status.get('id')));
- } else {
- dispatch(openModal('CONFIRM', {
- message: intl.formatMessage(messages.deleteMessage),
- confirm: intl.formatMessage(messages.deleteConfirm),
- onConfirm: () => dispatch(deleteStatus(status.get('id'))),
- }));
- }
- }
-
- handleMentionClick = (account, router) => {
- this.props.dispatch(mentionCompose(account, router));
- }
-
- handleOpenMedia = (media, index) => {
- this.props.dispatch(openModal('MEDIA', { media, index }));
- }
-
- handleOpenVideo = (media, time) => {
- this.props.dispatch(openModal('VIDEO', { media, time }));
- }
-
- handleReport = (status) => {
- this.props.dispatch(initReport(status.get('account'), status));
- }
-
- handleEmbed = (status) => {
- this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
- }
-
- handleHotkeyMoveUp = () => {
- this.handleMoveUp(this.props.status.get('id'));
- }
-
- handleHotkeyMoveDown = () => {
- this.handleMoveDown(this.props.status.get('id'));
- }
-
- handleHotkeyReply = e => {
- e.preventDefault();
- this.handleReplyClick(this.props.status);
- }
-
- handleHotkeyFavourite = () => {
- this.handleFavouriteClick(this.props.status);
- }
-
- handleHotkeyBoost = () => {
- this.handleReblogClick(this.props.status);
- }
-
- handleHotkeyMention = e => {
- e.preventDefault();
- this.handleMentionClick(this.props.status);
- }
-
- handleHotkeyOpenProfile = () => {
- this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
- }
-
- handleMoveUp = id => {
- const { status, ancestorsIds, descendantsIds } = this.props;
-
- if (id === status.get('id')) {
- this._selectChild(ancestorsIds.size - 1);
- } else {
- let index = ancestorsIds.indexOf(id);
-
- if (index === -1) {
- index = descendantsIds.indexOf(id);
- this._selectChild(ancestorsIds.size + index);
- } else {
- this._selectChild(index - 1);
- }
- }
- }
-
- handleMoveDown = id => {
- const { status, ancestorsIds, descendantsIds } = this.props;
-
- if (id === status.get('id')) {
- this._selectChild(ancestorsIds.size + 1);
- } else {
- let index = ancestorsIds.indexOf(id);
-
- if (index === -1) {
- index = descendantsIds.indexOf(id);
- this._selectChild(ancestorsIds.size + index + 2);
- } else {
- this._selectChild(index + 1);
- }
- }
- }
-
- _selectChild (index) {
- const element = this.node.querySelectorAll('.focusable')[index];
-
- if (element) {
- element.focus();
- }
- }
-
- renderChildren (list) {
- return list.map(id => (
-
- ));
- }
-
- setRef = c => {
- this.node = c;
- }
-
- componentDidUpdate () {
- if (this._scrolledIntoView) {
- return;
- }
-
- const { status, ancestorsIds } = this.props;
-
- if (status && ancestorsIds && ancestorsIds.size > 0) {
- const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
-
- if (element) {
- element.scrollIntoView(true);
- this._scrolledIntoView = true;
- }
- }
- }
-
- componentWillUnmount () {
- detachFullscreenListener(this.onFullScreenChange);
- }
-
- onFullScreenChange = () => {
- this.setState({ fullscreen: isFullscreen() });
- }
-
- render () {
- let ancestors, descendants;
- const { status, settings, ancestorsIds, descendantsIds } = this.props;
- const { fullscreen } = this.state;
-
- if (status === null) {
- return (
-
-
-
-
- );
- }
-
- if (ancestorsIds && ancestorsIds.size > 0) {
- ancestors = {this.renderChildren(ancestorsIds)}
;
- }
-
- if (descendantsIds && descendantsIds.size > 0) {
- descendants = {this.renderChildren(descendantsIds)}
;
- }
-
- const handlers = {
- moveUp: this.handleHotkeyMoveUp,
- moveDown: this.handleHotkeyMoveDown,
- reply: this.handleHotkeyReply,
- favourite: this.handleHotkeyFavourite,
- boost: this.handleHotkeyBoost,
- mention: this.handleHotkeyMention,
- openProfile: this.handleHotkeyOpenProfile,
- };
-
- return (
-
-
-
-
-
- {ancestors}
-
-
-
-
-
- {descendants}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
deleted file mode 100644
index 1e5e1d8dc..000000000
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import { mount } from 'enzyme';
-import Column from '../column';
-import ColumnHeader from '../column_header';
-
-describe(' ', () => {
- describe(' click handler', () => {
- const originalRaf = global.requestAnimationFrame;
-
- beforeEach(() => {
- global.requestAnimationFrame = jest.fn();
- });
-
- afterAll(() => {
- global.requestAnimationFrame = originalRaf;
- });
-
- it('runs the scroll animation if the column contains scrollable content', () => {
- const wrapper = mount(
-
-
-
- );
- wrapper.find(ColumnHeader).simulate('click');
- expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
- });
-
- it('does not try to scroll if there is no scrollable content', () => {
- const wrapper = mount( );
- wrapper.find(ColumnHeader).simulate('click');
- expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
- });
- });
-});
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
deleted file mode 100644
index 79a5a20ef..000000000
--- a/app/javascript/mastodon/features/ui/components/actions_modal.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import StatusContent from '../../../components/status_content';
-import Avatar from '../../../components/avatar';
-import RelativeTimestamp from '../../../components/relative_timestamp';
-import DisplayName from '../../../components/display_name';
-import IconButton from '../../../components/icon_button';
-import classNames from 'classnames';
-
-export default class ActionsModal extends ImmutablePureComponent {
-
- static propTypes = {
- status: ImmutablePropTypes.map,
- actions: PropTypes.array,
- onClick: PropTypes.func,
- };
-
- renderAction = (action, i) => {
- if (action === null) {
- return ;
- }
-
- const { icon = null, text, meta = null, active = false, href = '#' } = action;
-
- return (
-
-
- {icon && }
-
-
-
- );
- }
-
- render () {
- const status = this.props.status && (
-
- );
-
- return (
-
- {status}
-
-
- {this.props.actions.map(this.renderAction)}
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
deleted file mode 100644
index dfd1284e9..000000000
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Button from '../../../components/button';
-import StatusContent from '../../../../glitch/components/status/content';
-import Avatar from '../../../components/avatar';
-import RelativeTimestamp from '../../../components/relative_timestamp';
-import DisplayName from '../../../components/display_name';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-const messages = defineMessages({
- reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
-});
-
-@injectIntl
-export default class BoostModal extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map.isRequired,
- onReblog: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleReblog = () => {
- this.props.onReblog(this.props.status);
- this.props.onClose();
- }
-
- handleAccountClick = (e) => {
- if (e.button === 0) {
- e.preventDefault();
- this.props.onClose();
- this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
- }
- }
-
- setRef = (c) => {
- this.button = c;
- }
-
- render () {
- const { status, intl } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js
deleted file mode 100644
index fc88e0c70..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const emptyComponent = () => null;
-const noop = () => { };
-
-class Bundle extends React.Component {
-
- static propTypes = {
- fetchComponent: PropTypes.func.isRequired,
- loading: PropTypes.func,
- error: PropTypes.func,
- children: PropTypes.func.isRequired,
- renderDelay: PropTypes.number,
- onFetch: PropTypes.func,
- onFetchSuccess: PropTypes.func,
- onFetchFail: PropTypes.func,
- }
-
- static defaultProps = {
- loading: emptyComponent,
- error: emptyComponent,
- renderDelay: 0,
- onFetch: noop,
- onFetchSuccess: noop,
- onFetchFail: noop,
- }
-
- static cache = {}
-
- state = {
- mod: undefined,
- forceRender: false,
- }
-
- componentWillMount() {
- this.load(this.props);
- }
-
- componentWillReceiveProps(nextProps) {
- if (nextProps.fetchComponent !== this.props.fetchComponent) {
- this.load(nextProps);
- }
- }
-
- componentWillUnmount () {
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
- }
-
- load = (props) => {
- const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
-
- onFetch();
-
- if (Bundle.cache[fetchComponent.name]) {
- const mod = Bundle.cache[fetchComponent.name];
-
- this.setState({ mod: mod.default });
- onFetchSuccess();
- return Promise.resolve();
- }
-
- this.setState({ mod: undefined });
-
- if (renderDelay !== 0) {
- this.timestamp = new Date();
- this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
- }
-
- return fetchComponent()
- .then((mod) => {
- Bundle.cache[fetchComponent.name] = mod;
- this.setState({ mod: mod.default });
- onFetchSuccess();
- })
- .catch((error) => {
- this.setState({ mod: null });
- onFetchFail(error);
- });
- }
-
- render() {
- const { loading: Loading, error: Error, children, renderDelay } = this.props;
- const { mod, forceRender } = this.state;
- const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
-
- if (mod === undefined) {
- return (elapsed >= renderDelay || forceRender) ? : null;
- }
-
- if (mod === null) {
- return ;
- }
-
- return children(mod);
- }
-
-}
-
-export default Bundle;
diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.js b/app/javascript/mastodon/features/ui/components/bundle_column_error.js
deleted file mode 100644
index cd124746a..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle_column_error.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-
-import Column from './column';
-import ColumnHeader from './column_header';
-import ColumnBackButtonSlim from '../../../components/column_back_button_slim';
-import IconButton from '../../../components/icon_button';
-
-const messages = defineMessages({
- title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
- body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
- retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
-});
-
-class BundleColumnError extends React.Component {
-
- static propTypes = {
- onRetry: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- }
-
- handleRetry = () => {
- this.props.onRetry();
- }
-
- render () {
- const { intl: { formatMessage } } = this.props;
-
- return (
-
-
-
-
-
- {formatMessage(messages.body)}
-
-
- );
- }
-
-}
-
-export default injectIntl(BundleColumnError);
diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
deleted file mode 100644
index 928bfe1f7..000000000
--- a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-
-import IconButton from '../../../components/icon_button';
-
-const messages = defineMessages({
- error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
- retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
- close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
-});
-
-class BundleModalError extends React.Component {
-
- static propTypes = {
- onRetry: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- }
-
- handleRetry = () => {
- this.props.onRetry();
- }
-
- render () {
- const { onClose, intl: { formatMessage } } = this.props;
-
- // Keep the markup in sync with
- // (make sure they have the same dimensions)
- return (
-
-
-
- {formatMessage(messages.error)}
-
-
-
-
-
- {formatMessage(messages.close)}
-
-
-
-
- );
- }
-
-}
-
-export default injectIntl(BundleModalError);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
deleted file mode 100644
index c1700f86e..000000000
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import ColumnHeader from './column_header';
-import PropTypes from 'prop-types';
-import { debounce } from 'lodash';
-import { scrollTop } from '../../../scroll';
-import { isMobile } from '../../../is_mobile';
-
-export default class Column extends React.PureComponent {
-
- static propTypes = {
- heading: PropTypes.string,
- icon: PropTypes.string,
- children: PropTypes.node,
- active: PropTypes.bool,
- hideHeadingOnMobile: PropTypes.bool,
- name: PropTypes.string,
- };
-
- handleHeaderClick = () => {
- const scrollable = this.node.querySelector('.scrollable');
-
- if (!scrollable) {
- return;
- }
-
- this._interruptScrollAnimation = scrollTop(scrollable);
- }
-
- scrollTop () {
- const scrollable = this.node.querySelector('.scrollable');
-
- if (!scrollable) {
- return;
- }
-
- this._interruptScrollAnimation = scrollTop(scrollable);
- }
-
-
- handleScroll = debounce(() => {
- if (typeof this._interruptScrollAnimation !== 'undefined') {
- this._interruptScrollAnimation();
- }
- }, 200)
-
- setRef = (c) => {
- this.node = c;
- }
-
- render () {
- const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props;
-
- const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
-
- const columnHeaderId = showHeading && heading.replace(/ /g, '-');
- const header = showHeading && (
-
- );
- return (
-
- {header}
- {children}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
deleted file mode 100644
index af195ea9c..000000000
--- a/app/javascript/mastodon/features/ui/components/column_header.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class ColumnHeader extends React.PureComponent {
-
- static propTypes = {
- icon: PropTypes.string,
- type: PropTypes.string,
- active: PropTypes.bool,
- onClick: PropTypes.func,
- columnHeaderId: PropTypes.string,
- };
-
- handleClick = () => {
- this.props.onClick();
- }
-
- render () {
- const { type, active, columnHeaderId } = this.props;
-
- let icon = '';
-
- if (this.props.icon) {
- icon = ;
- }
-
- return (
-
- {icon}
- {type}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
deleted file mode 100644
index b845d1895..000000000
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Link } from 'react-router-dom';
-
-const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
- if (href) {
- return (
-
-
- {text}
-
- );
- } else if (to) {
- return (
-
-
- {text}
-
- );
- } else {
- return (
-
-
- {text}
-
- );
- }
-};
-
-ColumnLink.propTypes = {
- icon: PropTypes.string.isRequired,
- text: PropTypes.string.isRequired,
- to: PropTypes.string,
- onClick: PropTypes.func,
- href: PropTypes.string,
- method: PropTypes.string,
-};
-
-export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
deleted file mode 100644
index 9503a7a1a..000000000
--- a/app/javascript/mastodon/features/ui/components/column_loading.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class ColumnLoading extends ImmutablePureComponent {
-
- static propTypes = {
- title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
- icon: PropTypes.string,
- };
-
- static defaultProps = {
- title: '',
- icon: '',
- };
-
- render() {
- let { title, icon } = this.props;
- return (
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
deleted file mode 100644
index 8160c4aa3..000000000
--- a/app/javascript/mastodon/features/ui/components/column_subheading.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const ColumnSubheading = ({ text }) => {
- return (
-
- {text}
-
- );
-};
-
-ColumnSubheading.propTypes = {
- text: PropTypes.string.isRequired,
-};
-
-export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
deleted file mode 100644
index ee1064229..000000000
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl } from 'react-intl';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import ReactSwipeableViews from 'react-swipeable-views';
-import { links, getIndex, getLink } from './tabs_bar';
-
-import BundleContainer from '../containers/bundle_container';
-import ColumnLoading from './column_loading';
-import DrawerLoading from './drawer_loading';
-import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components';
-
-import detectPassiveEvents from 'detect-passive-events';
-import { scrollRight } from '../../../scroll';
-
-const componentMap = {
- 'COMPOSE': Compose,
- 'HOME': HomeTimeline,
- 'NOTIFICATIONS': Notifications,
- 'PUBLIC': PublicTimeline,
- 'COMMUNITY': CommunityTimeline,
- 'HASHTAG': HashtagTimeline,
- 'DIRECT': DirectTimeline,
- 'FAVOURITES': FavouritedStatuses,
-};
-
-@component => injectIntl(component, { withRef: true })
-export default class ColumnsArea extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- columns: ImmutablePropTypes.list.isRequired,
- singleColumn: PropTypes.bool,
- children: PropTypes.node,
- };
-
- state = {
- shouldAnimate: false,
- }
-
- componentWillReceiveProps() {
- this.setState({ shouldAnimate: false });
- }
-
- componentDidMount() {
- if (!this.props.singleColumn) {
- this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
- }
- this.lastIndex = getIndex(this.context.router.history.location.pathname);
- this.setState({ shouldAnimate: true });
- }
-
- componentWillUpdate(nextProps) {
- if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
- this.node.removeEventListener('wheel', this.handleWheel);
- }
- }
-
- componentDidUpdate(prevProps) {
- if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
- this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
- }
- this.lastIndex = getIndex(this.context.router.history.location.pathname);
- this.setState({ shouldAnimate: true });
- }
-
- componentWillUnmount () {
- if (!this.props.singleColumn) {
- this.node.removeEventListener('wheel', this.handleWheel);
- }
- }
-
- handleChildrenContentChange() {
- if (!this.props.singleColumn) {
- this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
- }
- }
-
- handleSwipe = (index) => {
- this.pendingIndex = index;
-
- const nextLinkTranslationId = links[index].props['data-preview-title-id'];
- const currentLinkSelector = '.tabs-bar__link.active';
- const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
-
- // HACK: Remove the active class from the current link and set it to the next one
- // React-router does this for us, but too late, feeling laggy.
- document.querySelector(currentLinkSelector).classList.remove('active');
- document.querySelector(nextLinkSelector).classList.add('active');
- }
-
- handleAnimationEnd = () => {
- if (typeof this.pendingIndex === 'number') {
- this.context.router.history.push(getLink(this.pendingIndex));
- this.pendingIndex = null;
- }
- }
-
- handleWheel = () => {
- if (typeof this._interruptScrollAnimation !== 'function') {
- return;
- }
-
- this._interruptScrollAnimation();
- }
-
- setRef = (node) => {
- this.node = node;
- }
-
- renderView = (link, index) => {
- const columnIndex = getIndex(this.context.router.history.location.pathname);
- const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
- const icon = link.props['data-preview-icon'];
-
- const view = (index === columnIndex) ?
- React.cloneElement(this.props.children) :
- ;
-
- return (
-
- {view}
-
- );
- }
-
- renderLoading = columnId => () => {
- return columnId === 'COMPOSE' ? : ;
- }
-
- renderError = (props) => {
- return ;
- }
-
- render () {
- const { columns, children, singleColumn } = this.props;
- const { shouldAnimate } = this.state;
-
- const columnIndex = getIndex(this.context.router.history.location.pathname);
- this.pendingIndex = null;
-
- if (singleColumn) {
- return columnIndex !== -1 ? (
-
- {links.map(this.renderView)}
-
- ) : {children}
;
- }
-
- return (
-
- {columns.map(column => {
- const params = column.get('params', null) === null ? null : column.get('params').toJS();
-
- return (
-
- {SpecificComponent => }
-
- );
- })}
-
- {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
deleted file mode 100644
index 86588c46a..000000000
--- a/app/javascript/mastodon/features/ui/components/confirmation_modal.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import Button from '../../../components/button';
-
-@injectIntl
-export default class ConfirmationModal extends React.PureComponent {
-
- static propTypes = {
- message: PropTypes.node.isRequired,
- confirm: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- onConfirm: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleClick = () => {
- this.props.onClose();
- this.props.onConfirm();
- }
-
- handleCancel = () => {
- this.props.onClose();
- }
-
- setRef = (c) => {
- this.button = c;
- }
-
- render () {
- const { message, confirm } = this.props;
-
- return (
-
-
- {message}
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/doodle_modal.js b/app/javascript/mastodon/features/ui/components/doodle_modal.js
deleted file mode 100644
index 4efc9d2e6..000000000
--- a/app/javascript/mastodon/features/ui/components/doodle_modal.js
+++ /dev/null
@@ -1,614 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Button from '../../../components/button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Atrament from 'atrament'; // the doodling library
-import { connect } from 'react-redux';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { doodleSet, uploadCompose } from '../../../actions/compose';
-import IconButton from '../../../components/icon_button';
-import { debounce, mapValues } from 'lodash';
-import classNames from 'classnames';
-
-// palette nicked from MyPaint, CC0
-const palette = [
- ['rgb( 0, 0, 0)', 'Black'],
- ['rgb( 38, 38, 38)', 'Gray 15'],
- ['rgb( 77, 77, 77)', 'Grey 30'],
- ['rgb(128, 128, 128)', 'Grey 50'],
- ['rgb(171, 171, 171)', 'Grey 67'],
- ['rgb(217, 217, 217)', 'Grey 85'],
- ['rgb(255, 255, 255)', 'White'],
- ['rgb(128, 0, 0)', 'Maroon'],
- ['rgb(209, 0, 0)', 'English-red'],
- ['rgb(255, 54, 34)', 'Tomato'],
- ['rgb(252, 60, 3)', 'Orange-red'],
- ['rgb(255, 140, 105)', 'Salmon'],
- ['rgb(252, 232, 32)', 'Cadium-yellow'],
- ['rgb(243, 253, 37)', 'Lemon yellow'],
- ['rgb(121, 5, 35)', 'Dark crimson'],
- ['rgb(169, 32, 62)', 'Deep carmine'],
- ['rgb(255, 140, 0)', 'Orange'],
- ['rgb(255, 168, 18)', 'Dark tangerine'],
- ['rgb(217, 144, 88)', 'Persian orange'],
- ['rgb(194, 178, 128)', 'Sand'],
- ['rgb(255, 229, 180)', 'Peach'],
- ['rgb(100, 54, 46)', 'Bole'],
- ['rgb(108, 41, 52)', 'Dark cordovan'],
- ['rgb(163, 65, 44)', 'Chestnut'],
- ['rgb(228, 136, 100)', 'Dark salmon'],
- ['rgb(255, 195, 143)', 'Apricot'],
- ['rgb(255, 219, 188)', 'Unbleached silk'],
- ['rgb(242, 227, 198)', 'Straw'],
- ['rgb( 53, 19, 13)', 'Bistre'],
- ['rgb( 84, 42, 14)', 'Dark chocolate'],
- ['rgb(102, 51, 43)', 'Burnt sienna'],
- ['rgb(184, 66, 0)', 'Sienna'],
- ['rgb(216, 153, 12)', 'Yellow ochre'],
- ['rgb(210, 180, 140)', 'Tan'],
- ['rgb(232, 204, 144)', 'Dark wheat'],
- ['rgb( 0, 49, 83)', 'Prussian blue'],
- ['rgb( 48, 69, 119)', 'Dark grey blue'],
- ['rgb( 0, 71, 171)', 'Cobalt blue'],
- ['rgb( 31, 117, 254)', 'Blue'],
- ['rgb(120, 180, 255)', 'Bright french blue'],
- ['rgb(171, 200, 255)', 'Bright steel blue'],
- ['rgb(208, 231, 255)', 'Ice blue'],
- ['rgb( 30, 51, 58)', 'Medium jungle green'],
- ['rgb( 47, 79, 79)', 'Dark slate grey'],
- ['rgb( 74, 104, 93)', 'Dark grullo green'],
- ['rgb( 0, 128, 128)', 'Teal'],
- ['rgb( 67, 170, 176)', 'Turquoise'],
- ['rgb(109, 174, 199)', 'Cerulean frost'],
- ['rgb(173, 217, 186)', 'Tiffany green'],
- ['rgb( 22, 34, 29)', 'Gray-asparagus'],
- ['rgb( 36, 48, 45)', 'Medium dark teal'],
- ['rgb( 74, 104, 93)', 'Xanadu'],
- ['rgb(119, 198, 121)', 'Mint'],
- ['rgb(175, 205, 182)', 'Timberwolf'],
- ['rgb(185, 245, 246)', 'Celeste'],
- ['rgb(193, 255, 234)', 'Aquamarine'],
- ['rgb( 29, 52, 35)', 'Cal Poly Pomona'],
- ['rgb( 1, 68, 33)', 'Forest green'],
- ['rgb( 42, 128, 0)', 'Napier green'],
- ['rgb(128, 128, 0)', 'Olive'],
- ['rgb( 65, 156, 105)', 'Sea green'],
- ['rgb(189, 246, 29)', 'Green-yellow'],
- ['rgb(231, 244, 134)', 'Bright chartreuse'],
- ['rgb(138, 23, 137)', 'Purple'],
- ['rgb( 78, 39, 138)', 'Violet'],
- ['rgb(193, 75, 110)', 'Dark thulian pink'],
- ['rgb(222, 49, 99)', 'Cerise'],
- ['rgb(255, 20, 147)', 'Deep pink'],
- ['rgb(255, 102, 204)', 'Rose pink'],
- ['rgb(255, 203, 219)', 'Pink'],
- ['rgb(255, 255, 255)', 'White'],
- ['rgb(229, 17, 1)', 'RGB Red'],
- ['rgb( 0, 255, 0)', 'RGB Green'],
- ['rgb( 0, 0, 255)', 'RGB Blue'],
- ['rgb( 0, 255, 255)', 'CMYK Cyan'],
- ['rgb(255, 0, 255)', 'CMYK Magenta'],
- ['rgb(255, 255, 0)', 'CMYK Yellow'],
-];
-
-// re-arrange to the right order for display
-let palReordered = [];
-for (let row = 0; row < 7; row++) {
- for (let col = 0; col < 11; col++) {
- palReordered.push(palette[col * 7 + row]);
- }
- palReordered.push(null); // null indicates a
-}
-
-// Utility for converting base64 image to binary for upload
-// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
-function dataURLtoFile(dataurl, filename) {
- let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
- bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
- while(n--){
- u8arr[n] = bstr.charCodeAt(n);
- }
- return new File([u8arr], filename, { type: mime });
-}
-
-const DOODLE_SIZES = {
- normal: [500, 500, 'Square 500'],
- tootbanner: [702, 330, 'Tootbanner'],
- s640x480: [640, 480, '640×480 - 480p'],
- s800x600: [800, 600, '800×600 - SVGA'],
- s720x480: [720, 405, '720x405 - 16:9'],
-};
-
-
-const mapStateToProps = state => ({
- options: state.getIn(['compose', 'doodle']),
-});
-
-const mapDispatchToProps = dispatch => ({
- /** Set options in the redux store */
- setOpt: (opts) => dispatch(doodleSet(opts)),
- /** Submit doodle for upload */
- submit: (file) => dispatch(uploadCompose([file])),
-});
-
-/**
- * Doodling dialog with drawing canvas
- *
- * Keyboard shortcuts:
- * - Delete: Clear screen, fill with background color
- * - Backspace, Ctrl+Z: Undo one step
- * - Ctrl held while drawing: Use background color
- * - Shift held while clicking screen: Use fill tool
- *
- * Palette:
- * - Left mouse button: pick foreground
- * - Ctrl + left mouse button: pick background
- * - Right mouse button: pick background
- */
-@connect(mapStateToProps, mapDispatchToProps)
-export default class DoodleModal extends ImmutablePureComponent {
-
- static propTypes = {
- options: ImmutablePropTypes.map,
- onClose: PropTypes.func.isRequired,
- setOpt: PropTypes.func.isRequired,
- submit: PropTypes.func.isRequired,
- };
-
- //region Option getters/setters
-
- /** Foreground color */
- get fg () {
- return this.props.options.get('fg');
- }
- set fg (value) {
- this.props.setOpt({ fg: value });
- }
-
- /** Background color */
- get bg () {
- return this.props.options.get('bg');
- }
- set bg (value) {
- this.props.setOpt({ bg: value });
- }
-
- /** Swap Fg and Bg for drawing */
- get swapped () {
- return this.props.options.get('swapped');
- }
- set swapped (value) {
- this.props.setOpt({ swapped: value });
- }
-
- /** Mode - 'draw' or 'fill' */
- get mode () {
- return this.props.options.get('mode');
- }
- set mode (value) {
- this.props.setOpt({ mode: value });
- }
-
- /** Base line weight */
- get weight () {
- return this.props.options.get('weight');
- }
- set weight (value) {
- this.props.setOpt({ weight: value });
- }
-
- /** Drawing opacity */
- get opacity () {
- return this.props.options.get('opacity');
- }
- set opacity (value) {
- this.props.setOpt({ opacity: value });
- }
-
- /** Adaptive stroke - change width with speed */
- get adaptiveStroke () {
- return this.props.options.get('adaptiveStroke');
- }
- set adaptiveStroke (value) {
- this.props.setOpt({ adaptiveStroke: value });
- }
-
- /** Smoothing (for mouse drawing) */
- get smoothing () {
- return this.props.options.get('smoothing');
- }
- set smoothing (value) {
- this.props.setOpt({ smoothing: value });
- }
-
- /** Size preset */
- get size () {
- return this.props.options.get('size');
- }
- set size (value) {
- this.props.setOpt({ size: value });
- }
-
- //endregion
-
- /** Key up handler */
- handleKeyUp = (e) => {
- if (e.target.nodeName === 'INPUT') return;
-
- if (e.key === 'Delete') {
- e.preventDefault();
- this.handleClearBtn();
- return;
- }
-
- if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
- e.preventDefault();
- this.undo();
- }
-
- if (e.key === 'Control' || e.key === 'Meta') {
- this.controlHeld = false;
- this.swapped = false;
- }
-
- if (e.key === 'Shift') {
- this.shiftHeld = false;
- this.mode = 'draw';
- }
- };
-
- /** Key down handler */
- handleKeyDown = (e) => {
- if (e.key === 'Control' || e.key === 'Meta') {
- this.controlHeld = true;
- this.swapped = true;
- }
-
- if (e.key === 'Shift') {
- this.shiftHeld = true;
- this.mode = 'fill';
- }
- };
-
- /**
- * Component installed in the DOM, do some initial set-up
- */
- componentDidMount () {
- this.controlHeld = false;
- this.shiftHeld = false;
- this.swapped = false;
- window.addEventListener('keyup', this.handleKeyUp, false);
- window.addEventListener('keydown', this.handleKeyDown, false);
- };
-
- /**
- * Tear component down
- */
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp, false);
- window.removeEventListener('keydown', this.handleKeyDown, false);
- if (this.sketcher) this.sketcher.destroy();
- }
-
- /**
- * Set reference to the canvas element.
- * This is called during component init
- *
- * @param elem - canvas element
- */
- setCanvasRef = (elem) => {
- this.canvas = elem;
- if (elem) {
- elem.addEventListener('dirty', () => {
- this.saveUndo();
- this.sketcher._dirty = false;
- });
-
- elem.addEventListener('click', () => {
- // sketcher bug - does not fire dirty on fill
- if (this.mode === 'fill') {
- this.saveUndo();
- }
- });
-
- // prevent context menu
- elem.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- });
-
- elem.addEventListener('mousedown', (e) => {
- if (e.button === 2) {
- this.swapped = true;
- }
- });
-
- elem.addEventListener('mouseup', (e) => {
- if (e.button === 2) {
- this.swapped = this.controlHeld;
- }
- });
-
- this.initSketcher(elem);
- this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
- }
- };
-
- /**
- * Set up the sketcher instance
- *
- * @param canvas - canvas element. Null if we're just resizing
- */
- initSketcher (canvas = null) {
- const sizepreset = DOODLE_SIZES[this.size];
-
- if (this.sketcher) this.sketcher.destroy();
- this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
-
- if (canvas) {
- this.ctx = this.sketcher.context;
- this.updateSketcherSettings();
- }
-
- this.clearScreen();
- }
-
- /**
- * Done button handler
- */
- onDoneButton = () => {
- const dataUrl = this.sketcher.toImage();
- const file = dataURLtoFile(dataUrl, 'doodle.png');
- this.props.submit(file);
- this.props.onClose(); // close dialog
- };
-
- /**
- * Cancel button handler
- */
- onCancelButton = () => {
- if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
- return;
- }
-
- this.props.onClose(); // close dialog
- };
-
- /**
- * Update sketcher options based on state
- */
- updateSketcherSettings () {
- if (!this.sketcher) return;
-
- if (this.oldSize !== this.size) this.initSketcher();
-
- this.sketcher.color = (this.swapped ? this.bg : this.fg);
- this.sketcher.opacity = this.opacity;
- this.sketcher.weight = this.weight;
- this.sketcher.mode = this.mode;
- this.sketcher.smoothing = this.smoothing;
- this.sketcher.adaptiveStroke = this.adaptiveStroke;
-
- this.oldSize = this.size;
- }
-
- /**
- * Fill screen with background color
- */
- clearScreen = () => {
- this.ctx.fillStyle = this.bg;
- this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
- this.undos = [];
-
- this.doSaveUndo();
- };
-
- /**
- * Undo one step
- */
- undo = () => {
- if (this.undos.length > 1) {
- this.undos.pop();
- const buf = this.undos.pop();
-
- this.sketcher.clear();
- this.ctx.putImageData(buf, 0, 0);
- this.doSaveUndo();
- }
- };
-
- /**
- * Save canvas content into the undo buffer immediately
- */
- doSaveUndo = () => {
- this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
- };
-
- /**
- * Called on each canvas change.
- * Saves canvas content to the undo buffer after some period of inactivity.
- */
- saveUndo = debounce(() => {
- this.doSaveUndo();
- }, 100);
-
- /**
- * Palette left click.
- * Selects Fg color (or Bg, if Control/Meta is held)
- *
- * @param e - event
- */
- onPaletteClick = (e) => {
- const c = e.target.dataset.color;
-
- if (this.controlHeld) {
- this.bg = c;
- } else {
- this.fg = c;
- }
-
- e.target.blur();
- e.preventDefault();
- };
-
- /**
- * Palette right click.
- * Selects Bg color
- *
- * @param e - event
- */
- onPaletteRClick = (e) => {
- this.bg = e.target.dataset.color;
- e.target.blur();
- e.preventDefault();
- };
-
- /**
- * Handle click on the Draw mode button
- *
- * @param e - event
- */
- setModeDraw = (e) => {
- this.mode = 'draw';
- e.target.blur();
- };
-
- /**
- * Handle click on the Fill mode button
- *
- * @param e - event
- */
- setModeFill = (e) => {
- this.mode = 'fill';
- e.target.blur();
- };
-
- /**
- * Handle click on Smooth checkbox
- *
- * @param e - event
- */
- tglSmooth = (e) => {
- this.smoothing = !this.smoothing;
- e.target.blur();
- };
-
- /**
- * Handle click on Adaptive checkbox
- *
- * @param e - event
- */
- tglAdaptive = (e) => {
- this.adaptiveStroke = !this.adaptiveStroke;
- e.target.blur();
- };
-
- /**
- * Handle change of the Weight input field
- *
- * @param e - event
- */
- setWeight = (e) => {
- this.weight = +e.target.value || 1;
- };
-
- /**
- * Set size - clalback from the select box
- *
- * @param e - event
- */
- changeSize = (e) => {
- let newSize = e.target.value;
- if (newSize === this.oldSize) return;
-
- if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
- return;
- }
-
- this.size = newSize;
- };
-
- handleClearBtn = () => {
- if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
- return;
- }
-
- this.clearScreen();
- };
-
- /**
- * Render the component
- */
- render () {
- this.updateSketcherSettings();
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- Smoothing
-
-
-
-
-
- Adaptive
-
-
-
-
-
- Weight
-
-
-
-
-
-
- { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
- {val[2]}
- )) }
-
-
-
-
-
-
-
-
-
-
- {
- palReordered.map((c, i) =>
- c === null ?
- :
-
- )
- }
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.js b/app/javascript/mastodon/features/ui/components/drawer_loading.js
deleted file mode 100644
index 08b0d2347..000000000
--- a/app/javascript/mastodon/features/ui/components/drawer_loading.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-
-const DrawerLoading = () => (
-
-);
-
-export default DrawerLoading;
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
deleted file mode 100644
index 1afffb51b..000000000
--- a/app/javascript/mastodon/features/ui/components/embed_modal.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { FormattedMessage, injectIntl } from 'react-intl';
-import axios from 'axios';
-
-@injectIntl
-export default class EmbedModal extends ImmutablePureComponent {
-
- static propTypes = {
- url: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- }
-
- state = {
- loading: false,
- oembed: null,
- };
-
- componentDidMount () {
- const { url } = this.props;
-
- this.setState({ loading: true });
-
- axios.post('/api/web/embed', { url }).then(res => {
- this.setState({ loading: false, oembed: res.data });
-
- const iframeDocument = this.iframe.contentWindow.document;
-
- iframeDocument.open();
- iframeDocument.write(res.data.html);
- iframeDocument.close();
-
- iframeDocument.body.style.margin = 0;
- this.iframe.width = iframeDocument.body.scrollWidth;
- this.iframe.height = iframeDocument.body.scrollHeight;
- });
- }
-
- setIframeRef = c => {
- this.iframe = c;
- }
-
- handleTextareaClick = (e) => {
- e.target.select();
- }
-
- render () {
- const { oembed } = this.state;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js
deleted file mode 100644
index aad594380..000000000
--- a/app/javascript/mastodon/features/ui/components/image_loader.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-
-export default class ImageLoader extends React.PureComponent {
-
- static propTypes = {
- alt: PropTypes.string,
- src: PropTypes.string.isRequired,
- previewSrc: PropTypes.string.isRequired,
- width: PropTypes.number,
- height: PropTypes.number,
- }
-
- static defaultProps = {
- alt: '',
- width: null,
- height: null,
- };
-
- state = {
- loading: true,
- error: false,
- }
-
- removers = [];
-
- get canvasContext() {
- if (!this.canvas) {
- return null;
- }
- this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
- return this._canvasContext;
- }
-
- componentDidMount () {
- this.loadImage(this.props);
- }
-
- componentWillReceiveProps (nextProps) {
- if (this.props.src !== nextProps.src) {
- this.loadImage(nextProps);
- }
- }
-
- loadImage (props) {
- this.removeEventListeners();
- this.setState({ loading: true, error: false });
- Promise.all([
- this.loadPreviewCanvas(props),
- this.hasSize() && this.loadOriginalImage(props),
- ].filter(Boolean))
- .then(() => {
- this.setState({ loading: false, error: false });
- this.clearPreviewCanvas();
- })
- .catch(() => this.setState({ loading: false, error: true }));
- }
-
- loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
- const image = new Image();
- const removeEventListeners = () => {
- image.removeEventListener('error', handleError);
- image.removeEventListener('load', handleLoad);
- };
- const handleError = () => {
- removeEventListeners();
- reject();
- };
- const handleLoad = () => {
- removeEventListeners();
- this.canvasContext.drawImage(image, 0, 0, width, height);
- resolve();
- };
- image.addEventListener('error', handleError);
- image.addEventListener('load', handleLoad);
- image.src = previewSrc;
- this.removers.push(removeEventListeners);
- })
-
- clearPreviewCanvas () {
- const { width, height } = this.canvas;
- this.canvasContext.clearRect(0, 0, width, height);
- }
-
- loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
- const image = new Image();
- const removeEventListeners = () => {
- image.removeEventListener('error', handleError);
- image.removeEventListener('load', handleLoad);
- };
- const handleError = () => {
- removeEventListeners();
- reject();
- };
- const handleLoad = () => {
- removeEventListeners();
- resolve();
- };
- image.addEventListener('error', handleError);
- image.addEventListener('load', handleLoad);
- image.src = src;
- this.removers.push(removeEventListeners);
- });
-
- removeEventListeners () {
- this.removers.forEach(listeners => listeners());
- this.removers = [];
- }
-
- hasSize () {
- const { width, height } = this.props;
- return typeof width === 'number' && typeof height === 'number';
- }
-
- setCanvasRef = c => {
- this.canvas = c;
- }
-
- render () {
- const { alt, src, width, height } = this.props;
- const { loading } = this.state;
-
- const className = classNames('image-loader', {
- 'image-loader--loading': loading,
- 'image-loader--amorphous': !this.hasSize(),
- });
-
- return (
-
-
-
- {!loading && (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
deleted file mode 100644
index f41a83089..000000000
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import ReactSwipeableViews from 'react-swipeable-views';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from '../../../components/icon_button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImageLoader from './image_loader';
-
-const messages = defineMessages({
- close: { id: 'lightbox.close', defaultMessage: 'Close' },
- previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
- next: { id: 'lightbox.next', defaultMessage: 'Next' },
-});
-
-@injectIntl
-export default class MediaModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.list.isRequired,
- index: PropTypes.number.isRequired,
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- index: null,
- };
-
- handleSwipe = (index) => {
- this.setState({ index: index % this.props.media.size });
- }
-
- handleNextClick = () => {
- this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
- }
-
- handlePrevClick = () => {
- this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
- }
-
- handleChangeIndex = (e) => {
- const index = Number(e.currentTarget.getAttribute('data-index'));
- this.setState({ index: index % this.props.media.size });
- }
-
- handleKeyUp = (e) => {
- switch(e.key) {
- case 'ArrowLeft':
- this.handlePrevClick();
- break;
- case 'ArrowRight':
- this.handleNextClick();
- break;
- }
- }
-
- componentDidMount () {
- window.addEventListener('keyup', this.handleKeyUp, false);
- }
-
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp);
- }
-
- getIndex () {
- return this.state.index !== null ? this.state.index : this.props.index;
- }
-
- render () {
- const { media, intl, onClose } = this.props;
-
- const index = this.getIndex();
- let pagination = [];
-
- const leftNav = media.size > 1 && ;
- const rightNav = media.size > 1 && ;
-
- if (media.size > 1) {
- pagination = media.map((item, i) => {
- const classes = ['media-modal__button'];
- if (i === index) {
- classes.push('media-modal__button--active');
- }
- return ({i + 1} );
- });
- }
-
- const content = media.map((image) => {
- const width = image.getIn(['meta', 'original', 'width']) || null;
- const height = image.getIn(['meta', 'original', 'height']) || null;
-
- if (image.get('type') === 'image') {
- return ;
- } else if (image.get('type') === 'gifv') {
- return ;
- }
-
- return null;
- }).toArray();
-
- const containerStyle = {
- alignItems: 'center', // center vertically
- };
-
- return (
-
- {leftNav}
-
-
-
-
- {content}
-
-
-
-
- {rightNav}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.js b/app/javascript/mastodon/features/ui/components/modal_loading.js
deleted file mode 100644
index f403ca4c9..000000000
--- a/app/javascript/mastodon/features/ui/components/modal_loading.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-
-import LoadingIndicator from '../../../components/loading_indicator';
-
-// Keep the markup in sync with
-// (make sure they have the same dimensions)
-const ModalLoading = () => (
-
-);
-
-export default ModalLoading;
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
deleted file mode 100644
index 3e56fbf8e..000000000
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import BundleContainer from '../containers/bundle_container';
-import BundleModalError from './bundle_modal_error';
-import ModalLoading from './modal_loading';
-import ActionsModal from './actions_modal';
-import MediaModal from './media_modal';
-import VideoModal from './video_modal';
-import BoostModal from './boost_modal';
-import DoodleModal from './doodle_modal';
-import ConfirmationModal from './confirmation_modal';
-import {
- OnboardingModal,
- MuteModal,
- ReportModal,
- SettingsModal,
- EmbedModal,
-} from '../../../features/ui/util/async-components';
-
-const MODAL_COMPONENTS = {
- 'MEDIA': () => Promise.resolve({ default: MediaModal }),
- 'ONBOARDING': OnboardingModal,
- 'VIDEO': () => Promise.resolve({ default: VideoModal }),
- 'BOOST': () => Promise.resolve({ default: BoostModal }),
- 'DOODLE': () => Promise.resolve({ default: DoodleModal }),
- 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
- 'MUTE': MuteModal,
- 'REPORT': ReportModal,
- 'SETTINGS': SettingsModal,
- 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
- 'EMBED': EmbedModal,
-};
-
-export default class ModalRoot extends React.PureComponent {
-
- static propTypes = {
- type: PropTypes.string,
- props: PropTypes.object,
- onClose: PropTypes.func.isRequired,
- };
-
- state = {
- revealed: false,
- };
-
- handleKeyUp = (e) => {
- if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
- && !!this.props.type && !this.props.props.noEsc) {
- this.props.onClose();
- }
- }
-
- componentDidMount () {
- window.addEventListener('keyup', this.handleKeyUp, false);
- }
-
- componentWillReceiveProps (nextProps) {
- if (!!nextProps.type && !this.props.type) {
- this.activeElement = document.activeElement;
-
- this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
- } else if (!nextProps.type) {
- this.setState({ revealed: false });
- }
- }
-
- componentDidUpdate (prevProps) {
- if (!this.props.type && !!prevProps.type) {
- this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
- this.activeElement.focus();
- this.activeElement = null;
- }
- if (this.props.type) {
- requestAnimationFrame(() => {
- this.setState({ revealed: true });
- });
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp);
- }
-
- getSiblings = () => {
- return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
- }
-
- setRef = ref => {
- this.node = ref;
- }
-
- renderLoading = modalId => () => {
- return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
- }
-
- renderError = (props) => {
- const { onClose } = this.props;
-
- return ;
- }
-
- render () {
- const { type, props, onClose } = this.props;
- const { revealed } = this.state;
- const visible = !!type;
-
- if (!visible) {
- return (
-
- );
- }
-
- return (
-
-
-
-
- {
- visible ?
- (
- {(SpecificComponent) => }
- ) :
- null
- }
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
deleted file mode 100644
index 73e48cf09..000000000
--- a/app/javascript/mastodon/features/ui/components/mute_modal.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-import Button from '../../../components/button';
-import { closeModal } from '../../../actions/modal';
-import { muteAccount } from '../../../actions/accounts';
-import { toggleHideNotifications } from '../../../actions/mutes';
-
-
-const mapStateToProps = state => {
- return {
- isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
- account: state.getIn(['mutes', 'new', 'account']),
- notifications: state.getIn(['mutes', 'new', 'notifications']),
- };
-};
-
-const mapDispatchToProps = dispatch => {
- return {
- onConfirm(account, notifications) {
- dispatch(muteAccount(account.get('id'), notifications));
- },
-
- onClose() {
- dispatch(closeModal());
- },
-
- onToggleNotifications() {
- dispatch(toggleHideNotifications());
- },
- };
-};
-
-@connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
-export default class MuteModal extends React.PureComponent {
-
- static propTypes = {
- isSubmitting: PropTypes.bool.isRequired,
- account: PropTypes.object.isRequired,
- notifications: PropTypes.bool.isRequired,
- onClose: PropTypes.func.isRequired,
- onConfirm: PropTypes.func.isRequired,
- onToggleNotifications: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount() {
- this.button.focus();
- }
-
- handleClick = () => {
- this.props.onClose();
- this.props.onConfirm(this.props.account, this.props.notifications);
- }
-
- handleCancel = () => {
- this.props.onClose();
- }
-
- setRef = (c) => {
- this.button = c;
- }
-
- toggleNotifications = () => {
- this.props.onToggleNotifications();
- }
-
- render () {
- const { account, notifications } = this.props;
-
- return (
-
-
-
- @{account.get('acct')} }}
- />
-
-
-
-
- {' '}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
deleted file mode 100644
index 1f9f0cd03..000000000
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ /dev/null
@@ -1,323 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ReactSwipeableViews from 'react-swipeable-views';
-import classNames from 'classnames';
-import Permalink from '../../../components/permalink';
-import ComposeForm from '../../compose/components/compose_form';
-import Search from '../../compose/components/search';
-import NavigationBar from '../../compose/components/navigation_bar';
-import ColumnHeader from './column_header';
-import {
- List as ImmutableList,
- Map as ImmutableMap,
-} from 'immutable';
-import { me } from '../../../initial_state';
-
-const noop = () => { };
-
-const messages = defineMessages({
- home_title: { id: 'column.home', defaultMessage: 'Home' },
- notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
- local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
- federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' },
-});
-
-const PageOne = ({ acct, domain }) => (
-
-
-
-
-
-
-
@{acct}@{domain} }} />
-
-
-);
-
-PageOne.propTypes = {
- acct: PropTypes.string.isRequired,
- domain: PropTypes.string.isRequired,
-};
-
-const PageTwo = ({ myAccount }) => (
-
-);
-
-PageTwo.propTypes = {
- myAccount: ImmutablePropTypes.map.isRequired,
-};
-
-const PageThree = ({ myAccount }) => (
-
-
-
-
#illustration, introductions: #introductions }} />
-
-
-);
-
-PageThree.propTypes = {
- myAccount: ImmutablePropTypes.map.isRequired,
-};
-
-const PageFour = ({ domain, intl }) => (
-
-);
-
-PageFour.propTypes = {
- domain: PropTypes.string.isRequired,
- intl: PropTypes.object.isRequired,
-};
-
-const PageSix = ({ admin, domain }) => {
- let adminSection = '';
-
- if (admin) {
- adminSection = (
-
- @{admin.get('acct')} }} />
-
- }} />
-
- );
- }
-
- return (
-
-
- {adminSection}
-
fork, Mastodon: Mastodon , github: GitHub }} />
-
}} />
-
-
- );
-};
-
-PageSix.propTypes = {
- admin: ImmutablePropTypes.map,
- domain: PropTypes.string.isRequired,
-};
-
-const mapStateToProps = state => ({
- myAccount: state.getIn(['accounts', me]),
- admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
- domain: state.getIn(['meta', 'domain']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class OnboardingModal extends React.PureComponent {
-
- static propTypes = {
- onClose: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- myAccount: ImmutablePropTypes.map.isRequired,
- domain: PropTypes.string.isRequired,
- admin: ImmutablePropTypes.map,
- };
-
- state = {
- currentIndex: 0,
- };
-
- componentWillMount() {
- const { myAccount, admin, domain, intl } = this.props;
- this.pages = [
- ,
- ,
- ,
- ,
- ,
- ];
- };
-
- componentDidMount() {
- window.addEventListener('keyup', this.handleKeyUp);
- }
-
- componentWillUnmount() {
- window.addEventListener('keyup', this.handleKeyUp);
- }
-
- handleSkip = (e) => {
- e.preventDefault();
- this.props.onClose();
- }
-
- handleDot = (e) => {
- const i = Number(e.currentTarget.getAttribute('data-index'));
- e.preventDefault();
- this.setState({ currentIndex: i });
- }
-
- handlePrev = () => {
- this.setState(({ currentIndex }) => ({
- currentIndex: Math.max(0, currentIndex - 1),
- }));
- }
-
- handleNext = () => {
- const { pages } = this;
- this.setState(({ currentIndex }) => ({
- currentIndex: Math.min(currentIndex + 1, pages.length - 1),
- }));
- }
-
- handleSwipe = (index) => {
- this.setState({ currentIndex: index });
- }
-
- handleKeyUp = ({ key }) => {
- switch (key) {
- case 'ArrowLeft':
- this.handlePrev();
- break;
- case 'ArrowRight':
- this.handleNext();
- break;
- }
- }
-
- handleClose = () => {
- this.props.onClose();
- }
-
- render () {
- const { pages } = this;
- const { currentIndex } = this.state;
- const hasMore = currentIndex < pages.length - 1;
-
- const nextOrDoneBtn = hasMore ? (
-
-
-
- ) : (
-
-
-
- );
-
- return (
-
-
- {pages.map((page, i) => {
- const className = classNames('onboarding-modal__page__wrapper', {
- 'onboarding-modal__page__wrapper--active': i === currentIndex,
- });
- return (
- {page}
- );
- })}
-
-
-
-
-
-
-
-
-
-
- {pages.map((_, i) => {
- const className = classNames('onboarding-modal__dot', {
- active: i === currentIndex,
- });
- return (
-
- );
- })}
-
-
-
- {nextOrDoneBtn}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
deleted file mode 100644
index b5dfa422e..000000000
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { changeReportComment, submitReport } from '../../../actions/reports';
-import { refreshAccountTimeline } from '../../../actions/timelines';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { makeGetAccount } from '../../../selectors';
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import StatusCheckBox from '../../report/containers/status_check_box_container';
-import { OrderedSet } from 'immutable';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Button from '../../../components/button';
-
-const messages = defineMessages({
- placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
- submit: { id: 'report.submit', defaultMessage: 'Submit' },
-});
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = state => {
- const accountId = state.getIn(['reports', 'new', 'account_id']);
-
- return {
- isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
- account: getAccount(state, accountId),
- comment: state.getIn(['reports', 'new', 'comment']),
- statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
- };
- };
-
- return mapStateToProps;
-};
-
-@connect(makeMapStateToProps)
-@injectIntl
-export default class ReportModal extends ImmutablePureComponent {
-
- static propTypes = {
- isSubmitting: PropTypes.bool,
- account: ImmutablePropTypes.map,
- statusIds: ImmutablePropTypes.orderedSet.isRequired,
- comment: PropTypes.string.isRequired,
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleCommentChange = (e) => {
- this.props.dispatch(changeReportComment(e.target.value));
- }
-
- handleSubmit = () => {
- this.props.dispatch(submitReport());
- }
-
- componentDidMount () {
- this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
- }
-
- componentWillReceiveProps (nextProps) {
- if (this.props.account !== nextProps.account && nextProps.account) {
- this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
- }
- }
-
- render () {
- const { account, comment, intl, statusIds, isSubmitting } = this.props;
-
- if (!account) {
- return null;
- }
-
- return (
-
-
- {account.get('acct')} }} />
-
-
-
-
-
- {statusIds.map(statusId => )}
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
deleted file mode 100644
index 7694e5ab3..000000000
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { NavLink } from 'react-router-dom';
-import { FormattedMessage, injectIntl } from 'react-intl';
-import { debounce } from 'lodash';
-import { isUserTouching } from '../../../is_mobile';
-
-export const links = [
- ,
- ,
- ,
-
- ,
- ,
-
- ,
-];
-
-export function getIndex (path) {
- return links.findIndex(link => link.props.to === path);
-}
-
-export function getLink (index) {
- return links[index].props.to;
-}
-
-@injectIntl
-export default class TabsBar extends React.Component {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- }
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- }
-
- setRef = ref => {
- this.node = ref;
- }
-
- handleClick = (e) => {
- // Only apply optimization for touch devices, which we assume are slower
- // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
- if (isUserTouching()) {
- e.preventDefault();
- e.persist();
-
- requestAnimationFrame(() => {
- const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
- const currentTab = tabs.find(tab => tab.classList.contains('active'));
- const nextTab = tabs.find(tab => tab.contains(e.target));
- const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
-
-
- if (currentTab !== nextTab) {
- if (currentTab) {
- currentTab.classList.remove('active');
- }
-
- const listener = debounce(() => {
- nextTab.removeEventListener('transitionend', listener);
- this.context.router.history.push(to);
- }, 50);
-
- nextTab.addEventListener('transitionend', listener);
- nextTab.classList.add('active');
- }
- });
- }
-
- }
-
- render () {
- const { intl: { formatMessage } } = this.props;
-
- return (
-
- {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
deleted file mode 100644
index 8b9a26270..000000000
--- a/app/javascript/mastodon/features/ui/components/upload_area.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from '../../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
-
-export default class UploadArea extends React.PureComponent {
-
- static propTypes = {
- active: PropTypes.bool,
- onClose: PropTypes.func,
- };
-
- handleKeyUp = (e) => {
- const keyCode = e.keyCode;
- if (this.props.active) {
- switch(keyCode) {
- case 27:
- e.preventDefault();
- e.stopPropagation();
- this.props.onClose();
- break;
- }
- }
- }
-
- componentDidMount () {
- window.addEventListener('keyup', this.handleKeyUp, false);
- }
-
- componentWillUnmount () {
- window.removeEventListener('keyup', this.handleKeyUp);
- }
-
- render () {
- const { active } = this.props;
-
- return (
-
- {({ backgroundOpacity, backgroundScale }) =>
-
- }
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
deleted file mode 100644
index 1437deeb0..000000000
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Video from '../../video';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class VideoModal extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- time: PropTypes.number,
- onClose: PropTypes.func.isRequired,
- };
-
- render () {
- const { media, time, onClose } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/containers/bundle_container.js b/app/javascript/mastodon/features/ui/containers/bundle_container.js
deleted file mode 100644
index 7e3f0c3a6..000000000
--- a/app/javascript/mastodon/features/ui/containers/bundle_container.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connect } from 'react-redux';
-
-import Bundle from '../components/bundle';
-
-import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles';
-
-const mapDispatchToProps = dispatch => ({
- onFetch () {
- dispatch(fetchBundleRequest());
- },
- onFetchSuccess () {
- dispatch(fetchBundleSuccess());
- },
- onFetchFail (error) {
- dispatch(fetchBundleFail(error));
- },
-});
-
-export default connect(null, mapDispatchToProps)(Bundle);
diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
deleted file mode 100644
index 95f95618b..000000000
--- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import ColumnsArea from '../components/columns_area';
-
-const mapStateToProps = state => ({
- columns: state.getIn(['settings', 'columns']),
-});
-
-export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
deleted file mode 100644
index 4bb90fb68..000000000
--- a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import LoadingBar from 'react-redux-loading-bar';
-
-const mapStateToProps = (state) => ({
- loading: state.get('loadingBar'),
-});
-
-export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
deleted file mode 100644
index 2d27180f7..000000000
--- a/app/javascript/mastodon/features/ui/containers/modal_container.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { connect } from 'react-redux';
-import { closeModal } from '../../../actions/modal';
-import ModalRoot from '../components/modal_root';
-
-const mapStateToProps = state => ({
- type: state.get('modal').modalType,
- props: state.get('modal').modalProps,
-});
-
-const mapDispatchToProps = dispatch => ({
- onClose () {
- dispatch(closeModal());
- },
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
deleted file mode 100644
index 5924197f1..000000000
--- a/app/javascript/mastodon/features/ui/containers/notifications_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import { NotificationStack } from 'react-notification';
-import { dismissAlert } from '../../../actions/alerts';
-import { getAlerts } from '../../../selectors';
-
-const mapStateToProps = state => ({
- notifications: getAlerts(state),
-});
-
-const mapDispatchToProps = (dispatch) => {
- return {
- onDismiss: alert => {
- dispatch(dismissAlert(alert));
- },
- };
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
deleted file mode 100644
index a0aec4403..000000000
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { connect } from 'react-redux';
-import StatusList from '../../../components/status_list';
-import { scrollTopTimeline } from '../../../actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-import { createSelector } from 'reselect';
-import { debounce } from 'lodash';
-import { me } from '../../../initial_state';
-
-const makeGetStatusIds = () => createSelector([
- (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
- (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
- (state) => state.get('statuses'),
-], (columnSettings, statusIds, statuses) => {
- const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
- let regex = null;
-
- try {
- regex = rawRegex && new RegExp(rawRegex, 'i');
- } catch (e) {
- // Bad regex, don't affect filters
- }
-
- return statusIds.filter(id => {
- const statusForId = statuses.get(id);
- let showStatus = true;
-
- if (columnSettings.getIn(['shows', 'reblog']) === false) {
- showStatus = showStatus && statusForId.get('reblog') === null;
- }
-
- if (columnSettings.getIn(['shows', 'reply']) === false) {
- showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
- }
-
- if (showStatus && regex && statusForId.get('account') !== me) {
- const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
- showStatus = !regex.test(searchIndex);
- }
-
- return showStatus;
- });
-});
-
-const makeMapStateToProps = () => {
- const getStatusIds = makeGetStatusIds();
-
- const mapStateToProps = (state, { timelineId }) => ({
- statusIds: getStatusIds(state, { type: timelineId }),
- isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
- hasMore: !!state.getIn(['timelines', timelineId, 'next']),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
-
- onScrollToBottom: debounce(() => {
- dispatch(scrollTopTimeline(timelineId, false));
- loadMore();
- }, 300, { leading: true }),
-
- onScrollToTop: debounce(() => {
- dispatch(scrollTopTimeline(timelineId, true));
- }, 100),
-
- onScroll: debounce(() => {
- dispatch(scrollTopTimeline(timelineId, false));
- }, 100),
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
deleted file mode 100644
index 69eb1bbf7..000000000
--- a/app/javascript/mastodon/features/ui/index.js
+++ /dev/null
@@ -1,442 +0,0 @@
-import React from 'react';
-import NotificationsContainer from './containers/notifications_container';
-import PropTypes from 'prop-types';
-import LoadingBarContainer from './containers/loading_bar_container';
-import TabsBar from './components/tabs_bar';
-import ModalContainer from './containers/modal_container';
-import { connect } from 'react-redux';
-import { Redirect, withRouter } from 'react-router-dom';
-import { isMobile } from '../../is_mobile';
-import { debounce } from 'lodash';
-import { uploadCompose, resetCompose } from '../../actions/compose';
-import { refreshHomeTimeline } from '../../actions/timelines';
-import { refreshNotifications } from '../../actions/notifications';
-import { clearHeight } from '../../actions/height_cache';
-import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
-import UploadArea from './components/upload_area';
-import ColumnsAreaContainer from './containers/columns_area_container';
-import classNames from 'classnames';
-import {
- Compose,
- Status,
- GettingStarted,
- PublicTimeline,
- CommunityTimeline,
- AccountTimeline,
- AccountGallery,
- HomeTimeline,
- Followers,
- Following,
- Reblogs,
- Favourites,
- DirectTimeline,
- HashtagTimeline,
- Notifications,
- FollowRequests,
- GenericNotFound,
- FavouritedStatuses,
- Blocks,
- Mutes,
- PinnedStatuses,
-} from './util/async-components';
-import { HotKeys } from 'react-hotkeys';
-import { me } from '../../initial_state';
-import { defineMessages, injectIntl } from 'react-intl';
-
-// Dummy import, to make sure that ends up in the application bundle.
-// Without this it ends up in ~8 very commonly used bundles.
-import '../../../glitch/components/status';
-
-const messages = defineMessages({
- beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
-});
-
-const mapStateToProps = state => ({
- isComposing: state.getIn(['compose', 'is_composing']),
- hasComposingText: state.getIn(['compose', 'text']) !== '',
- layout: state.getIn(['local_settings', 'layout']),
- isWide: state.getIn(['local_settings', 'stretch']),
- navbarUnder: state.getIn(['local_settings', 'navbar_under']),
-});
-
-const keyMap = {
- new: 'n',
- search: 's',
- forceNew: 'option+n',
- focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
- reply: 'r',
- favourite: 'f',
- boost: 'b',
- mention: 'm',
- open: ['enter', 'o'],
- openProfile: 'p',
- moveDown: ['down', 'j'],
- moveUp: ['up', 'k'],
- back: 'backspace',
- goToHome: 'g h',
- goToNotifications: 'g n',
- goToLocal: 'g l',
- goToFederated: 'g t',
- goToDirect: 'g d',
- goToStart: 'g s',
- goToFavourites: 'g f',
- goToPinned: 'g p',
- goToProfile: 'g u',
- goToBlocked: 'g b',
- goToMuted: 'g m',
-};
-
-@connect(mapStateToProps)
-@injectIntl
-@withRouter
-export default class UI extends React.Component {
-
- static contextTypes = {
- router: PropTypes.object.isRequired,
- };
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- children: PropTypes.node,
- layout: PropTypes.string,
- isWide: PropTypes.bool,
- systemFontUi: PropTypes.bool,
- navbarUnder: PropTypes.bool,
- isComposing: PropTypes.bool,
- hasComposingText: PropTypes.bool,
- location: PropTypes.object,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- width: window.innerWidth,
- draggingOver: false,
- };
-
- handleBeforeUnload = (e) => {
- const { intl, isComposing, hasComposingText } = this.props;
-
- if (isComposing && hasComposingText) {
- // Setting returnValue to any string causes confirmation dialog.
- // Many browsers no longer display this text to users,
- // but we set user-friendly message for other browsers, e.g. Edge.
- e.returnValue = intl.formatMessage(messages.beforeUnload);
- }
- }
-
- handleResize = debounce(() => {
- // The cached heights are no longer accurate, invalidate
- this.props.dispatch(clearHeight());
-
- this.setState({ width: window.innerWidth });
- }, 500, {
- trailing: true,
- });
-
- handleDragEnter = (e) => {
- e.preventDefault();
-
- if (!this.dragTargets) {
- this.dragTargets = [];
- }
-
- if (this.dragTargets.indexOf(e.target) === -1) {
- this.dragTargets.push(e.target);
- }
-
- if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
- this.setState({ draggingOver: true });
- }
- }
-
- handleDragOver = (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- try {
- e.dataTransfer.dropEffect = 'copy';
- } catch (err) {
-
- }
-
- return false;
- }
-
- handleDrop = (e) => {
- e.preventDefault();
-
- this.setState({ draggingOver: false });
-
- if (e.dataTransfer && e.dataTransfer.files.length === 1) {
- this.props.dispatch(uploadCompose(e.dataTransfer.files));
- }
- }
-
- handleDragLeave = (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
-
- if (this.dragTargets.length > 0) {
- return;
- }
-
- this.setState({ draggingOver: false });
- }
-
- closeUploadModal = () => {
- this.setState({ draggingOver: false });
- }
-
- handleServiceWorkerPostMessage = ({ data }) => {
- if (data.type === 'navigate') {
- this.context.router.history.push(data.path);
- } else {
- console.warn('Unknown message type:', data.type);
- }
- }
-
- componentWillMount () {
- window.addEventListener('beforeunload', this.handleBeforeUnload, false);
- window.addEventListener('resize', this.handleResize, { passive: true });
- document.addEventListener('dragenter', this.handleDragEnter, false);
- document.addEventListener('dragover', this.handleDragOver, false);
- document.addEventListener('drop', this.handleDrop, false);
- document.addEventListener('dragleave', this.handleDragLeave, false);
- document.addEventListener('dragend', this.handleDragEnd, false);
-
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
- }
-
- this.props.dispatch(refreshHomeTimeline());
- this.props.dispatch(refreshNotifications());
- }
-
- componentDidMount () {
- this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
- return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
- };
- }
-
- shouldComponentUpdate (nextProps) {
- if (nextProps.isComposing !== this.props.isComposing) {
- // Avoid expensive update just to toggle a class
- this.node.classList.toggle('is-composing', nextProps.isComposing);
- this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
-
- return false;
- }
-
- // Why isn't this working?!?
- // return super.shouldComponentUpdate(nextProps, nextState);
- return true;
- }
-
- componentDidUpdate (prevProps) {
- if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
- this.columnsAreaNode.handleChildrenContentChange();
- }
- }
-
- componentWillUnmount () {
- window.removeEventListener('beforeunload', this.handleBeforeUnload);
- window.removeEventListener('resize', this.handleResize);
- document.removeEventListener('dragenter', this.handleDragEnter);
- document.removeEventListener('dragover', this.handleDragOver);
- document.removeEventListener('drop', this.handleDrop);
- document.removeEventListener('dragleave', this.handleDragLeave);
- document.removeEventListener('dragend', this.handleDragEnd);
- }
-
- setRef = c => {
- this.node = c;
- }
-
- setColumnsAreaRef = c => {
- this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
- }
-
- handleHotkeyNew = e => {
- e.preventDefault();
-
- const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
-
- if (element) {
- element.focus();
- }
- }
-
- handleHotkeySearch = e => {
- e.preventDefault();
-
- const element = this.node.querySelector('.search__input');
-
- if (element) {
- element.focus();
- }
- }
-
- handleHotkeyForceNew = e => {
- this.handleHotkeyNew(e);
- this.props.dispatch(resetCompose());
- }
-
- 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) {
- const status = column.querySelector('.focusable');
-
- if (status) {
- status.focus();
- }
- }
- }
-
- handleHotkeyBack = () => {
- if (window.history && window.history.length === 1) {
- this.context.router.history.push('/');
- } else {
- this.context.router.history.goBack();
- }
- }
-
- setHotkeysRef = c => {
- this.hotkeys = c;
- }
-
- handleHotkeyGoToHome = () => {
- this.context.router.history.push('/timelines/home');
- }
-
- handleHotkeyGoToNotifications = () => {
- this.context.router.history.push('/notifications');
- }
-
- handleHotkeyGoToLocal = () => {
- this.context.router.history.push('/timelines/public/local');
- }
-
- handleHotkeyGoToFederated = () => {
- this.context.router.history.push('/timelines/public');
- }
-
- handleHotkeyGoToDirect = () => {
- this.context.router.history.push('/timelines/direct');
- }
-
- handleHotkeyGoToStart = () => {
- this.context.router.history.push('/getting-started');
- }
-
- handleHotkeyGoToFavourites = () => {
- this.context.router.history.push('/favourites');
- }
-
- handleHotkeyGoToPinned = () => {
- this.context.router.history.push('/pinned');
- }
-
- handleHotkeyGoToProfile = () => {
- this.context.router.history.push(`/accounts/${me}`);
- }
-
- handleHotkeyGoToBlocked = () => {
- this.context.router.history.push('/blocks');
- }
-
- handleHotkeyGoToMuted = () => {
- this.context.router.history.push('/mutes');
- }
-
- render () {
- const { width, draggingOver } = this.state;
- const { children, layout, isWide, navbarUnder } = this.props;
-
- const columnsClass = layout => {
- switch (layout) {
- case 'single':
- return 'single-column';
- case 'multiple':
- return 'multi-columns';
- default:
- return 'auto-columns';
- }
- };
-
- const className = classNames('ui', columnsClass(layout), {
- 'wide': isWide,
- 'system-font': this.props.systemFontUi,
- 'navbar-under': navbarUnder,
- });
-
- const handlers = {
- new: this.handleHotkeyNew,
- search: this.handleHotkeySearch,
- forceNew: this.handleHotkeyForceNew,
- focusColumn: this.handleHotkeyFocusColumn,
- back: this.handleHotkeyBack,
- goToHome: this.handleHotkeyGoToHome,
- goToNotifications: this.handleHotkeyGoToNotifications,
- goToLocal: this.handleHotkeyGoToLocal,
- goToFederated: this.handleHotkeyGoToFederated,
- goToDirect: this.handleHotkeyGoToDirect,
- goToStart: this.handleHotkeyGoToStart,
- goToFavourites: this.handleHotkeyGoToFavourites,
- goToPinned: this.handleHotkeyGoToPinned,
- goToProfile: this.handleHotkeyGoToProfile,
- goToBlocked: this.handleHotkeyGoToBlocked,
- goToMuted: this.handleHotkeyGoToMuted,
- };
-
- return (
-
-
- {navbarUnder ? null : ( )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {navbarUnder ? ( ) : null}
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
deleted file mode 100644
index dc8e9dfb9..000000000
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ /dev/null
@@ -1,118 +0,0 @@
-export function EmojiPicker () {
- return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
-}
-
-export function Compose () {
- return import(/* webpackChunkName: "features/compose" */'../../compose');
-}
-
-export function Notifications () {
- return import(/* webpackChunkName: "features/notifications" */'../../notifications');
-}
-
-export function HomeTimeline () {
- return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
-}
-
-export function PublicTimeline () {
- return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
-}
-
-export function CommunityTimeline () {
- return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
-}
-
-export function HashtagTimeline () {
- return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
-}
-
-export function DirectTimeline() {
- return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
-}
-
-export function Status () {
- return import(/* webpackChunkName: "features/status" */'../../status');
-}
-
-export function GettingStarted () {
- return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
-}
-
-export function PinnedStatuses () {
- return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
-}
-
-export function AccountTimeline () {
- return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
-}
-
-export function AccountGallery () {
- return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
-}
-
-export function Followers () {
- return import(/* webpackChunkName: "features/followers" */'../../followers');
-}
-
-export function Following () {
- return import(/* webpackChunkName: "features/following" */'../../following');
-}
-
-export function Reblogs () {
- return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
-}
-
-export function Favourites () {
- return import(/* webpackChunkName: "features/favourites" */'../../favourites');
-}
-
-export function FollowRequests () {
- return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
-}
-
-export function GenericNotFound () {
- return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found');
-}
-
-export function FavouritedStatuses () {
- return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
-}
-
-export function Blocks () {
- return import(/* webpackChunkName: "features/blocks" */'../../blocks');
-}
-
-export function Mutes () {
- return import(/* webpackChunkName: "features/mutes" */'../../mutes');
-}
-
-export function OnboardingModal () {
- return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
-}
-
-export function MuteModal () {
- return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
-}
-
-export function ReportModal () {
- return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
-}
-
-export function SettingsModal () {
- return import(/* webpackChunkName: "modals/settings_modal" */'glitch/components/local_settings/container');
-}
-
-// THESE AREN'T USED BY US; SEE `glitch/components/status` AND `mastodon/features/status`. //
-// IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL. //
-
-export function MediaGallery () {
- return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
-}
-
-export function Video () {
- return import(/* webpackChunkName: "features/video" */'../../video');
-}
-
-export function EmbedModal () {
- return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
-}
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js
deleted file mode 100644
index cf5d0cf98..000000000
--- a/app/javascript/mastodon/features/ui/util/fullscreen.js
+++ /dev/null
@@ -1,46 +0,0 @@
-// APIs for normalizing fullscreen operations. Note that Edge uses
-// the WebKit-prefixed APIs currently (as of Edge 16).
-
-export const isFullscreen = () => document.fullscreenElement ||
- document.webkitFullscreenElement ||
- document.mozFullScreenElement;
-
-export const exitFullscreen = () => {
- if (document.exitFullscreen) {
- document.exitFullscreen();
- } else if (document.webkitExitFullscreen) {
- document.webkitExitFullscreen();
- } else if (document.mozCancelFullScreen) {
- document.mozCancelFullScreen();
- }
-};
-
-export const requestFullscreen = el => {
- if (el.requestFullscreen) {
- el.requestFullscreen();
- } else if (el.webkitRequestFullscreen) {
- el.webkitRequestFullscreen();
- } else if (el.mozRequestFullScreen) {
- el.mozRequestFullScreen();
- }
-};
-
-export const attachFullscreenListener = (listener) => {
- if ('onfullscreenchange' in document) {
- document.addEventListener('fullscreenchange', listener);
- } else if ('onwebkitfullscreenchange' in document) {
- document.addEventListener('webkitfullscreenchange', listener);
- } else if ('onmozfullscreenchange' in document) {
- document.addEventListener('mozfullscreenchange', listener);
- }
-};
-
-export const detachFullscreenListener = (listener) => {
- if ('onfullscreenchange' in document) {
- document.removeEventListener('fullscreenchange', listener);
- } else if ('onwebkitfullscreenchange' in document) {
- document.removeEventListener('webkitfullscreenchange', listener);
- } else if ('onmozfullscreenchange' in document) {
- document.removeEventListener('mozfullscreenchange', listener);
- }
-};
diff --git a/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js b/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js
deleted file mode 100644
index c266cd7dc..000000000
--- a/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js
+++ /dev/null
@@ -1,21 +0,0 @@
-
-// Get the bounding client rect from an IntersectionObserver entry.
-// This is to work around a bug in Chrome: https://crbug.com/737228
-
-let hasBoundingRectBug;
-
-function getRectFromEntry(entry) {
- if (typeof hasBoundingRectBug !== 'boolean') {
- const boundingRect = entry.target.getBoundingClientRect();
- const observerRect = entry.boundingClientRect;
- hasBoundingRectBug = boundingRect.height !== observerRect.height ||
- boundingRect.top !== observerRect.top ||
- boundingRect.width !== observerRect.width ||
- boundingRect.bottom !== observerRect.bottom ||
- boundingRect.left !== observerRect.left ||
- boundingRect.right !== observerRect.right;
- }
- return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
-}
-
-export default getRectFromEntry;
diff --git a/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
deleted file mode 100644
index 2b24c6583..000000000
--- a/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
+++ /dev/null
@@ -1,57 +0,0 @@
-// Wrapper for IntersectionObserver in order to make working with it
-// a bit easier. We also follow this performance advice:
-// "If you need to observe multiple elements, it is both possible and
-// advised to observe multiple elements using the same IntersectionObserver
-// instance by calling observe() multiple times."
-// https://developers.google.com/web/updates/2016/04/intersectionobserver
-
-class IntersectionObserverWrapper {
-
- callbacks = {};
- observerBacklog = [];
- observer = null;
-
- connect (options) {
- const onIntersection = (entries) => {
- entries.forEach(entry => {
- const id = entry.target.getAttribute('data-id');
- if (this.callbacks[id]) {
- this.callbacks[id](entry);
- }
- });
- };
-
- this.observer = new IntersectionObserver(onIntersection, options);
- this.observerBacklog.forEach(([ id, node, callback ]) => {
- this.observe(id, node, callback);
- });
- this.observerBacklog = null;
- }
-
- observe (id, node, callback) {
- if (!this.observer) {
- this.observerBacklog.push([ id, node, callback ]);
- } else {
- this.callbacks[id] = callback;
- this.observer.observe(node);
- }
- }
-
- unobserve (id, node) {
- if (this.observer) {
- delete this.callbacks[id];
- this.observer.unobserve(node);
- }
- }
-
- disconnect () {
- if (this.observer) {
- this.callbacks = {};
- this.observer.disconnect();
- this.observer = null;
- }
- }
-
-}
-
-export default IntersectionObserverWrapper;
diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js
deleted file mode 100644
index df3a8b54a..000000000
--- a/app/javascript/mastodon/features/ui/util/optional_motion.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { reduceMotion } from '../../../initial_state';
-import ReducedMotion from './reduced_motion';
-import Motion from 'react-motion/lib/Motion';
-
-export default reduceMotion ? ReducedMotion : Motion;
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
deleted file mode 100644
index 43007ddc3..000000000
--- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Switch, Route } from 'react-router-dom';
-
-import ColumnLoading from '../components/column_loading';
-import BundleColumnError from '../components/bundle_column_error';
-import BundleContainer from '../containers/bundle_container';
-
-// Small wrapper to pass multiColumn to the route components
-export class WrappedSwitch extends React.PureComponent {
-
- render () {
- const { multiColumn, children } = this.props;
-
- return (
-
- {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
-
- );
- }
-
-}
-
-WrappedSwitch.propTypes = {
- multiColumn: PropTypes.bool,
- children: PropTypes.node,
-};
-
-// Small Wraper to extract the params from the route and pass
-// them to the rendered component, together with the content to
-// be rendered inside (the children)
-export class WrappedRoute extends React.Component {
-
- static propTypes = {
- component: PropTypes.func.isRequired,
- content: PropTypes.node,
- multiColumn: PropTypes.bool,
- }
-
- renderComponent = ({ match }) => {
- const { component, content, multiColumn } = this.props;
-
- return (
-
- {Component => {content} }
-
- );
- }
-
- renderLoading = () => {
- return ;
- }
-
- renderError = (props) => {
- return ;
- }
-
- render () {
- const { component: Component, content, ...rest } = this.props;
-
- return ;
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.js b/app/javascript/mastodon/features/ui/util/reduced_motion.js
deleted file mode 100644
index 95519042b..000000000
--- a/app/javascript/mastodon/features/ui/util/reduced_motion.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Like react-motion's Motion, but reduces all animations to cross-fades
-// for the benefit of users with motion sickness.
-import React from 'react';
-import Motion from 'react-motion/lib/Motion';
-import PropTypes from 'prop-types';
-
-const stylesToKeep = ['opacity', 'backgroundOpacity'];
-
-const extractValue = (value) => {
- // This is either an object with a "val" property or it's a number
- return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
-};
-
-class ReducedMotion extends React.Component {
-
- static propTypes = {
- defaultStyle: PropTypes.object,
- style: PropTypes.object,
- children: PropTypes.func,
- }
-
- render() {
-
- const { style, defaultStyle, children } = this.props;
-
- Object.keys(style).forEach(key => {
- if (stylesToKeep.includes(key)) {
- return;
- }
- // If it's setting an x or height or scale or some other value, we need
- // to preserve the end-state value without actually animating it
- style[key] = defaultStyle[key] = extractValue(style[key]);
- });
-
- return (
-
- {children}
-
- );
- }
-
-}
-
-export default ReducedMotion;
diff --git a/app/javascript/mastodon/features/ui/util/schedule_idle_task.js b/app/javascript/mastodon/features/ui/util/schedule_idle_task.js
deleted file mode 100644
index b04d4a8ee..000000000
--- a/app/javascript/mastodon/features/ui/util/schedule_idle_task.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// Wrapper to call requestIdleCallback() to schedule low-priority work.
-// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
-// for a good breakdown of the concepts behind this.
-
-import Queue from 'tiny-queue';
-
-const taskQueue = new Queue();
-let runningRequestIdleCallback = false;
-
-function runTasks(deadline) {
- while (taskQueue.length && deadline.timeRemaining() > 0) {
- taskQueue.shift()();
- }
- if (taskQueue.length) {
- requestIdleCallback(runTasks);
- } else {
- runningRequestIdleCallback = false;
- }
-}
-
-function scheduleIdleTask(task) {
- taskQueue.push(task);
- if (!runningRequestIdleCallback) {
- runningRequestIdleCallback = true;
- requestIdleCallback(runTasks);
- }
-}
-
-export default scheduleIdleTask;
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
deleted file mode 100644
index 003bf23a8..000000000
--- a/app/javascript/mastodon/features/video/index.js
+++ /dev/null
@@ -1,286 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { throttle } from 'lodash';
-import classNames from 'classnames';
-import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
-
-const messages = defineMessages({
- play: { id: 'video.play', defaultMessage: 'Play' },
- pause: { id: 'video.pause', defaultMessage: 'Pause' },
- mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
- unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
- hide: { id: 'video.hide', defaultMessage: 'Hide video' },
- expand: { id: 'video.expand', defaultMessage: 'Expand video' },
- close: { id: 'video.close', defaultMessage: 'Close video' },
- fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
- exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
-});
-
-const findElementPosition = el => {
- let box;
-
- if (el.getBoundingClientRect && el.parentNode) {
- box = el.getBoundingClientRect();
- }
-
- if (!box) {
- return {
- left: 0,
- top: 0,
- };
- }
-
- const docEl = document.documentElement;
- const body = document.body;
-
- const clientLeft = docEl.clientLeft || body.clientLeft || 0;
- const scrollLeft = window.pageXOffset || body.scrollLeft;
- const left = (box.left + scrollLeft) - clientLeft;
-
- const clientTop = docEl.clientTop || body.clientTop || 0;
- const scrollTop = window.pageYOffset || body.scrollTop;
- const top = (box.top + scrollTop) - clientTop;
-
- return {
- left: Math.round(left),
- top: Math.round(top),
- };
-};
-
-const getPointerPosition = (el, event) => {
- const position = {};
- const box = findElementPosition(el);
- const boxW = el.offsetWidth;
- const boxH = el.offsetHeight;
- const boxY = box.top;
- const boxX = box.left;
-
- let pageY = event.pageY;
- let pageX = event.pageX;
-
- if (event.changedTouches) {
- pageX = event.changedTouches[0].pageX;
- pageY = event.changedTouches[0].pageY;
- }
-
- position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
- position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
-
- return position;
-};
-
-@injectIntl
-export default class Video extends React.PureComponent {
-
- static propTypes = {
- preview: PropTypes.string,
- src: PropTypes.string.isRequired,
- alt: PropTypes.string,
- width: PropTypes.number,
- height: PropTypes.number,
- sensitive: PropTypes.bool,
- startTime: PropTypes.number,
- onOpenVideo: PropTypes.func,
- onCloseVideo: PropTypes.func,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- progress: 0,
- paused: true,
- dragging: false,
- fullscreen: false,
- hovered: false,
- muted: false,
- revealed: !this.props.sensitive,
- };
-
- setPlayerRef = c => {
- this.player = c;
- }
-
- setVideoRef = c => {
- this.video = c;
- }
-
- setSeekRef = c => {
- this.seek = c;
- }
-
- handlePlay = () => {
- this.setState({ paused: false });
- }
-
- handlePause = () => {
- this.setState({ paused: true });
- }
-
- handleTimeUpdate = () => {
- this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
- }
-
- handleMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseMove, true);
- document.addEventListener('mouseup', this.handleMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseMove, true);
- document.addEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: true });
- this.video.pause();
- this.handleMouseMove(e);
- }
-
- handleMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseMove, true);
- document.removeEventListener('mouseup', this.handleMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseMove, true);
- document.removeEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: false });
- this.video.play();
- }
-
- handleMouseMove = throttle(e => {
- const { x } = getPointerPosition(this.seek, e);
- this.video.currentTime = this.video.duration * x;
- this.setState({ progress: x * 100 });
- }, 60);
-
- togglePlay = () => {
- if (this.state.paused) {
- this.video.play();
- } else {
- this.video.pause();
- }
- }
-
- toggleFullscreen = () => {
- if (isFullscreen()) {
- exitFullscreen();
- } else {
- requestFullscreen(this.player);
- }
- }
-
- componentDidMount () {
- document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
- document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
- }
-
- componentWillUnmount () {
- document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
- document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
- }
-
- handleFullscreenChange = () => {
- this.setState({ fullscreen: isFullscreen() });
- }
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- }
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- }
-
- toggleMute = () => {
- this.video.muted = !this.video.muted;
- this.setState({ muted: this.video.muted });
- }
-
- toggleReveal = () => {
- if (this.state.revealed) {
- this.video.pause();
- }
-
- this.setState({ revealed: !this.state.revealed });
- }
-
- handleLoadedData = () => {
- if (this.props.startTime) {
- this.video.currentTime = this.props.startTime;
- this.video.play();
- }
- }
-
- handleProgress = () => {
- if (this.video.buffered.length > 0) {
- this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
- }
- }
-
- handleOpenVideo = () => {
- this.video.pause();
- this.props.onOpenVideo(this.video.currentTime);
- }
-
- handleCloseVideo = () => {
- this.video.pause();
- this.props.onCloseVideo();
- }
-
- render () {
- const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
- const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!onCloseVideo && }
-
-
-
- {(!fullscreen && onOpenVideo) && }
- {onCloseVideo && }
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
deleted file mode 100644
index ef5d8b0ef..000000000
--- a/app/javascript/mastodon/initial_state.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const element = document.getElementById('initial-state');
-const initialState = element && function () {
- const result = JSON.parse(element.textContent);
- try {
- result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
- } catch (e) {
- result.local_settings = {};
- }
- return result;
-}();
-
-const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
-
-export const reduceMotion = getMeta('reduce_motion');
-export const autoPlayGif = getMeta('auto_play_gif');
-export const unfollowModal = getMeta('unfollow_modal');
-export const boostModal = getMeta('boost_modal');
-export const deleteModal = getMeta('delete_modal');
-export const me = getMeta('me');
-
-export default initialState;
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
deleted file mode 100644
index 80e8e0a8a..000000000
--- a/app/javascript/mastodon/is_mobile.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import detectPassiveEvents from 'detect-passive-events';
-
-const LAYOUT_BREAKPOINT = 630;
-
-export function isMobile(width, columns) {
- switch (columns) {
- case 'multiple':
- return false;
- case 'single':
- return true;
- default:
- return width <= LAYOUT_BREAKPOINT;
- }
-};
-
-const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
-
-let userTouching = false;
-let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
-
-function touchListener() {
- userTouching = true;
- window.removeEventListener('touchstart', touchListener, listenerOptions);
-}
-
-window.addEventListener('touchstart', touchListener, listenerOptions);
-
-export function isUserTouching() {
- return userTouching;
-}
-
-export function isIOS() {
- return iOS;
-};
diff --git a/app/javascript/mastodon/link_header.js b/app/javascript/mastodon/link_header.js
deleted file mode 100644
index a3e7ccf1c..000000000
--- a/app/javascript/mastodon/link_header.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Link from 'http-link-header';
-import querystring from 'querystring';
-
-Link.parseAttrs = (link, parts) => {
- let match = null;
- let attr = '';
- let value = '';
- let attrs = '';
-
- let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts);
-
- if(uriAttrs) {
- attrs = uriAttrs[2];
- link = Link.parseParams(link, uriAttrs[1]);
- }
-
- while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
- attr = match[1].toLowerCase();
- value = match[4] || match[3] || match[2];
-
- if( /\*$/.test(attr)) {
- Link.setAttr(link, attr, Link.parseExtendedValue(value));
- } else if(/%/.test(value)) {
- Link.setAttr(link, attr, querystring.decode(value));
- } else {
- Link.setAttr(link, attr, value);
- }
- }
-
- return link;
-};
-
-export default Link;
diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js
deleted file mode 100644
index 8927b7358..000000000
--- a/app/javascript/mastodon/load_polyfills.js
+++ /dev/null
@@ -1,39 +0,0 @@
-// Convenience function to load polyfills and return a promise when it's done.
-// If there are no polyfills, then this is just Promise.resolve() which means
-// it will execute in the same tick of the event loop (i.e. near-instant).
-
-function importBasePolyfills() {
- return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
-}
-
-function importExtraPolyfills() {
- return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
-}
-
-function loadPolyfills() {
- const needsBasePolyfills = !(
- window.Intl &&
- Object.assign &&
- Number.isNaN &&
- window.Symbol &&
- Array.prototype.includes
- );
-
- // Latest version of Firefox and Safari do not have IntersectionObserver.
- // Edge does not have requestIdleCallback and object-fit CSS property.
- // This avoids shipping them all the polyfills.
- const needsExtraPolyfills = !(
- window.IntersectionObserver &&
- window.IntersectionObserverEntry &&
- 'isIntersecting' in IntersectionObserverEntry.prototype &&
- window.requestIdleCallback &&
- 'object-fit' in (new Image()).style
- );
-
- return Promise.all([
- needsBasePolyfills && importBasePolyfills(),
- needsExtraPolyfills && importExtraPolyfills(),
- ]);
-}
-
-export default loadPolyfills;
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
deleted file mode 100644
index 93d2eaf10..000000000
--- a/app/javascript/mastodon/main.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import * as WebPushSubscription from './web_push_subscription';
-import Mastodon from './containers/mastodon';
-import React from 'react';
-import ReactDOM from 'react-dom';
-import ready from './ready';
-
-const perf = require('./performance');
-
-function main() {
- perf.start('main()');
-
- if (window.history && history.replaceState) {
- const { pathname, search, hash } = window.location;
- const path = pathname + search + hash;
- if (!(/^\/web[$/]/).test(path)) {
- history.replaceState(null, document.title, `/web${path}`);
- }
- }
-
- ready(() => {
- const mountNode = document.getElementById('mastodon');
- const props = JSON.parse(mountNode.getAttribute('data-props'));
-
- ReactDOM.render( , mountNode);
- if (process.env.NODE_ENV === 'production') {
- // avoid offline in dev mode because it's harder to debug
- require('offline-plugin/runtime').install();
- WebPushSubscription.register();
- }
- perf.stop('main()');
-
- // remember the initial URL
- if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') {
- window._mastoInitialHistoryLen = window.history.length;
- }
- });
-}
-
-export default main;
diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js
deleted file mode 100644
index b2c5f0898..000000000
--- a/app/javascript/mastodon/middleware/errors.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { showAlert } from '../actions/alerts';
-
-const defaultFailSuffix = 'FAIL';
-
-export default function errorsMiddleware() {
- return ({ dispatch }) => next => action => {
- if (action.type && !action.skipAlert) {
- const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
-
- if (action.type.match(isFail)) {
- if (action.error.response) {
- const { data, status, statusText } = action.error.response;
-
- let message = statusText;
- let title = `${status}`;
-
- if (data.error) {
- message = data.error;
- }
-
- dispatch(showAlert(title, message));
- } else {
- console.error(action.error);
- dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
- }
- }
- }
-
- return next(action);
- };
-};
diff --git a/app/javascript/mastodon/middleware/loading_bar.js b/app/javascript/mastodon/middleware/loading_bar.js
deleted file mode 100644
index a98f1bb2b..000000000
--- a/app/javascript/mastodon/middleware/loading_bar.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { showLoading, hideLoading } from 'react-redux-loading-bar';
-
-const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
-
-export default function loadingBarMiddleware(config = {}) {
- const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
-
- return ({ dispatch }) => next => (action) => {
- if (action.type && !action.skipLoading) {
- const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
-
- const isPending = new RegExp(`${PENDING}$`, 'g');
- const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
- const isRejected = new RegExp(`${REJECTED}$`, 'g');
-
- if (action.type.match(isPending)) {
- dispatch(showLoading());
- } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
- dispatch(hideLoading());
- }
- }
-
- return next(action);
- };
-};
diff --git a/app/javascript/mastodon/middleware/sounds.js b/app/javascript/mastodon/middleware/sounds.js
deleted file mode 100644
index 3d1e3eaba..000000000
--- a/app/javascript/mastodon/middleware/sounds.js
+++ /dev/null
@@ -1,46 +0,0 @@
-const createAudio = sources => {
- const audio = new Audio();
- sources.forEach(({ type, src }) => {
- const source = document.createElement('source');
- source.type = type;
- source.src = src;
- audio.appendChild(source);
- });
- return audio;
-};
-
-const play = audio => {
- if (!audio.paused) {
- audio.pause();
- if (typeof audio.fastSeek === 'function') {
- audio.fastSeek(0);
- } else {
- audio.seek(0);
- }
- }
-
- audio.play();
-};
-
-export default function soundsMiddleware() {
- const soundCache = {
- boop: createAudio([
- {
- src: '/sounds/boop.ogg',
- type: 'audio/ogg',
- },
- {
- src: '/sounds/boop.mp3',
- type: 'audio/mpeg',
- },
- ]),
- };
-
- return () => next => action => {
- if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
- play(soundCache[action.meta.sound]);
- }
-
- return next(action);
- };
-};
diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js
deleted file mode 100644
index 450a90626..000000000
--- a/app/javascript/mastodon/performance.js
+++ /dev/null
@@ -1,31 +0,0 @@
-//
-// Tools for performance debugging, only enabled in development mode.
-// Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
-// Also see config/webpack/loaders/mark.js for the webpack loader marks.
-//
-
-let marky;
-
-if (process.env.NODE_ENV === 'development') {
- if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
- // Increase Firefox's performance entry limit; otherwise it's capped to 150.
- // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
- performance.setResourceTimingBufferSize(Infinity);
- }
- marky = require('marky');
- // allows us to easily do e.g. ReactPerf.printWasted() while debugging
- //window.ReactPerf = require('react-addons-perf');
- //window.ReactPerf.start();
-}
-
-export function start(name) {
- if (process.env.NODE_ENV === 'development') {
- marky.mark(name);
- }
-}
-
-export function stop(name) {
- if (process.env.NODE_ENV === 'development') {
- marky.stop(name);
- }
-}
diff --git a/app/javascript/mastodon/ready.js b/app/javascript/mastodon/ready.js
deleted file mode 100644
index dd543910b..000000000
--- a/app/javascript/mastodon/ready.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function ready(loaded) {
- if (['interactive', 'complete'].includes(document.readyState)) {
- loaded();
- } else {
- document.addEventListener('DOMContentLoaded', loaded);
- }
-}
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
deleted file mode 100644
index 8a4d69f26..000000000
--- a/app/javascript/mastodon/reducers/accounts.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import {
- ACCOUNT_FETCH_SUCCESS,
- FOLLOWERS_FETCH_SUCCESS,
- FOLLOWERS_EXPAND_SUCCESS,
- FOLLOWING_FETCH_SUCCESS,
- FOLLOWING_EXPAND_SUCCESS,
- FOLLOW_REQUESTS_FETCH_SUCCESS,
- FOLLOW_REQUESTS_EXPAND_SUCCESS,
-} from '../actions/accounts';
-import {
- BLOCKS_FETCH_SUCCESS,
- BLOCKS_EXPAND_SUCCESS,
-} from '../actions/blocks';
-import {
- MUTES_FETCH_SUCCESS,
- MUTES_EXPAND_SUCCESS,
-} from '../actions/mutes';
-import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
-import {
- REBLOG_SUCCESS,
- UNREBLOG_SUCCESS,
- FAVOURITE_SUCCESS,
- UNFAVOURITE_SUCCESS,
- REBLOGS_FETCH_SUCCESS,
- FAVOURITES_FETCH_SUCCESS,
-} from '../actions/interactions';
-import {
- TIMELINE_REFRESH_SUCCESS,
- TIMELINE_UPDATE,
- TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
- STATUS_FETCH_SUCCESS,
- CONTEXT_FETCH_SUCCESS,
-} from '../actions/statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import {
- NOTIFICATIONS_UPDATE,
- NOTIFICATIONS_REFRESH_SUCCESS,
- NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
- FAVOURITED_STATUSES_FETCH_SUCCESS,
- FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import { STORE_HYDRATE } from '../actions/store';
-import emojify from '../features/emoji/emoji';
-import { Map as ImmutableMap, fromJS } from 'immutable';
-import escapeTextContentForBrowser from 'escape-html';
-
-const normalizeAccount = (state, account) => {
- account = { ...account };
-
- delete account.followers_count;
- delete account.following_count;
- delete account.statuses_count;
-
- const displayName = account.display_name.length === 0 ? account.username : account.display_name;
- account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
- account.note_emojified = emojify(account.note);
-
- return state.set(account.id, fromJS(account));
-};
-
-const normalizeAccounts = (state, accounts) => {
- accounts.forEach(account => {
- state = normalizeAccount(state, account);
- });
-
- return state;
-};
-
-const normalizeAccountFromStatus = (state, status) => {
- state = normalizeAccount(state, status.account);
-
- if (status.reblog && status.reblog.account) {
- state = normalizeAccount(state, status.reblog.account);
- }
-
- return state;
-};
-
-const normalizeAccountsFromStatuses = (state, statuses) => {
- statuses.forEach(status => {
- state = normalizeAccountFromStatus(state, status);
- });
-
- return state;
-};
-
-const initialState = ImmutableMap();
-
-export default function accounts(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return state.merge(action.state.get('accounts'));
- case ACCOUNT_FETCH_SUCCESS:
- case NOTIFICATIONS_UPDATE:
- return normalizeAccount(state, action.account);
- case FOLLOWERS_FETCH_SUCCESS:
- case FOLLOWERS_EXPAND_SUCCESS:
- case FOLLOWING_FETCH_SUCCESS:
- case FOLLOWING_EXPAND_SUCCESS:
- case REBLOGS_FETCH_SUCCESS:
- case FAVOURITES_FETCH_SUCCESS:
- case COMPOSE_SUGGESTIONS_READY:
- case FOLLOW_REQUESTS_FETCH_SUCCESS:
- case FOLLOW_REQUESTS_EXPAND_SUCCESS:
- case BLOCKS_FETCH_SUCCESS:
- case BLOCKS_EXPAND_SUCCESS:
- case MUTES_FETCH_SUCCESS:
- case MUTES_EXPAND_SUCCESS:
- return action.accounts ? normalizeAccounts(state, action.accounts) : state;
- case NOTIFICATIONS_REFRESH_SUCCESS:
- case NOTIFICATIONS_EXPAND_SUCCESS:
- case SEARCH_FETCH_SUCCESS:
- return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
- case TIMELINE_REFRESH_SUCCESS:
- case TIMELINE_EXPAND_SUCCESS:
- case CONTEXT_FETCH_SUCCESS:
- case FAVOURITED_STATUSES_FETCH_SUCCESS:
- case FAVOURITED_STATUSES_EXPAND_SUCCESS:
- return normalizeAccountsFromStatuses(state, action.statuses);
- case REBLOG_SUCCESS:
- case FAVOURITE_SUCCESS:
- case UNREBLOG_SUCCESS:
- case UNFAVOURITE_SUCCESS:
- return normalizeAccountFromStatus(state, action.response);
- case TIMELINE_UPDATE:
- case STATUS_FETCH_SUCCESS:
- return normalizeAccountFromStatus(state, action.status);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
deleted file mode 100644
index 1f795199b..000000000
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ /dev/null
@@ -1,136 +0,0 @@
-import {
- ACCOUNT_FETCH_SUCCESS,
- FOLLOWERS_FETCH_SUCCESS,
- FOLLOWERS_EXPAND_SUCCESS,
- FOLLOWING_FETCH_SUCCESS,
- FOLLOWING_EXPAND_SUCCESS,
- FOLLOW_REQUESTS_FETCH_SUCCESS,
- FOLLOW_REQUESTS_EXPAND_SUCCESS,
- ACCOUNT_FOLLOW_SUCCESS,
- ACCOUNT_UNFOLLOW_SUCCESS,
-} from '../actions/accounts';
-import {
- BLOCKS_FETCH_SUCCESS,
- BLOCKS_EXPAND_SUCCESS,
-} from '../actions/blocks';
-import {
- MUTES_FETCH_SUCCESS,
- MUTES_EXPAND_SUCCESS,
-} from '../actions/mutes';
-import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
-import {
- REBLOG_SUCCESS,
- UNREBLOG_SUCCESS,
- FAVOURITE_SUCCESS,
- UNFAVOURITE_SUCCESS,
- REBLOGS_FETCH_SUCCESS,
- FAVOURITES_FETCH_SUCCESS,
-} from '../actions/interactions';
-import {
- TIMELINE_REFRESH_SUCCESS,
- TIMELINE_UPDATE,
- TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
- STATUS_FETCH_SUCCESS,
- CONTEXT_FETCH_SUCCESS,
-} from '../actions/statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import {
- NOTIFICATIONS_UPDATE,
- NOTIFICATIONS_REFRESH_SUCCESS,
- NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
- FAVOURITED_STATUSES_FETCH_SUCCESS,
- FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import { STORE_HYDRATE } from '../actions/store';
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-const normalizeAccount = (state, account) => state.set(account.id, fromJS({
- followers_count: account.followers_count,
- following_count: account.following_count,
- statuses_count: account.statuses_count,
-}));
-
-const normalizeAccounts = (state, accounts) => {
- accounts.forEach(account => {
- state = normalizeAccount(state, account);
- });
-
- return state;
-};
-
-const normalizeAccountFromStatus = (state, status) => {
- state = normalizeAccount(state, status.account);
-
- if (status.reblog && status.reblog.account) {
- state = normalizeAccount(state, status.reblog.account);
- }
-
- return state;
-};
-
-const normalizeAccountsFromStatuses = (state, statuses) => {
- statuses.forEach(status => {
- state = normalizeAccountFromStatus(state, status);
- });
-
- return state;
-};
-
-const initialState = ImmutableMap();
-
-export default function accountsCounters(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return state.merge(action.state.get('accounts').map(item => fromJS({
- followers_count: item.get('followers_count'),
- following_count: item.get('following_count'),
- statuses_count: item.get('statuses_count'),
- })));
- case ACCOUNT_FETCH_SUCCESS:
- case NOTIFICATIONS_UPDATE:
- return normalizeAccount(state, action.account);
- case FOLLOWERS_FETCH_SUCCESS:
- case FOLLOWERS_EXPAND_SUCCESS:
- case FOLLOWING_FETCH_SUCCESS:
- case FOLLOWING_EXPAND_SUCCESS:
- case REBLOGS_FETCH_SUCCESS:
- case FAVOURITES_FETCH_SUCCESS:
- case COMPOSE_SUGGESTIONS_READY:
- case FOLLOW_REQUESTS_FETCH_SUCCESS:
- case FOLLOW_REQUESTS_EXPAND_SUCCESS:
- case BLOCKS_FETCH_SUCCESS:
- case BLOCKS_EXPAND_SUCCESS:
- case MUTES_FETCH_SUCCESS:
- case MUTES_EXPAND_SUCCESS:
- return action.accounts ? normalizeAccounts(state, action.accounts) : state;
- case NOTIFICATIONS_REFRESH_SUCCESS:
- case NOTIFICATIONS_EXPAND_SUCCESS:
- case SEARCH_FETCH_SUCCESS:
- return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
- case TIMELINE_REFRESH_SUCCESS:
- case TIMELINE_EXPAND_SUCCESS:
- case CONTEXT_FETCH_SUCCESS:
- case FAVOURITED_STATUSES_FETCH_SUCCESS:
- case FAVOURITED_STATUSES_EXPAND_SUCCESS:
- return normalizeAccountsFromStatuses(state, action.statuses);
- case REBLOG_SUCCESS:
- case FAVOURITE_SUCCESS:
- case UNREBLOG_SUCCESS:
- case UNFAVOURITE_SUCCESS:
- return normalizeAccountFromStatus(state, action.response);
- case TIMELINE_UPDATE:
- case STATUS_FETCH_SUCCESS:
- return normalizeAccountFromStatus(state, action.status);
- case ACCOUNT_FOLLOW_SUCCESS:
- if (action.alreadyFollowing) { return state; }
- return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
- case ACCOUNT_UNFOLLOW_SUCCESS:
- return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
deleted file mode 100644
index 089d920c3..000000000
--- a/app/javascript/mastodon/reducers/alerts.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import {
- ALERT_SHOW,
- ALERT_DISMISS,
- ALERT_CLEAR,
-} from '../actions/alerts';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-
-const initialState = ImmutableList([]);
-
-export default function alerts(state = initialState, action) {
- switch(action.type) {
- case ALERT_SHOW:
- return state.push(ImmutableMap({
- key: state.size > 0 ? state.last().get('key') + 1 : 0,
- title: action.title,
- message: action.message,
- }));
- case ALERT_DISMISS:
- return state.filterNot(item => item.get('key') === action.alert.key);
- case ALERT_CLEAR:
- return state.clear();
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js
deleted file mode 100644
index 4d86b0d7e..000000000
--- a/app/javascript/mastodon/reducers/cards.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
-
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-const initialState = ImmutableMap();
-
-export default function cards(state = initialState, action) {
- switch(action.type) {
- case STATUS_CARD_FETCH_SUCCESS:
- return state.set(action.id, fromJS(action.card));
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
deleted file mode 100644
index 5d0acbd60..000000000
--- a/app/javascript/mastodon/reducers/compose.js
+++ /dev/null
@@ -1,307 +0,0 @@
-import {
- COMPOSE_MOUNT,
- COMPOSE_UNMOUNT,
- COMPOSE_CHANGE,
- COMPOSE_REPLY,
- COMPOSE_REPLY_CANCEL,
- COMPOSE_MENTION,
- COMPOSE_SUBMIT_REQUEST,
- COMPOSE_SUBMIT_SUCCESS,
- COMPOSE_SUBMIT_FAIL,
- COMPOSE_UPLOAD_REQUEST,
- COMPOSE_UPLOAD_SUCCESS,
- COMPOSE_UPLOAD_FAIL,
- COMPOSE_UPLOAD_UNDO,
- COMPOSE_UPLOAD_PROGRESS,
- COMPOSE_SUGGESTIONS_CLEAR,
- COMPOSE_SUGGESTIONS_READY,
- COMPOSE_SUGGESTION_SELECT,
- COMPOSE_ADVANCED_OPTIONS_CHANGE,
- COMPOSE_SENSITIVITY_CHANGE,
- COMPOSE_SPOILERNESS_CHANGE,
- COMPOSE_SPOILER_TEXT_CHANGE,
- COMPOSE_VISIBILITY_CHANGE,
- COMPOSE_COMPOSING_CHANGE,
- COMPOSE_EMOJI_INSERT,
- COMPOSE_UPLOAD_CHANGE_REQUEST,
- COMPOSE_UPLOAD_CHANGE_SUCCESS,
- COMPOSE_UPLOAD_CHANGE_FAIL,
- COMPOSE_DOODLE_SET,
- COMPOSE_RESET,
-} from '../actions/compose';
-import { TIMELINE_DELETE } from '../actions/timelines';
-import { STORE_HYDRATE } from '../actions/store';
-import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
-import uuid from '../uuid';
-import { me } from '../initial_state';
-
-const initialState = ImmutableMap({
- mounted: false,
- advanced_options: ImmutableMap({
- do_not_federate: false,
- }),
- sensitive: false,
- spoiler: false,
- spoiler_text: '',
- privacy: null,
- text: '',
- focusDate: null,
- preselectDate: null,
- in_reply_to: null,
- is_composing: false,
- is_submitting: false,
- is_uploading: false,
- progress: 0,
- media_attachments: ImmutableList(),
- suggestion_token: null,
- suggestions: ImmutableList(),
- default_advanced_options: ImmutableMap({
- do_not_federate: false,
- }),
- default_privacy: 'public',
- default_sensitive: false,
- resetFileKey: Math.floor((Math.random() * 0x10000)),
- idempotencyKey: null,
- doodle: ImmutableMap({
- fg: 'rgb( 0, 0, 0)',
- bg: 'rgb(255, 255, 255)',
- swapped: false,
- mode: 'draw',
- size: 'normal',
- weight: 2,
- opacity: 1,
- adaptiveStroke: true,
- smoothing: false,
- }),
-});
-
-function statusToTextMentions(state, status) {
- let set = ImmutableOrderedSet([]);
-
- if (status.getIn(['account', 'id']) !== me) {
- set = set.add(`@${status.getIn(['account', 'acct'])} `);
- }
-
- return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
-};
-
-function clearAll(state) {
- return state.withMutations(map => {
- map.set('text', '');
- map.set('spoiler', false);
- map.set('spoiler_text', '');
- map.set('is_submitting', false);
- map.set('in_reply_to', null);
- map.set('advanced_options', state.get('default_advanced_options'));
- map.set('privacy', state.get('default_privacy'));
- map.set('sensitive', false);
- map.update('media_attachments', list => list.clear());
- map.set('idempotencyKey', uuid());
- });
-};
-
-function appendMedia(state, media) {
- const prevSize = state.get('media_attachments').size;
-
- return state.withMutations(map => {
- map.update('media_attachments', list => list.push(media));
- map.set('is_uploading', false);
- map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
- map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
- map.set('focusDate', new Date());
- map.set('idempotencyKey', uuid());
-
- if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
- map.set('sensitive', true);
- }
- });
-};
-
-function removeMedia(state, mediaId) {
- const media = state.get('media_attachments').find(item => item.get('id') === mediaId);
- const prevSize = state.get('media_attachments').size;
-
- return state.withMutations(map => {
- map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
- map.update('text', text => text.replace(media.get('text_url'), '').trim());
- map.set('idempotencyKey', uuid());
-
- if (prevSize === 1) {
- map.set('sensitive', false);
- }
- });
-};
-
-const insertSuggestion = (state, position, token, completion) => {
- return state.withMutations(map => {
- map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
- map.set('suggestion_token', null);
- map.update('suggestions', ImmutableList(), list => list.clear());
- map.set('focusDate', new Date());
- map.set('idempotencyKey', uuid());
- });
-};
-
-const insertEmoji = (state, position, emojiData) => {
- const emoji = emojiData.native;
-
- return state.withMutations(map => {
- map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
- map.set('focusDate', new Date());
- map.set('idempotencyKey', uuid());
- });
-};
-
-const privacyPreference = (a, b) => {
- if (a === 'direct' || b === 'direct') {
- return 'direct';
- } else if (a === 'private' || b === 'private') {
- return 'private';
- } else if (a === 'unlisted' || b === 'unlisted') {
- return 'unlisted';
- } else {
- return 'public';
- }
-};
-
-const hydrate = (state, hydratedState) => {
- state = clearAll(state.merge(hydratedState));
-
- if (hydratedState.has('text')) {
- state = state.set('text', hydratedState.get('text'));
- }
-
- return state;
-};
-
-export default function compose(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return hydrate(state, action.state.get('compose'));
- case COMPOSE_MOUNT:
- return state.set('mounted', true);
- case COMPOSE_UNMOUNT:
- return state
- .set('mounted', false)
- .set('is_composing', false);
- case COMPOSE_ADVANCED_OPTIONS_CHANGE:
- return state
- .set('advanced_options',
- state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
- .set('idempotencyKey', uuid());
- case COMPOSE_SENSITIVITY_CHANGE:
- return state.withMutations(map => {
- if (!state.get('spoiler')) {
- map.set('sensitive', !state.get('sensitive'));
- }
-
- map.set('idempotencyKey', uuid());
- });
- case COMPOSE_SPOILERNESS_CHANGE:
- return state.withMutations(map => {
- map.set('spoiler_text', '');
- map.set('spoiler', !state.get('spoiler'));
- map.set('idempotencyKey', uuid());
-
- if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
- map.set('sensitive', true);
- }
- });
- case COMPOSE_SPOILER_TEXT_CHANGE:
- return state
- .set('spoiler_text', action.text)
- .set('idempotencyKey', uuid());
- case COMPOSE_VISIBILITY_CHANGE:
- return state
- .set('privacy', action.value)
- .set('idempotencyKey', uuid());
- case COMPOSE_CHANGE:
- return state
- .set('text', action.text)
- .set('idempotencyKey', uuid());
- case COMPOSE_COMPOSING_CHANGE:
- return state.set('is_composing', action.value);
- case COMPOSE_REPLY:
- return state.withMutations(map => {
- map.set('in_reply_to', action.status.get('id'));
- map.set('text', statusToTextMentions(state, action.status));
- map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
- map.set('advanced_options', new ImmutableMap({
- do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')),
- }));
- map.set('focusDate', new Date());
- map.set('preselectDate', new Date());
- map.set('idempotencyKey', uuid());
-
- if (action.status.get('spoiler_text').length > 0) {
- map.set('spoiler', true);
- map.set('spoiler_text', action.status.get('spoiler_text'));
- } else {
- map.set('spoiler', false);
- map.set('spoiler_text', '');
- }
- });
- case COMPOSE_REPLY_CANCEL:
- case COMPOSE_RESET:
- return state.withMutations(map => {
- map.set('in_reply_to', null);
- map.set('text', '');
- map.set('spoiler', false);
- map.set('spoiler_text', '');
- map.set('privacy', state.get('default_privacy'));
- map.set('advanced_options', state.get('default_advanced_options'));
- map.set('idempotencyKey', uuid());
- });
- case COMPOSE_SUBMIT_REQUEST:
- case COMPOSE_UPLOAD_CHANGE_REQUEST:
- return state.set('is_submitting', true);
- case COMPOSE_SUBMIT_SUCCESS:
- return clearAll(state);
- case COMPOSE_SUBMIT_FAIL:
- case COMPOSE_UPLOAD_CHANGE_FAIL:
- return state.set('is_submitting', false);
- case COMPOSE_UPLOAD_REQUEST:
- return state.set('is_uploading', true);
- case COMPOSE_UPLOAD_SUCCESS:
- return appendMedia(state, fromJS(action.media));
- case COMPOSE_UPLOAD_FAIL:
- return state.set('is_uploading', false);
- case COMPOSE_UPLOAD_UNDO:
- return removeMedia(state, action.media_id);
- case COMPOSE_UPLOAD_PROGRESS:
- return state.set('progress', Math.round((action.loaded / action.total) * 100));
- case COMPOSE_MENTION:
- return state
- .update('text', text => `${text}@${action.account.get('acct')} `)
- .set('focusDate', new Date())
- .set('idempotencyKey', uuid());
- case COMPOSE_SUGGESTIONS_CLEAR:
- return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
- case COMPOSE_SUGGESTIONS_READY:
- return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
- case COMPOSE_SUGGESTION_SELECT:
- return insertSuggestion(state, action.position, action.token, action.completion);
- case TIMELINE_DELETE:
- if (action.id === state.get('in_reply_to')) {
- return state.set('in_reply_to', null);
- } else {
- return state;
- }
- case COMPOSE_EMOJI_INSERT:
- return insertEmoji(state, action.position, action.emoji);
- case COMPOSE_UPLOAD_CHANGE_SUCCESS:
- return state
- .set('is_submitting', false)
- .update('media_attachments', list => list.map(item => {
- if (item.get('id') === action.media.id) {
- return item.set('description', action.media.description);
- }
-
- return item;
- }));
- case COMPOSE_DOODLE_SET:
- return state.mergeIn(['doodle'], action.options);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
deleted file mode 100644
index 64d584a01..000000000
--- a/app/javascript/mastodon/reducers/contexts.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
-import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-
-const initialState = ImmutableMap({
- ancestors: ImmutableMap(),
- descendants: ImmutableMap(),
-});
-
-const normalizeContext = (state, id, ancestors, descendants) => {
- const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id));
- const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
-
- return state.withMutations(map => {
- map.setIn(['ancestors', id], ancestorsIds);
- map.setIn(['descendants', id], descendantsIds);
- });
-};
-
-const deleteFromContexts = (state, id) => {
- state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
- state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
- });
-
- state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
- state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
- });
-
- state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
-
- return state;
-};
-
-const updateContext = (state, status, references) => {
- return state.update('descendants', map => {
- references.forEach(parentId => {
- map = map.update(parentId, ImmutableList(), list => {
- if (list.includes(status.id)) {
- return list;
- }
-
- return list.push(status.id);
- });
- });
-
- return map;
- });
-};
-
-export default function contexts(state = initialState, action) {
- switch(action.type) {
- case CONTEXT_FETCH_SUCCESS:
- return normalizeContext(state, action.id, action.ancestors, action.descendants);
- case TIMELINE_DELETE:
- return deleteFromContexts(state, action.id);
- case TIMELINE_CONTEXT_UPDATE:
- return updateContext(state, action.status, action.references);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
deleted file mode 100644
index 307bcc7dc..000000000
--- a/app/javascript/mastodon/reducers/custom_emojis.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { List as ImmutableList } from 'immutable';
-import { STORE_HYDRATE } from '../actions/store';
-import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
-import { buildCustomEmojis } from '../features/emoji/emoji';
-
-const initialState = ImmutableList();
-
-export default function custom_emojis(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
- return action.state.get('custom_emojis');
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/height_cache.js b/app/javascript/mastodon/reducers/height_cache.js
deleted file mode 100644
index 2f5716fae..000000000
--- a/app/javascript/mastodon/reducers/height_cache.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Map as ImmutableMap } from 'immutable';
-import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from '../actions/height_cache';
-
-const initialState = ImmutableMap();
-
-const setHeight = (state, key, id, height) => {
- return state.update(key, ImmutableMap(), map => map.set(id, height));
-};
-
-const clearHeights = () => {
- return ImmutableMap();
-};
-
-export default function statuses(state = initialState, action) {
- switch(action.type) {
- case HEIGHT_CACHE_SET:
- return setHeight(state, action.key, action.id, action.height);
- case HEIGHT_CACHE_CLEAR:
- return clearHeights();
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
deleted file mode 100644
index 593d0efa4..000000000
--- a/app/javascript/mastodon/reducers/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { combineReducers } from 'redux-immutable';
-import timelines from './timelines';
-import meta from './meta';
-import alerts from './alerts';
-import { loadingBarReducer } from 'react-redux-loading-bar';
-import modal from './modal';
-import user_lists from './user_lists';
-import accounts from './accounts';
-import accounts_counters from './accounts_counters';
-import statuses from './statuses';
-import relationships from './relationships';
-import settings from './settings';
-import local_settings from '../../glitch/reducers/local_settings';
-import push_notifications from './push_notifications';
-import status_lists from './status_lists';
-import cards from './cards';
-import mutes from './mutes';
-import reports from './reports';
-import contexts from './contexts';
-import compose from './compose';
-import search from './search';
-import media_attachments from './media_attachments';
-import notifications from './notifications';
-import height_cache from './height_cache';
-import custom_emojis from './custom_emojis';
-
-const reducers = {
- timelines,
- meta,
- alerts,
- loadingBar: loadingBarReducer,
- modal,
- user_lists,
- status_lists,
- accounts,
- accounts_counters,
- statuses,
- relationships,
- settings,
- local_settings,
- push_notifications,
- cards,
- mutes,
- reports,
- contexts,
- compose,
- search,
- media_attachments,
- notifications,
- height_cache,
- custom_emojis,
-};
-
-export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/media_attachments.js b/app/javascript/mastodon/reducers/media_attachments.js
deleted file mode 100644
index 24119f628..000000000
--- a/app/javascript/mastodon/reducers/media_attachments.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { STORE_HYDRATE } from '../actions/store';
-import { Map as ImmutableMap } from 'immutable';
-
-const initialState = ImmutableMap({
- accept_content_types: [],
-});
-
-export default function meta(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return state.merge(action.state.get('media_attachments'));
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
deleted file mode 100644
index 36a5a1c35..000000000
--- a/app/javascript/mastodon/reducers/meta.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { STORE_HYDRATE } from '../actions/store';
-import { Map as ImmutableMap } from 'immutable';
-
-const initialState = ImmutableMap({
- streaming_api_base_url: null,
- access_token: null,
-});
-
-export default function meta(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return state.merge(action.state.get('meta'));
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
deleted file mode 100644
index 599a2443e..000000000
--- a/app/javascript/mastodon/reducers/modal.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
-
-const initialState = {
- modalType: null,
- modalProps: {},
-};
-
-export default function modal(state = initialState, action) {
- switch(action.type) {
- case MODAL_OPEN:
- return { modalType: action.modalType, modalProps: action.modalProps };
- case MODAL_CLOSE:
- return initialState;
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js
deleted file mode 100644
index a96232dbd..000000000
--- a/app/javascript/mastodon/reducers/mutes.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Immutable from 'immutable';
-
-import {
- MUTES_INIT_MODAL,
- MUTES_TOGGLE_HIDE_NOTIFICATIONS,
-} from '../actions/mutes';
-
-const initialState = Immutable.Map({
- new: Immutable.Map({
- isSubmitting: false,
- account: null,
- notifications: true,
- }),
-});
-
-export default function mutes(state = initialState, action) {
- switch (action.type) {
- case MUTES_INIT_MODAL:
- return state.withMutations((state) => {
- state.setIn(['new', 'isSubmitting'], false);
- state.setIn(['new', 'account'], action.account);
- state.setIn(['new', 'notifications'], true);
- });
- case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
- return state.updateIn(['new', 'notifications'], (old) => !old);
- default:
- return state;
- }
-}
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
deleted file mode 100644
index 48850ab01..000000000
--- a/app/javascript/mastodon/reducers/notifications.js
+++ /dev/null
@@ -1,191 +0,0 @@
-import {
- NOTIFICATIONS_UPDATE,
- NOTIFICATIONS_REFRESH_SUCCESS,
- NOTIFICATIONS_EXPAND_SUCCESS,
- NOTIFICATIONS_REFRESH_REQUEST,
- NOTIFICATIONS_EXPAND_REQUEST,
- NOTIFICATIONS_REFRESH_FAIL,
- NOTIFICATIONS_EXPAND_FAIL,
- NOTIFICATIONS_CLEAR,
- NOTIFICATIONS_SCROLL_TOP,
- NOTIFICATIONS_DELETE_MARKED_REQUEST,
- NOTIFICATIONS_DELETE_MARKED_SUCCESS,
- NOTIFICATION_MARK_FOR_DELETE,
- NOTIFICATIONS_DELETE_MARKED_FAIL,
- NOTIFICATIONS_ENTER_CLEARING_MODE,
- NOTIFICATIONS_MARK_ALL_FOR_DELETE,
-} from '../actions/notifications';
-import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
-} from '../actions/accounts';
-import { TIMELINE_DELETE } from '../actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-
-const initialState = ImmutableMap({
- items: ImmutableList(),
- next: null,
- top: true,
- unread: 0,
- loaded: false,
- isLoading: true,
- cleaningMode: false,
- // notification removal mark of new notifs loaded whilst cleaningMode is true.
- markNewForDelete: false,
-});
-
-const notificationToMap = (state, notification) => ImmutableMap({
- id: notification.id,
- type: notification.type,
- account: notification.account.id,
- markedForDelete: state.get('markNewForDelete'),
- status: notification.status ? notification.status.id : null,
-});
-
-const normalizeNotification = (state, notification) => {
- const top = state.get('top');
-
- if (!top) {
- state = state.update('unread', unread => unread + 1);
- }
-
- return state.update('items', list => {
- if (top && list.size > 40) {
- list = list.take(20);
- }
-
- return list.unshift(notificationToMap(state, notification));
- });
-};
-
-const normalizeNotifications = (state, notifications, next) => {
- let items = ImmutableList();
- const loaded = state.get('loaded');
-
- notifications.forEach((n, i) => {
- items = items.set(i, notificationToMap(state, n));
- });
-
- if (state.get('next') === null) {
- state = state.set('next', next);
- }
-
- return state
- .update('items', list => loaded ? items.concat(list) : list.concat(items))
- .set('loaded', true)
- .set('isLoading', false);
-};
-
-const appendNormalizedNotifications = (state, notifications, next) => {
- let items = ImmutableList();
-
- notifications.forEach((n, i) => {
- items = items.set(i, notificationToMap(state, n));
- });
-
- return state
- .update('items', list => list.concat(items))
- .set('next', next)
- .set('isLoading', false);
-};
-
-const filterNotifications = (state, relationship) => {
- return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
-};
-
-const updateTop = (state, top) => {
- if (top) {
- state = state.set('unread', 0);
- }
-
- return state.set('top', top);
-};
-
-const deleteByStatus = (state, statusId) => {
- return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
-};
-
-const markForDelete = (state, notificationId, yes) => {
- return state.update('items', list => list.map(item => {
- if(item.get('id') === notificationId) {
- return item.set('markedForDelete', yes);
- } else {
- return item;
- }
- }));
-};
-
-const markAllForDelete = (state, yes) => {
- return state.update('items', list => list.map(item => {
- if(yes !== null) {
- return item.set('markedForDelete', yes);
- } else {
- return item.set('markedForDelete', !item.get('markedForDelete'));
- }
- }));
-};
-
-const unmarkAllForDelete = (state) => {
- return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
-};
-
-const deleteMarkedNotifs = (state) => {
- return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
-};
-
-export default function notifications(state = initialState, action) {
- let st;
-
- switch(action.type) {
- case NOTIFICATIONS_REFRESH_REQUEST:
- case NOTIFICATIONS_EXPAND_REQUEST:
- case NOTIFICATIONS_DELETE_MARKED_REQUEST:
- return state.set('isLoading', true);
- case NOTIFICATIONS_DELETE_MARKED_FAIL:
- case NOTIFICATIONS_REFRESH_FAIL:
- case NOTIFICATIONS_EXPAND_FAIL:
- return state.set('isLoading', false);
- case NOTIFICATIONS_SCROLL_TOP:
- return updateTop(state, action.top);
- case NOTIFICATIONS_UPDATE:
- return normalizeNotification(state, action.notification);
- case NOTIFICATIONS_REFRESH_SUCCESS:
- return normalizeNotifications(state, action.notifications, action.next);
- case NOTIFICATIONS_EXPAND_SUCCESS:
- return appendNormalizedNotifications(state, action.notifications, action.next);
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterNotifications(state, action.relationship);
- case NOTIFICATIONS_CLEAR:
- return state.set('items', ImmutableList()).set('next', null);
- case TIMELINE_DELETE:
- return deleteByStatus(state, action.id);
-
- case NOTIFICATION_MARK_FOR_DELETE:
- return markForDelete(state, action.id, action.yes);
-
- case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
- return deleteMarkedNotifs(state).set('isLoading', false);
-
- case NOTIFICATIONS_ENTER_CLEARING_MODE:
- st = state.set('cleaningMode', action.yes);
- if (!action.yes) {
- return unmarkAllForDelete(st).set('markNewForDelete', false);
- } else {
- return st;
- }
-
- case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
- st = state;
- if (action.yes === null) {
- // Toggle - this is a bit confusing, as it toggles the all-none mode
- //st = st.set('markNewForDelete', !st.get('markNewForDelete'));
- } else {
- st = st.set('markNewForDelete', action.yes);
- }
- return markAllForDelete(st, action.yes);
-
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
deleted file mode 100644
index 31a40d246..000000000
--- a/app/javascript/mastodon/reducers/push_notifications.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { STORE_HYDRATE } from '../actions/store';
-import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications';
-import Immutable from 'immutable';
-
-const initialState = Immutable.Map({
- subscription: null,
- alerts: new Immutable.Map({
- follow: false,
- favourite: false,
- reblog: false,
- mention: false,
- }),
- isSubscribed: false,
- browserSupport: false,
-});
-
-export default function push_subscriptions(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE: {
- const push_subscription = action.state.get('push_subscription');
-
- if (push_subscription) {
- return state
- .set('subscription', new Immutable.Map({
- id: push_subscription.get('id'),
- endpoint: push_subscription.get('endpoint'),
- }))
- .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
- .set('isSubscribed', true);
- }
-
- return state;
- }
- case SET_SUBSCRIPTION:
- return state
- .set('subscription', new Immutable.Map({
- id: action.subscription.id,
- endpoint: action.subscription.endpoint,
- }))
- .set('alerts', new Immutable.Map(action.subscription.alerts))
- .set('isSubscribed', true);
- case SET_BROWSER_SUPPORT:
- return state.set('browserSupport', action.value);
- case CLEAR_SUBSCRIPTION:
- return initialState;
- case ALERTS_CHANGE:
- return state.setIn(action.key, action.value);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
deleted file mode 100644
index c7b04a668..000000000
--- a/app/javascript/mastodon/reducers/relationships.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import {
- ACCOUNT_FOLLOW_SUCCESS,
- ACCOUNT_UNFOLLOW_SUCCESS,
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_UNBLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
- ACCOUNT_UNMUTE_SUCCESS,
- RELATIONSHIPS_FETCH_SUCCESS,
-} from '../actions/accounts';
-import {
- DOMAIN_BLOCK_SUCCESS,
- DOMAIN_UNBLOCK_SUCCESS,
-} from '../actions/domain_blocks';
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
-
-const normalizeRelationships = (state, relationships) => {
- relationships.forEach(relationship => {
- state = normalizeRelationship(state, relationship);
- });
-
- return state;
-};
-
-const initialState = ImmutableMap();
-
-export default function relationships(state = initialState, action) {
- switch(action.type) {
- case ACCOUNT_FOLLOW_SUCCESS:
- case ACCOUNT_UNFOLLOW_SUCCESS:
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_UNBLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- case ACCOUNT_UNMUTE_SUCCESS:
- return normalizeRelationship(state, action.relationship);
- case RELATIONSHIPS_FETCH_SUCCESS:
- return normalizeRelationships(state, action.relationships);
- case DOMAIN_BLOCK_SUCCESS:
- return state.setIn([action.accountId, 'domain_blocking'], true);
- case DOMAIN_UNBLOCK_SUCCESS:
- return state.setIn([action.accountId, 'domain_blocking'], false);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
deleted file mode 100644
index a08bbec38..000000000
--- a/app/javascript/mastodon/reducers/reports.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
- REPORT_INIT,
- REPORT_SUBMIT_REQUEST,
- REPORT_SUBMIT_SUCCESS,
- REPORT_SUBMIT_FAIL,
- REPORT_CANCEL,
- REPORT_STATUS_TOGGLE,
- REPORT_COMMENT_CHANGE,
-} from '../actions/reports';
-import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
-
-const initialState = ImmutableMap({
- new: ImmutableMap({
- isSubmitting: false,
- account_id: null,
- status_ids: ImmutableSet(),
- comment: '',
- }),
-});
-
-export default function reports(state = initialState, action) {
- switch(action.type) {
- case REPORT_INIT:
- return state.withMutations(map => {
- map.setIn(['new', 'isSubmitting'], false);
- map.setIn(['new', 'account_id'], action.account.get('id'));
-
- if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
- map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
- map.setIn(['new', 'comment'], '');
- } else if (action.status) {
- map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
- }
- });
- case REPORT_STATUS_TOGGLE:
- return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => {
- if (action.checked) {
- return set.add(action.statusId);
- }
-
- return set.remove(action.statusId);
- });
- case REPORT_COMMENT_CHANGE:
- return state.setIn(['new', 'comment'], action.comment);
- case REPORT_SUBMIT_REQUEST:
- return state.setIn(['new', 'isSubmitting'], true);
- case REPORT_SUBMIT_FAIL:
- return state.setIn(['new', 'isSubmitting'], false);
- case REPORT_CANCEL:
- case REPORT_SUBMIT_SUCCESS:
- return state.withMutations(map => {
- map.setIn(['new', 'account_id'], null);
- map.setIn(['new', 'status_ids'], ImmutableSet());
- map.setIn(['new', 'comment'], '');
- map.setIn(['new', 'isSubmitting'], false);
- });
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
deleted file mode 100644
index 08d90e4e8..000000000
--- a/app/javascript/mastodon/reducers/search.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import {
- SEARCH_CHANGE,
- SEARCH_CLEAR,
- SEARCH_FETCH_SUCCESS,
- SEARCH_SHOW,
-} from '../actions/search';
-import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-
-const initialState = ImmutableMap({
- value: '',
- submitted: false,
- hidden: false,
- results: ImmutableMap(),
-});
-
-export default function search(state = initialState, action) {
- switch(action.type) {
- case SEARCH_CHANGE:
- return state.set('value', action.value);
- case SEARCH_CLEAR:
- return state.withMutations(map => {
- map.set('value', '');
- map.set('results', ImmutableMap());
- map.set('submitted', false);
- map.set('hidden', false);
- });
- case SEARCH_SHOW:
- return state.set('hidden', false);
- case COMPOSE_REPLY:
- case COMPOSE_MENTION:
- return state.set('hidden', true);
- case SEARCH_FETCH_SUCCESS:
- return state.set('results', ImmutableMap({
- accounts: ImmutableList(action.results.accounts.map(item => item.id)),
- statuses: ImmutableList(action.results.statuses.map(item => item.id)),
- hashtags: ImmutableList(action.results.hashtags),
- })).set('submitted', true);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
deleted file mode 100644
index 4b8a652d1..000000000
--- a/app/javascript/mastodon/reducers/settings.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
-import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
-import { STORE_HYDRATE } from '../actions/store';
-import { EMOJI_USE } from '../actions/emojis';
-import { Map as ImmutableMap, fromJS } from 'immutable';
-import uuid from '../uuid';
-
-const initialState = ImmutableMap({
- saved: true,
-
- onboarded: false,
- layout: 'auto',
-
- skinTone: 1,
-
- home: ImmutableMap({
- shows: ImmutableMap({
- reblog: true,
- reply: true,
- }),
-
- regex: ImmutableMap({
- body: '',
- }),
- }),
-
- notifications: ImmutableMap({
- alerts: ImmutableMap({
- follow: true,
- favourite: true,
- reblog: true,
- mention: true,
- }),
-
- shows: ImmutableMap({
- follow: true,
- favourite: true,
- reblog: true,
- mention: true,
- }),
-
- sounds: ImmutableMap({
- follow: true,
- favourite: true,
- reblog: true,
- mention: true,
- }),
- }),
-
- community: ImmutableMap({
- regex: ImmutableMap({
- body: '',
- }),
- }),
-
- public: ImmutableMap({
- regex: ImmutableMap({
- body: '',
- }),
- }),
-
- direct: ImmutableMap({
- regex: ImmutableMap({
- body: '',
- }),
- }),
-});
-
-const defaultColumns = fromJS([
- { id: 'COMPOSE', uuid: uuid(), params: {} },
- { id: 'HOME', uuid: uuid(), params: {} },
- { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
-]);
-
-const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
-
-const moveColumn = (state, uuid, direction) => {
- const columns = state.get('columns');
- const index = columns.findIndex(item => item.get('uuid') === uuid);
- const newIndex = index + direction;
-
- let newColumns;
-
- newColumns = columns.splice(index, 1);
- newColumns = newColumns.splice(newIndex, 0, columns.get(index));
-
- return state
- .set('columns', newColumns)
- .set('saved', false);
-};
-
-const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
-
-export default function settings(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- return hydrate(state, action.state.get('settings'));
- case SETTING_CHANGE:
- return state
- .setIn(action.key, action.value)
- .set('saved', false);
- case COLUMN_ADD:
- return state
- .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
- .set('saved', false);
- case COLUMN_REMOVE:
- return state
- .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
- .set('saved', false);
- case COLUMN_MOVE:
- return moveColumn(state, action.uuid, action.direction);
- case EMOJI_USE:
- return updateFrequentEmojis(state, action.emoji);
- case SETTING_SAVE:
- return state.set('saved', true);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
deleted file mode 100644
index c4aeb338f..000000000
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import {
- FAVOURITED_STATUSES_FETCH_SUCCESS,
- FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import {
- PINNED_STATUSES_FETCH_SUCCESS,
-} from '../actions/pin_statuses';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-import {
- FAVOURITE_SUCCESS,
- UNFAVOURITE_SUCCESS,
- PIN_SUCCESS,
- UNPIN_SUCCESS,
-} from '../actions/interactions';
-
-const initialState = ImmutableMap({
- favourites: ImmutableMap({
- next: null,
- loaded: false,
- items: ImmutableList(),
- }),
- pins: ImmutableMap({
- next: null,
- loaded: false,
- items: ImmutableList(),
- }),
-});
-
-const normalizeList = (state, listType, statuses, next) => {
- return state.update(listType, listMap => listMap.withMutations(map => {
- map.set('next', next);
- map.set('loaded', true);
- map.set('items', ImmutableList(statuses.map(item => item.id)));
- }));
-};
-
-const appendToList = (state, listType, statuses, next) => {
- return state.update(listType, listMap => listMap.withMutations(map => {
- map.set('next', next);
- map.set('items', map.get('items').concat(statuses.map(item => item.id)));
- }));
-};
-
-const prependOneToList = (state, listType, status) => {
- return state.update(listType, listMap => listMap.withMutations(map => {
- map.set('items', map.get('items').unshift(status.get('id')));
- }));
-};
-
-const removeOneFromList = (state, listType, status) => {
- return state.update(listType, listMap => listMap.withMutations(map => {
- map.set('items', map.get('items').filter(item => item !== status.get('id')));
- }));
-};
-
-export default function statusLists(state = initialState, action) {
- switch(action.type) {
- case FAVOURITED_STATUSES_FETCH_SUCCESS:
- return normalizeList(state, 'favourites', action.statuses, action.next);
- case FAVOURITED_STATUSES_EXPAND_SUCCESS:
- return appendToList(state, 'favourites', action.statuses, action.next);
- case FAVOURITE_SUCCESS:
- return prependOneToList(state, 'favourites', action.status);
- case UNFAVOURITE_SUCCESS:
- return removeOneFromList(state, 'favourites', action.status);
- case PINNED_STATUSES_FETCH_SUCCESS:
- return normalizeList(state, 'pins', action.statuses, action.next);
- case PIN_SUCCESS:
- return prependOneToList(state, 'pins', action.status);
- case UNPIN_SUCCESS:
- return removeOneFromList(state, 'pins', action.status);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
deleted file mode 100644
index b1fb4c5da..000000000
--- a/app/javascript/mastodon/reducers/statuses.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import {
- REBLOG_REQUEST,
- REBLOG_SUCCESS,
- REBLOG_FAIL,
- UNREBLOG_SUCCESS,
- FAVOURITE_REQUEST,
- FAVOURITE_SUCCESS,
- FAVOURITE_FAIL,
- UNFAVOURITE_SUCCESS,
- PIN_SUCCESS,
- UNPIN_SUCCESS,
-} from '../actions/interactions';
-import {
- STATUS_FETCH_SUCCESS,
- CONTEXT_FETCH_SUCCESS,
- STATUS_MUTE_SUCCESS,
- STATUS_UNMUTE_SUCCESS,
-} from '../actions/statuses';
-import {
- TIMELINE_REFRESH_SUCCESS,
- TIMELINE_UPDATE,
- TIMELINE_DELETE,
- TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
-} from '../actions/accounts';
-import {
- NOTIFICATIONS_UPDATE,
- NOTIFICATIONS_REFRESH_SUCCESS,
- NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
- FAVOURITED_STATUSES_FETCH_SUCCESS,
- FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import {
- PINNED_STATUSES_FETCH_SUCCESS,
-} from '../actions/pin_statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import emojify from '../features/emoji/emoji';
-import { Map as ImmutableMap, fromJS } from 'immutable';
-import escapeTextContentForBrowser from 'escape-html';
-
-const domParser = new DOMParser();
-
-const normalizeStatus = (state, status) => {
- if (!status) {
- return state;
- }
-
- const normalStatus = { ...status };
- normalStatus.account = status.account.id;
-
- if (status.reblog && status.reblog.id) {
- state = normalizeStatus(state, status.reblog);
- normalStatus.reblog = status.reblog.id;
- }
-
- const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/ /g, '\n').replace(/<\/p>/g, '\n\n');
-
- const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
- obj[`:${emoji.shortcode}:`] = emoji;
- return obj;
- }, {});
-
- normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
- normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
- normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
-
- return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
-};
-
-const normalizeStatuses = (state, statuses) => {
- statuses.forEach(status => {
- state = normalizeStatus(state, status);
- });
-
- return state;
-};
-
-const deleteStatus = (state, id, references) => {
- references.forEach(ref => {
- state = deleteStatus(state, ref[0], []);
- });
-
- return state.delete(id);
-};
-
-const filterStatuses = (state, relationship) => {
- state.forEach(status => {
- if (status.get('account') !== relationship.id) {
- return;
- }
-
- state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
- });
-
- return state;
-};
-
-const initialState = ImmutableMap();
-
-export default function statuses(state = initialState, action) {
- switch(action.type) {
- case TIMELINE_UPDATE:
- case STATUS_FETCH_SUCCESS:
- case NOTIFICATIONS_UPDATE:
- return normalizeStatus(state, action.status);
- case REBLOG_SUCCESS:
- case UNREBLOG_SUCCESS:
- case FAVOURITE_SUCCESS:
- case UNFAVOURITE_SUCCESS:
- case PIN_SUCCESS:
- case UNPIN_SUCCESS:
- return normalizeStatus(state, action.response);
- case FAVOURITE_REQUEST:
- return state.setIn([action.status.get('id'), 'favourited'], true);
- case FAVOURITE_FAIL:
- return state.setIn([action.status.get('id'), 'favourited'], false);
- case REBLOG_REQUEST:
- return state.setIn([action.status.get('id'), 'reblogged'], true);
- case REBLOG_FAIL:
- return state.setIn([action.status.get('id'), 'reblogged'], false);
- case STATUS_MUTE_SUCCESS:
- return state.setIn([action.id, 'muted'], true);
- case STATUS_UNMUTE_SUCCESS:
- return state.setIn([action.id, 'muted'], false);
- case TIMELINE_REFRESH_SUCCESS:
- case TIMELINE_EXPAND_SUCCESS:
- case CONTEXT_FETCH_SUCCESS:
- case NOTIFICATIONS_REFRESH_SUCCESS:
- case NOTIFICATIONS_EXPAND_SUCCESS:
- case FAVOURITED_STATUSES_FETCH_SUCCESS:
- case FAVOURITED_STATUSES_EXPAND_SUCCESS:
- case PINNED_STATUSES_FETCH_SUCCESS:
- case SEARCH_FETCH_SUCCESS:
- return normalizeStatuses(state, action.statuses);
- case TIMELINE_DELETE:
- return deleteStatus(state, action.id, action.references);
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterStatuses(state, action.relationship);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
deleted file mode 100644
index bee4c4ef9..000000000
--- a/app/javascript/mastodon/reducers/timelines.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import {
- TIMELINE_REFRESH_REQUEST,
- TIMELINE_REFRESH_SUCCESS,
- TIMELINE_REFRESH_FAIL,
- TIMELINE_UPDATE,
- TIMELINE_DELETE,
- TIMELINE_EXPAND_SUCCESS,
- TIMELINE_EXPAND_REQUEST,
- TIMELINE_EXPAND_FAIL,
- TIMELINE_SCROLL_TOP,
- TIMELINE_CONNECT,
- TIMELINE_DISCONNECT,
-} from '../actions/timelines';
-import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
- ACCOUNT_UNFOLLOW_SUCCESS,
-} from '../actions/accounts';
-import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
-
-const initialState = ImmutableMap();
-
-const initialTimeline = ImmutableMap({
- unread: 0,
- online: false,
- top: true,
- loaded: false,
- isLoading: false,
- next: false,
- items: ImmutableList(),
-});
-
-const normalizeTimeline = (state, timeline, statuses, next) => {
- const oldIds = state.getIn([timeline, 'items'], ImmutableList());
- const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
- const wasLoaded = state.getIn([timeline, 'loaded']);
- const hadNext = state.getIn([timeline, 'next']);
-
- return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
- mMap.set('loaded', true);
- mMap.set('isLoading', false);
- if (!hadNext) mMap.set('next', next);
- mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids);
- }));
-};
-
-const appendNormalizedTimeline = (state, timeline, statuses, next) => {
- const oldIds = state.getIn([timeline, 'items'], ImmutableList());
- const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-
- return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
- mMap.set('isLoading', false);
- mMap.set('next', next);
- mMap.set('items', oldIds.concat(ids));
- }));
-};
-
-const updateTimeline = (state, timeline, status, references) => {
- const top = state.getIn([timeline, 'top']);
- const ids = state.getIn([timeline, 'items'], ImmutableList());
- const includesId = ids.includes(status.get('id'));
- const unread = state.getIn([timeline, 'unread'], 0);
-
- if (includesId) {
- return state;
- }
-
- let newIds = ids;
-
- return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
- if (!top) mMap.set('unread', unread + 1);
- if (top && ids.size > 40) newIds = newIds.take(20);
- if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
- mMap.set('items', newIds.unshift(status.get('id')));
- }));
-};
-
-const deleteStatus = (state, id, accountId, references) => {
- state.keySeq().forEach(timeline => {
- state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
- });
-
- // Remove reblogs of deleted status
- references.forEach(ref => {
- state = deleteStatus(state, ref[0], ref[1], []);
- });
-
- return state;
-};
-
-const filterTimelines = (state, relationship, statuses) => {
- let references;
-
- statuses.forEach(status => {
- if (status.get('account') !== relationship.id) {
- return;
- }
-
- references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
- state = deleteStatus(state, status.get('id'), status.get('account'), references);
- });
-
- return state;
-};
-
-const filterTimeline = (timeline, state, relationship, statuses) =>
- state.updateIn([timeline, 'items'], ImmutableList(), list =>
- list.filterNot(statusId =>
- statuses.getIn([statusId, 'account']) === relationship.id
- ));
-
-const updateTop = (state, timeline, top) => {
- return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
- if (top) mMap.set('unread', 0);
- mMap.set('top', top);
- }));
-};
-
-export default function timelines(state = initialState, action) {
- switch(action.type) {
- case TIMELINE_REFRESH_REQUEST:
- case TIMELINE_EXPAND_REQUEST:
- return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
- case TIMELINE_REFRESH_FAIL:
- case TIMELINE_EXPAND_FAIL:
- return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
- case TIMELINE_REFRESH_SUCCESS:
- return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next);
- case TIMELINE_EXPAND_SUCCESS:
- return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
- case TIMELINE_UPDATE:
- return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
- case TIMELINE_DELETE:
- return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterTimelines(state, action.relationship, action.statuses);
- case ACCOUNT_UNFOLLOW_SUCCESS:
- return filterTimeline('home', state, action.relationship, action.statuses);
- case TIMELINE_SCROLL_TOP:
- return updateTop(state, action.timeline, action.top);
- case TIMELINE_CONNECT:
- return state.update(action.timeline, initialTimeline, map => map.set('online', true));
- case TIMELINE_DISCONNECT:
- return state.update(action.timeline, initialTimeline, map => map.set('online', false));
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
deleted file mode 100644
index 8db18c5dc..000000000
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import {
- FOLLOWERS_FETCH_SUCCESS,
- FOLLOWERS_EXPAND_SUCCESS,
- FOLLOWING_FETCH_SUCCESS,
- FOLLOWING_EXPAND_SUCCESS,
- FOLLOW_REQUESTS_FETCH_SUCCESS,
- FOLLOW_REQUESTS_EXPAND_SUCCESS,
- FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
- FOLLOW_REQUEST_REJECT_SUCCESS,
-} from '../actions/accounts';
-import {
- REBLOGS_FETCH_SUCCESS,
- FAVOURITES_FETCH_SUCCESS,
-} from '../actions/interactions';
-import {
- BLOCKS_FETCH_SUCCESS,
- BLOCKS_EXPAND_SUCCESS,
-} from '../actions/blocks';
-import {
- MUTES_FETCH_SUCCESS,
- MUTES_EXPAND_SUCCESS,
-} from '../actions/mutes';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-
-const initialState = ImmutableMap({
- followers: ImmutableMap(),
- following: ImmutableMap(),
- reblogged_by: ImmutableMap(),
- favourited_by: ImmutableMap(),
- follow_requests: ImmutableMap(),
- blocks: ImmutableMap(),
- mutes: ImmutableMap(),
-});
-
-const normalizeList = (state, type, id, accounts, next) => {
- return state.setIn([type, id], ImmutableMap({
- next,
- items: ImmutableList(accounts.map(item => item.id)),
- }));
-};
-
-const appendToList = (state, type, id, accounts, next) => {
- return state.updateIn([type, id], map => {
- return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
- });
-};
-
-export default function userLists(state = initialState, action) {
- switch(action.type) {
- case FOLLOWERS_FETCH_SUCCESS:
- return normalizeList(state, 'followers', action.id, action.accounts, action.next);
- case FOLLOWERS_EXPAND_SUCCESS:
- return appendToList(state, 'followers', action.id, action.accounts, action.next);
- case FOLLOWING_FETCH_SUCCESS:
- return normalizeList(state, 'following', action.id, action.accounts, action.next);
- case FOLLOWING_EXPAND_SUCCESS:
- return appendToList(state, 'following', action.id, action.accounts, action.next);
- case REBLOGS_FETCH_SUCCESS:
- return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
- case FAVOURITES_FETCH_SUCCESS:
- return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
- case FOLLOW_REQUESTS_FETCH_SUCCESS:
- return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
- case FOLLOW_REQUESTS_EXPAND_SUCCESS:
- return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
- case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
- case FOLLOW_REQUEST_REJECT_SUCCESS:
- return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
- case BLOCKS_FETCH_SUCCESS:
- return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
- case BLOCKS_EXPAND_SUCCESS:
- return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
- case MUTES_FETCH_SUCCESS:
- return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
- case MUTES_EXPAND_SUCCESS:
- return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
- default:
- return state;
- }
-};
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
deleted file mode 100644
index 00870a15d..000000000
--- a/app/javascript/mastodon/rtl.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// U+0590 to U+05FF - Hebrew
-// U+0600 to U+06FF - Arabic
-// U+0700 to U+074F - Syriac
-// U+0750 to U+077F - Arabic Supplement
-// U+0780 to U+07BF - Thaana
-// U+07C0 to U+07FF - N'Ko
-// U+0800 to U+083F - Samaritan
-// U+08A0 to U+08FF - Arabic Extended-A
-// U+FB1D to U+FB4F - Hebrew presentation forms
-// U+FB50 to U+FDFF - Arabic presentation forms A
-// U+FE70 to U+FEFF - Arabic presentation forms B
-
-const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
-
-export function isRtl(text) {
- if (text.length === 0) {
- return false;
- }
-
- text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
- text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
- text = text.replace(/\s+/g, '');
-
- const matches = text.match(rtlChars);
-
- if (!matches) {
- return false;
- }
-
- return matches.length / text.length > 0.3;
-};
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
deleted file mode 100644
index 2af07e0fb..000000000
--- a/app/javascript/mastodon/scroll.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
-
-const scroll = (node, key, target) => {
- const startTime = Date.now();
- const offset = node[key];
- const gap = target - offset;
- const duration = 1000;
- let interrupt = false;
-
- const step = () => {
- const elapsed = Date.now() - startTime;
- const percentage = elapsed / duration;
-
- if (percentage > 1 || interrupt) {
- return;
- }
-
- node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
- requestAnimationFrame(step);
- };
-
- step();
-
- return () => {
- interrupt = true;
- };
-};
-
-export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
-export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
deleted file mode 100644
index d26d1b727..000000000
--- a/app/javascript/mastodon/selectors/index.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
-
-const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
-const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
-const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
-
-export const makeGetAccount = () => {
- return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
- if (base === null) {
- return null;
- }
-
- return base.merge(counters).set('relationship', relationship);
- });
-};
-
-export const makeGetStatus = () => {
- return createSelector(
- [
- (state, id) => state.getIn(['statuses', id]),
- (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
- (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
- (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
- ],
-
- (statusBase, statusReblog, accountBase, accountReblog) => {
- if (!statusBase) {
- return null;
- }
-
- if (statusReblog) {
- statusReblog = statusReblog.set('account', accountReblog);
- } else {
- statusReblog = null;
- }
-
- return statusBase.withMutations(map => {
- map.set('reblog', statusReblog);
- map.set('account', accountBase);
- });
- }
- );
-};
-
-const getAlertsBase = state => state.get('alerts');
-
-export const getAlerts = createSelector([getAlertsBase], (base) => {
- let arr = [];
-
- base.forEach(item => {
- arr.push({
- message: item.get('message'),
- title: item.get('title'),
- key: item.get('key'),
- dismissAfter: 5000,
- barStyle: {
- zIndex: 200,
- },
- });
- });
-
- return arr;
-});
-
-export const makeGetNotification = () => {
- return createSelector([
- (_, base) => base,
- (state, _, accountId) => state.getIn(['accounts', accountId]),
- ], (base, account) => {
- return base.set('account', account);
- });
-};
-
-export const getAccountGallery = createSelector([
- (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
- state => state.get('statuses'),
-], (statusIds, statuses) => {
- let medias = ImmutableList();
-
- statusIds.forEach(statusId => {
- const status = statuses.get(statusId);
- medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
- });
-
- return medias;
-});
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
deleted file mode 100644
index eea4cfc3c..000000000
--- a/app/javascript/mastodon/service_worker/entry.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import './web_push_notifications';
-
-// Cause a new version of a registered Service Worker to replace an existing one
-// that is already installed, and replace the currently active worker on open pages.
-self.addEventListener('install', function(event) {
- event.waitUntil(self.skipWaiting());
-});
-self.addEventListener('activate', function(event) {
- event.waitUntil(self.clients.claim());
-});
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
deleted file mode 100644
index f63cff335..000000000
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ /dev/null
@@ -1,159 +0,0 @@
-const MAX_NOTIFICATIONS = 5;
-const GROUP_TAG = 'tag';
-
-// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
-const formatGroupTitle = (message, count) => message.replace('%{count}', count);
-
-const notify = options =>
- self.registration.getNotifications().then(notifications => {
- if (notifications.length === MAX_NOTIFICATIONS) {
- // Reached the maximum number of notifications, proceed with grouping
- const group = {
- title: formatGroupTitle(options.data.message, notifications.length + 1),
- body: notifications
- .sort((n1, n2) => n1.timestamp < n2.timestamp)
- .map(notification => notification.title).join('\n'),
- badge: '/badge.png',
- icon: '/android-chrome-192x192.png',
- tag: GROUP_TAG,
- data: {
- url: (new URL('/web/notifications', self.location)).href,
- count: notifications.length + 1,
- message: options.data.message,
- },
- };
-
- notifications.forEach(notification => notification.close());
-
- return self.registration.showNotification(group.title, group);
- } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
- // Already grouped, proceed with appending the notification to the group
- const group = cloneNotification(notifications[0]);
-
- group.title = formatGroupTitle(group.data.message, group.data.count + 1);
- group.body = `${options.title}\n${group.body}`;
- group.data = { ...group.data, count: group.data.count + 1 };
-
- return self.registration.showNotification(group.title, group);
- }
-
- return self.registration.showNotification(options.title, options);
- });
-
-const handlePush = (event) => {
- const options = event.data.json();
-
- options.body = options.data.nsfw || options.data.content;
- options.dir = options.data.dir;
- options.image = options.image || undefined; // Null results in a network request (404)
- options.timestamp = options.timestamp && new Date(options.timestamp);
-
- const expandAction = options.data.actions.find(action => action.todo === 'expand');
-
- if (expandAction) {
- options.actions = [expandAction];
- options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
- options.data.hiddenImage = options.image;
- options.image = undefined;
- } else {
- options.actions = options.data.actions;
- }
-
- event.waitUntil(notify(options));
-};
-
-const cloneNotification = (notification) => {
- const clone = { };
-
- for(var k in notification) {
- clone[k] = notification[k];
- }
-
- return clone;
-};
-
-const expandNotification = (notification) => {
- const nextNotification = cloneNotification(notification);
-
- nextNotification.body = notification.data.content;
- nextNotification.image = notification.data.hiddenImage;
- nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
-
- return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const makeRequest = (notification, action) =>
- fetch(action.action, {
- headers: {
- 'Authorization': `Bearer ${notification.data.access_token}`,
- 'Content-Type': 'application/json',
- },
- method: action.method,
- credentials: 'include',
- });
-
-const findBestClient = clients => {
- const focusedClient = clients.find(client => client.focused);
- const visibleClient = clients.find(client => client.visibilityState === 'visible');
-
- return focusedClient || visibleClient || clients[0];
-};
-
-const openUrl = url =>
- self.clients.matchAll({ type: 'window' }).then(clientList => {
- if (clientList.length !== 0) {
- const webClients = clientList.filter(client => /\/web\//.test(client.url));
-
- if (webClients.length !== 0) {
- const client = findBestClient(webClients);
- const { pathname } = new URL(url);
-
- if (pathname.startsWith('/web/')) {
- return client.focus().then(client => client.postMessage({
- type: 'navigate',
- path: pathname.slice('/web/'.length - 1),
- }));
- }
- } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
- const client = findBestClient(clientList);
-
- return client.navigate(url).then(client => client.focus());
- }
- }
-
- return self.clients.openWindow(url);
- });
-
-const removeActionFromNotification = (notification, action) => {
- const actions = notification.actions.filter(act => act.action !== action.action);
- const nextNotification = cloneNotification(notification);
-
- nextNotification.actions = actions;
-
- return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const handleNotificationClick = (event) => {
- const reactToNotificationClick = new Promise((resolve, reject) => {
- if (event.action) {
- const action = event.notification.data.actions.find(({ action }) => action === event.action);
-
- if (action.todo === 'expand') {
- resolve(expandNotification(event.notification));
- } else if (action.todo === 'request') {
- resolve(makeRequest(event.notification, action)
- .then(() => removeActionFromNotification(event.notification, action)));
- } else {
- reject(`Unknown action: ${action.todo}`);
- }
- } else {
- event.notification.close();
- resolve(openUrl(event.notification.data.url));
- }
- });
-
- event.waitUntil(reactToNotificationClick);
-};
-
-self.addEventListener('push', handlePush);
-self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
deleted file mode 100644
index 1376d4cba..000000000
--- a/app/javascript/mastodon/store/configureStore.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { createStore, applyMiddleware, compose } from 'redux';
-import thunk from 'redux-thunk';
-import appReducer from '../reducers';
-import loadingBarMiddleware from '../middleware/loading_bar';
-import errorsMiddleware from '../middleware/errors';
-import soundsMiddleware from '../middleware/sounds';
-
-export default function configureStore() {
- return createStore(appReducer, compose(applyMiddleware(
- thunk,
- loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
- errorsMiddleware(),
- soundsMiddleware()
- ), window.devToolsExtension ? window.devToolsExtension() : f => f));
-};
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
deleted file mode 100644
index 36c68ffc5..000000000
--- a/app/javascript/mastodon/stream.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import WebSocketClient from 'websocket.js';
-
-export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
- return (dispatch, getState) => {
- const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
- const accessToken = getState().getIn(['meta', 'access_token']);
- const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
- let polling = null;
-
- const setupPolling = () => {
- polling = setInterval(() => {
- pollingRefresh(dispatch);
- }, 20000);
- };
-
- const clearPolling = () => {
- if (polling) {
- clearInterval(polling);
- polling = null;
- }
- };
-
- const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
- connected () {
- if (pollingRefresh) {
- clearPolling();
- }
- onConnect();
- },
-
- disconnected () {
- if (pollingRefresh) {
- setupPolling();
- }
- onDisconnect();
- },
-
- received (data) {
- onReceive(data);
- },
-
- reconnected () {
- if (pollingRefresh) {
- clearPolling();
- pollingRefresh(dispatch);
- }
- onConnect();
- },
-
- });
-
- const disconnect = () => {
- if (subscription) {
- subscription.close();
- }
- clearPolling();
- };
-
- return disconnect;
- };
-}
-
-
-export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
- const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
-
- ws.onopen = connected;
- ws.onmessage = e => received(JSON.parse(e.data));
- ws.onclose = disconnected;
- ws.onreconnect = reconnected;
-
- return ws;
-};
diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js
deleted file mode 100644
index 80148379b..000000000
--- a/app/javascript/mastodon/test_setup.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { configure } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-
-const adapter = new Adapter();
-configure({ adapter });
diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js
deleted file mode 100644
index be1899305..000000000
--- a/app/javascript/mastodon/uuid.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function uuid(a) {
- return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
-};
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
deleted file mode 100644
index 3dbed09ea..000000000
--- a/app/javascript/mastodon/web_push_subscription.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import axios from 'axios';
-import { store } from './containers/mastodon';
-import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications';
-
-// Taken from https://www.npmjs.com/package/web-push
-const urlBase64ToUint8Array = (base64String) => {
- const padding = '='.repeat((4 - base64String.length % 4) % 4);
- const base64 = (base64String + padding)
- .replace(/\-/g, '+')
- .replace(/_/g, '/');
-
- const rawData = window.atob(base64);
- const outputArray = new Uint8Array(rawData.length);
-
- for (let i = 0; i < rawData.length; ++i) {
- outputArray[i] = rawData.charCodeAt(i);
- }
- return outputArray;
-};
-
-const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
-
-const getRegistration = () => navigator.serviceWorker.ready;
-
-const getPushSubscription = (registration) =>
- registration.pushManager.getSubscription()
- .then(subscription => ({ registration, subscription }));
-
-const subscribe = (registration) =>
- registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
- });
-
-const unsubscribe = ({ registration, subscription }) =>
- subscription ? subscription.unsubscribe().then(() => registration) : registration;
-
-const sendSubscriptionToBackend = (subscription) =>
- axios.post('/api/web/push_subscriptions', {
- subscription,
- }).then(response => response.data);
-
-// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
-const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
-
-export function register () {
- store.dispatch(setBrowserSupport(supportsPushNotifications));
-
- if (supportsPushNotifications) {
- if (!getApplicationServerKey()) {
- console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
- return;
- }
-
- getRegistration()
- .then(getPushSubscription)
- .then(({ registration, subscription }) => {
- if (subscription !== null) {
- // We have a subscription, check if it is still valid
- const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
- const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
- const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
-
- // If the VAPID public key did not change and the endpoint corresponds
- // to the endpoint saved in the backend, the subscription is valid
- if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
- return subscription;
- } else {
- // Something went wrong, try to subscribe again
- return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
- }
- }
-
- // No subscription, try to subscribe
- return subscribe(registration).then(sendSubscriptionToBackend);
- })
- .then(subscription => {
- // If we got a PushSubscription (and not a subscription object from the backend)
- // it means that the backend subscription is valid (and was set during hydration)
- if (!(subscription instanceof PushSubscription)) {
- store.dispatch(setSubscription(subscription));
- }
- })
- .catch(error => {
- if (error.code === 20 && error.name === 'AbortError') {
- console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
- } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
- console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
- }
-
- // Clear alerts and hide UI settings
- store.dispatch(clearSubscription());
-
- try {
- getRegistration()
- .then(getPushSubscription)
- .then(unsubscribe);
- } catch (e) {
-
- }
- });
- } else {
- console.warn('Your browser does not support Web Push Notifications.');
- }
-}
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 50c81198e..6ce8757dc 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -1,9 +1,9 @@
-import loadPolyfills from '../mastodon/load_polyfills';
+import loadPolyfills from 'themes/glitch/util/load_polyfills';
require.context('../images/', true);
function loaded() {
- const TimelineContainer = require('../mastodon/containers/timeline_container').default;
+ const TimelineContainer = require('themes/glitch/containers/timeline_container').default;
const React = require('react');
const ReactDOM = require('react-dom');
const mountNode = document.getElementById('mastodon-timeline');
@@ -15,7 +15,7 @@ function loaded() {
}
function main() {
- const ready = require('../mastodon/ready').default;
+ const ready = require('themes/glitch/util/ready').default;
ready(loaded);
}
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index d275c3bb0..21dc78986 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -1,3 +1,7 @@
+// THIS IS THE `vanilla` THEME PACK FILE!!
+// IT'S HERE FOR UPSTREAM COMPATIBILITY!!
+// THE `glitch` PACK FILE IS IN `themes/glitch/index.js`!!
+
import loadPolyfills from '../mastodon/load_polyfills';
// import default stylesheet with variables
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
index 5ac6504d4..96e6f4b16 100644
--- a/app/javascript/packs/common.js
+++ b/app/javascript/packs/common.js
@@ -1,9 +1,6 @@
import { start } from 'rails-ujs';
import 'font-awesome/css/font-awesome.css';
-// import common styling
-require('../styles/common.scss');
-
require.context('../images/', true);
start();
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 59d0e98dd..4362905da 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,6 +1,6 @@
-import loadPolyfills from '../mastodon/load_polyfills';
-import { processBio } from '../glitch/util/bio_metadata';
-import ready from '../mastodon/ready';
+import loadPolyfills from 'themes/glitch/util/load_polyfills';
+import { processBio } from 'themes/glitch/util/bio_metadata';
+import ready from 'themes/glitch/util/ready';
window.addEventListener('message', e => {
const data = e.data || {};
@@ -22,12 +22,12 @@ function main() {
const { length } = require('stringz');
const IntlRelativeFormat = require('intl-relativeformat').default;
const { delegate } = require('rails-ujs');
- const emojify = require('../mastodon/features/emoji/emoji').default;
- const { getLocale } = require('../mastodon/locales');
+ const emojify = require('../themes/glitch/features/emoji/emoji').default;
+ const { getLocale } = require('mastodon/locales');
const { localeData } = getLocale();
- const VideoContainer = require('../mastodon/containers/video_container').default;
- const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default;
- const CardContainer = require('../mastodon/containers/card_container').default;
+ const VideoContainer = require('../themes/glitch/containers/video_container').default;
+ const MediaGalleryContainer = require('../themes/glitch/containers/media_gallery_container').default;
+ const CardContainer = require('../themes/glitch/containers/card_container').default;
const React = require('react');
const ReactDOM = require('react-dom');
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
index 51e4ae38b..9cd95bcee 100644
--- a/app/javascript/packs/share.js
+++ b/app/javascript/packs/share.js
@@ -1,9 +1,9 @@
-import loadPolyfills from '../mastodon/load_polyfills';
+import loadPolyfills from 'themes/glitch/util/load_polyfills';
require.context('../images/', true);
function loaded() {
- const ComposeContainer = require('../mastodon/containers/compose_container').default;
+ const ComposeContainer = require('themes/glitch/containers/compose_container').default;
const React = require('react');
const ReactDOM = require('react-dom');
const mountNode = document.getElementById('mastodon-compose');
@@ -15,7 +15,7 @@ function loaded() {
}
function main() {
- const ready = require('../mastodon/ready').default;
+ const ready = require('themes/glitch/util/ready').default;
ready(loaded);
}
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
deleted file mode 100644
index efd34393f..000000000
--- a/app/javascript/styles/application.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-@import 'mastodon/mixins';
-@import 'mastodon/variables';
-@import 'variables-glitch';
-@import 'fonts/roboto';
-@import 'fonts/roboto-mono';
-@import 'fonts/montserrat';
-
-@import 'mastodon/reset';
-@import 'mastodon/basics';
-@import 'mastodon/containers';
-@import 'mastodon/lists';
-@import 'mastodon/footer';
-@import 'mastodon/compact_header';
-@import 'mastodon/landing_strip';
-@import 'mastodon/forms';
-@import 'mastodon/accounts';
-@import 'mastodon/stream_entries';
-@import 'mastodon/components';
-@import 'mastodon/emoji_picker';
-@import 'mastodon/about';
-@import 'mastodon/tables';
-@import 'mastodon/admin';
-@import 'mastodon/rtl';
diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss
deleted file mode 100644
index a4a1cfc84..000000000
--- a/app/javascript/styles/doodle.scss
+++ /dev/null
@@ -1,86 +0,0 @@
-$doodleBg: #d9e1e8;
-.doodle-modal {
- @extend .boost-modal;
- width: unset;
-}
-
-.doodle-modal__container {
- background: $doodleBg;
- text-align: center;
- line-height: 0; // remove weird gap under canvas
- canvas {
- border: 5px solid $doodleBg;
- }
-}
-
-.doodle-modal__action-bar {
- @extend .boost-modal__action-bar;
-
- .filler {
- flex-grow: 1;
- margin: 0;
- padding: 0;
- }
-
- .doodle-toolbar {
- line-height: 1;
-
- display: flex;
- flex-direction: column;
- flex-grow: 0;
- justify-content: space-around;
-
- &.with-inputs {
- label {
- display: inline-block;
- width: 70px;
- text-align: right;
- margin-right: 2px;
- }
-
- input[type="number"],input[type="text"] {
- width: 40px;
- }
- span.val {
- display: inline-block;
- text-align: left;
- width: 50px;
- }
- }
- }
-
- .doodle-palette {
- padding-right: 0 !important;
- border: 1px solid black;
- line-height: .2rem;
- flex-grow: 0;
- background: white;
-
- button {
- appearance: none;
- width: 1rem;
- height: 1rem;
- margin: 0; padding: 0;
- text-align: center;
- color: black;
- text-shadow: 0 0 1px white;
- cursor: pointer;
- box-shadow: inset 0 0 1px rgba(white, .5);
- border: 1px solid black;
- outline-offset:-1px;
-
- &.foreground {
- outline: 1px dashed white;
- }
-
- &.background {
- outline: 1px dashed red;
- }
-
- &.foreground.background {
- outline: 1px dashed red;
- border-color: white;
- }
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss
deleted file mode 100644
index 7412991b8..000000000
--- a/app/javascript/styles/mastodon/_mixins.scss
+++ /dev/null
@@ -1,42 +0,0 @@
-@mixin avatar-radius() {
- border-radius: $ui-avatar-border-size;
- background: transparent no-repeat;
- background-position: 50%;
- background-clip: padding-box;
-}
-
-@mixin avatar-size($size:48px) {
- width: $size;
- height: $size;
- background-size: $size $size;
-}
-
-@mixin single-column($media, $parent: '&') {
- .auto-columns #{$parent} {
- @media #{$media} {
- @content;
- }
- }
- .single-column #{$parent} {
- @content;
- }
-}
-
-@mixin limited-single-column($media, $parent: '&') {
- .auto-columns #{$parent}, .single-column #{$parent} {
- @media #{$media} {
- @content;
- }
- }
-}
-
-@mixin multi-columns($media, $parent: '&') {
- .auto-columns #{$parent} {
- @media #{$media} {
- @content;
- }
- }
- .multi-columns #{$parent} {
- @content;
- }
-}
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
deleted file mode 100644
index 4ec689427..000000000
--- a/app/javascript/styles/mastodon/about.scss
+++ /dev/null
@@ -1,822 +0,0 @@
-.landing-page {
- p,
- li {
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 16px;
- font-weight: 400;
- font-size: 16px;
- line-height: 30px;
- margin-bottom: 12px;
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
-
- em {
- display: inline;
- margin: 0;
- padding: 0;
- font-weight: 500;
- background: transparent;
- font-family: inherit;
- font-size: inherit;
- line-height: inherit;
- color: lighten($ui-primary-color, 10%);
- }
-
- h1 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 26px;
- line-height: 30px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
-
- small {
- font-family: 'mastodon-font-sans-serif', sans-serif;
- display: block;
- font-size: 18px;
- font-weight: 400;
- color: $ui-base-lighter-color;
- }
- }
-
- h2 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 22px;
- line-height: 26px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
- }
-
- h3 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 18px;
- line-height: 24px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
- }
-
- h4 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 16px;
- line-height: 24px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
- }
-
- h5 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
- }
-
- h6 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 12px;
- line-height: 24px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-secondary-color;
- }
-
- ul,
- ol {
- margin-left: 20px;
-
- &[type='a'] {
- list-style-type: lower-alpha;
- }
-
- &[type='i'] {
- list-style-type: lower-roman;
- }
- }
-
- ul {
- list-style: disc;
- }
-
- ol {
- list-style: decimal;
- }
-
- li > ol,
- li > ul {
- margin-top: 6px;
- }
-
- hr {
- border-color: rgba($ui-base-lighter-color, .6);
- }
-
- .container {
- width: 100%;
- box-sizing: border-box;
- max-width: 800px;
- margin: 0 auto;
- word-wrap: break-word;
- }
-
- .header-wrapper {
- padding-top: 15px;
- background: $ui-base-color;
- background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
- position: relative;
-
- &.compact {
- background: $ui-base-color;
- padding-bottom: 15px;
-
- .hero .heading {
- padding-bottom: 20px;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 16px;
- font-weight: 400;
- font-size: 16px;
- line-height: 30px;
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
- }
-
- .mascot-container {
- max-width: 800px;
- margin: 0 auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 100%;
- }
-
- .mascot {
- position: absolute;
- bottom: -14px;
- width: auto;
- height: auto;
- left: 60px;
- z-index: 3;
- }
- }
-
- .header {
- line-height: 30px;
- overflow: hidden;
-
- .container {
- display: flex;
- justify-content: space-between;
- }
-
- .links {
- position: relative;
- z-index: 4;
-
- a {
- display: flex;
- justify-content: center;
- align-items: center;
- color: $ui-primary-color;
- text-decoration: none;
- padding: 12px 16px;
- line-height: 32px;
- font-family: 'mastodon-font-display', sans-serif;
- font-weight: 500;
- font-size: 14px;
-
- &:hover {
- color: $ui-secondary-color;
- }
- }
-
- .brand {
- a {
- padding-left: 0;
- padding-right: 0;
- color: $white;
- }
-
- img {
- height: 32px;
- position: relative;
- top: 4px;
- left: -10px;
- }
- }
-
- ul {
- list-style: none;
- margin: 0;
-
- li {
- display: inline-block;
- vertical-align: bottom;
- margin: 0;
-
- &:first-child a {
- padding-left: 0;
- }
-
- &:last-child a {
- padding-right: 0;
- }
- }
- }
- }
-
- .hero {
- margin-top: 50px;
- align-items: center;
- position: relative;
-
- .floats {
- position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
-
- div {
- position: absolute;
- transition: all 0.1s linear;
- animation-name: floating;
- animation-iteration-count: infinite;
- animation-direction: alternate;
- animation-timing-function: ease-in-out;
- z-index: 2;
- }
-
- .float-1 {
- width: 324px;
- height: 170px;
- right: -120px;
- bottom: 0;
- animation-duration: 3s;
- background-image: url('data:image/svg+xml;utf8, ');
- }
-
- .float-2 {
- width: 241px;
- height: 100px;
- right: 210px;
- bottom: 0;
- animation-duration: 3.5s;
- animation-delay: 0.2s;
- background-image: url('data:image/svg+xml;utf8, ');
- }
-
- .float-3 {
- width: 267px;
- height: 140px;
- right: 110px;
- top: -30px;
- animation-duration: 4s;
- animation-delay: 0.5s;
- background-image: url('data:image/svg+xml;utf8, ');
- }
- }
-
- .heading {
- position: relative;
- z-index: 4;
- padding-bottom: 150px;
- }
-
- .simple_form,
- .closed-registrations-message {
- background: darken($ui-base-color, 4%);
- width: 280px;
- padding: 15px 20px;
- border-radius: 4px 4px 0 0;
- line-height: initial;
- position: relative;
- z-index: 4;
-
- .actions {
- margin-bottom: 0;
-
- button,
- .button,
- .block-button {
- margin-bottom: 0;
- }
- }
- }
-
- .closed-registrations-message {
- min-height: 330px;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- }
- }
- }
-
- .about-short {
- background: darken($ui-base-color, 4%);
- padding: 50px 0 30px;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 16px;
- font-weight: 400;
- font-size: 16px;
- line-height: 30px;
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
-
- .information-board {
- background: darken($ui-base-color, 4%);
- padding: 20px 0;
-
- .container {
- position: relative;
- padding-right: 280px + 15px;
- }
-
- .information-board-sections {
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- }
-
- .section {
- flex: 1 0 0;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 16px;
- line-height: 28px;
- color: $primary-text-color;
- text-align: right;
- padding: 10px 15px;
-
- span,
- strong {
- display: block;
- }
-
- span {
- &:last-child {
- color: $ui-secondary-color;
- }
- }
-
- strong {
- font-weight: 500;
- font-size: 32px;
- line-height: 48px;
- }
- }
-
- .panel {
- position: absolute;
- width: 280px;
- box-sizing: border-box;
- background: darken($ui-base-color, 8%);
- padding: 20px;
- padding-top: 10px;
- border-radius: 4px 4px 0 0;
- right: 0;
- bottom: -40px;
-
- .panel-header {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500;
- color: $ui-primary-color;
- padding-bottom: 5px;
- margin-bottom: 15px;
- border-bottom: 1px solid lighten($ui-base-color, 4%);
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
-
- a,
- span {
- font-weight: 400;
- color: darken($ui-primary-color, 10%);
- }
-
- a {
- text-decoration: none;
- }
- }
- }
-
- .owner {
- text-align: center;
-
- .avatar {
- @include avatar-size(80px);
- margin: 0 auto;
- margin-bottom: 15px;
-
- img {
- @include avatar-radius();
- @include avatar-size(80px);
- display: block;
- }
- }
-
- .name {
- font-size: 14px;
-
- a {
- display: block;
- color: $primary-text-color;
- text-decoration: none;
-
- &:hover {
- .display_name {
- text-decoration: underline;
- }
- }
- }
-
- .username {
- display: block;
- color: $ui-primary-color;
- }
- }
- }
- }
-
- .features {
- padding: 50px 0;
-
- .container {
- display: flex;
- }
-
- #mastodon-timeline {
- display: flex;
- -webkit-overflow-scrolling: touch;
- -ms-overflow-style: -ms-autohiding-scrollbar;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 13px;
- line-height: 18px;
- font-weight: 400;
- color: $primary-text-color;
- width: 330px;
- margin-right: 30px;
- flex: 0 0 auto;
- background: $ui-base-color;
- overflow: hidden;
- border-radius: 4px;
- box-shadow: 0 0 6px rgba($black, 0.1);
-
- .column-header {
- color: inherit;
- font-family: inherit;
- font-size: 16px;
- line-height: inherit;
- font-weight: inherit;
- margin: 0;
- padding: 15px;
- }
-
- .column {
- padding: 0;
- border-radius: 4px;
- overflow: hidden;
- }
-
- .scrollable {
- height: 400px;
- }
-
- p {
- font-size: inherit;
- line-height: inherit;
- font-weight: inherit;
- color: $primary-text-color;
- margin-bottom: 20px;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- a {
- color: $ui-secondary-color;
- text-decoration: none;
- }
- }
- }
-
- .about-mastodon {
- max-width: 675px;
-
- p {
- margin-bottom: 20px;
- }
-
- .features-list {
- margin-top: 20px;
-
- .features-list__row {
- display: flex;
- padding: 10px 0;
- justify-content: space-between;
-
- &:first-child {
- padding-top: 0;
- }
-
- .visual {
- flex: 0 0 auto;
- display: flex;
- align-items: center;
- margin-left: 15px;
-
- .fa {
- display: block;
- color: $ui-primary-color;
- font-size: 48px;
- }
- }
-
- .text {
- font-size: 16px;
- line-height: 30px;
- color: $ui-primary-color;
-
- h6 {
- font-size: inherit;
- line-height: inherit;
- margin-bottom: 0;
- }
- }
- }
- }
- }
- }
-
- .extended-description {
- padding: 50px 0;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 16px;
- font-weight: 400;
- font-size: 16px;
- line-height: 30px;
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
-
- .footer-links {
- padding-bottom: 50px;
- text-align: right;
- color: $ui-base-lighter-color;
-
- p {
- font-size: 14px;
- }
-
- a {
- color: inherit;
- text-decoration: underline;
- }
- }
-
- @media screen and (max-width: 840px) {
- .container {
- padding: 0 20px;
- }
-
- .information-board {
-
- .container {
- padding-right: 20px;
- }
-
- .section {
- text-align: center;
- }
-
- .panel {
- position: static;
- margin-top: 20px;
- width: 100%;
- border-radius: 4px;
-
- .panel-header {
- text-align: center;
- }
- }
- }
-
- .header-wrapper .mascot {
- left: 20px;
- }
- }
-
- @media screen and (max-width: 689px) {
- .header-wrapper .mascot {
- display: none;
- }
- }
-
- @media screen and (max-width: 675px) {
- .header-wrapper {
- padding-top: 0;
-
- &.compact {
- padding-bottom: 0;
- }
-
- &.compact .hero .heading {
- text-align: initial;
- }
- }
-
- .header .container,
- .features .container {
- display: block;
- }
-
- .header {
-
- .links {
- padding-top: 15px;
- background: darken($ui-base-color, 4%);
-
- a {
- padding: 12px 8px;
- }
-
- .nav {
- display: flex;
- flex-flow: row wrap;
- justify-content: space-around;
- }
-
- .brand img {
- left: 0;
- top: 0;
- }
- }
-
- .hero {
- margin-top: 30px;
- padding: 0;
-
- .floats {
- display: none;
- }
-
- .heading {
- padding: 30px 20px;
- text-align: center;
- }
-
- .simple_form,
- .closed-registrations-message {
- background: darken($ui-base-color, 8%);
- width: 100%;
- border-radius: 0;
- box-sizing: border-box;
- }
- }
- }
-
- .features #mastodon-timeline {
- height: 70vh;
- width: 100%;
- margin-bottom: 50px;
-
- .column {
- width: 100%;
- }
- }
- }
-
- .cta {
- margin: 20px;
- }
-
- &.tag-page {
- .features {
- padding: 30px 0;
-
- .container {
- max-width: 820px;
-
- #mastodon-timeline {
- margin-right: 0;
- border-top-right-radius: 0;
- }
-
- .about-mastodon {
- .about-hashtag {
- background: darken($ui-base-color, 4%);
- padding: 0 20px 20px 30px;
- border-radius: 0 5px 5px 0;
-
- .brand {
- padding-top: 20px;
- margin-bottom: 20px;
-
- img {
- height: 48px;
- width: auto;
- }
- }
-
- p {
- strong {
- color: $ui-secondary-color;
- font-weight: 700;
- }
- }
-
- .cta {
- margin: 0;
-
- .button {
- margin-right: 4px;
- }
- }
- }
-
- .features-list {
- margin-left: 30px;
- margin-right: 10px;
- }
- }
- }
- }
-
- @media screen and (max-width: 675px) {
- .features {
- padding: 10px 0;
-
- .container {
- display: flex;
- flex-direction: column;
-
- #mastodon-timeline {
- order: 2;
- flex: 0 0 auto;
- height: 60vh;
- margin-bottom: 20px;
- border-top-right-radius: 4px;
- }
-
- .about-mastodon {
- order: 1;
- flex: 0 0 auto;
- max-width: 100%;
-
- .about-hashtag {
- background: unset;
- padding: 0;
- border-radius: 0;
-
- .cta {
- margin: 20px 0;
- }
- }
-
- .features-list {
- display: none;
- }
- }
- }
- }
- }
- }
-}
-
-@keyframes floating {
- from {
- transform: translate(0, 0);
- }
-
- 65% {
- transform: translate(0, 4px);
- }
-
- to {
- transform: translate(0, -0);
- }
-}
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
deleted file mode 100644
index 2cf98c642..000000000
--- a/app/javascript/styles/mastodon/accounts.scss
+++ /dev/null
@@ -1,589 +0,0 @@
-.card {
- background-color: lighten($ui-base-color, 4%);
- background-size: cover;
- background-position: center;
- border-radius: 4px 4px 0 0;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
- overflow: hidden;
- position: relative;
- display: flex;
-
- &::after {
- background: rgba(darken($ui-base-color, 8%), 0.5);
- display: block;
- content: "";
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- z-index: 1;
- }
-
- @media screen and (max-width: 740px) {
- border-radius: 0;
- box-shadow: none;
- }
-
- .card__illustration {
- padding: 60px 0;
- position: relative;
- flex: 1 1 auto;
- display: flex;
- justify-content: center;
- align-items: center;
- }
-
- .card__bio {
- max-width: 260px;
- flex: 1 1 auto;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- background: rgba(darken($ui-base-color, 8%), 0.8);
- position: relative;
- z-index: 2;
- }
-
- &.compact {
- padding: 30px 0;
- border-radius: 4px;
-
- .avatar {
- margin-bottom: 0;
-
- img {
- object-fit: cover;
- }
- }
- }
-
- .name {
- display: block;
- font-size: 20px;
- line-height: 18px * 1.5;
- color: $primary-text-color;
- padding: 10px 15px;
- padding-bottom: 0;
- font-weight: 500;
- position: relative;
- z-index: 2;
- margin-bottom: 30px;
- overflow: hidden;
- text-overflow: ellipsis;
-
- small {
- display: block;
- font-size: 14px;
- color: $ui-highlight-color;
- font-weight: 400;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
-
- .avatar {
- @include avatar-size(120px);
- margin: 0 auto;
- position: relative;
- z-index: 2;
-
- img {
- @include avatar-radius();
- @include avatar-size(120px);
- display: block;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
- }
- }
-
- .controls {
- position: absolute;
- top: 15px;
- left: 15px;
- z-index: 2;
-
- .icon-button {
- color: rgba($white, 0.8);
- text-decoration: none;
- font-size: 13px;
- line-height: 13px;
- font-weight: 500;
-
- .fa {
- font-weight: 400;
- margin-right: 5px;
- }
-
- &:hover,
- &:active,
- &:focus {
- color: $white;
- }
- }
- }
-
- .roles {
- margin-bottom: 30px;
- padding: 0 15px;
- }
-
- .details-counters {
- margin-top: 30px;
- display: flex;
- flex-direction: row;
- width: 100%;
- }
-
- .counter {
- width: 33.3%;
- box-sizing: border-box;
- flex: 0 0 auto;
- color: $ui-primary-color;
- padding: 5px 10px 0;
- margin-bottom: 10px;
- border-right: 1px solid lighten($ui-base-color, 4%);
- cursor: default;
- text-align: center;
- position: relative;
-
- a {
- display: block;
- }
-
- &:last-child {
- border-right: 0;
- }
-
- &::after {
- display: block;
- content: "";
- position: absolute;
- bottom: -10px;
- left: 0;
- width: 100%;
- border-bottom: 4px solid $ui-primary-color;
- opacity: 0.5;
- transition: all 400ms ease;
- }
-
- &.active {
- &::after {
- border-bottom: 4px solid $ui-highlight-color;
- opacity: 1;
- }
- }
-
- &:hover {
- &::after {
- opacity: 1;
- transition-duration: 100ms;
- }
- }
-
- a {
- text-decoration: none;
- color: inherit;
- }
-
- .counter-label {
- font-size: 12px;
- display: block;
- margin-bottom: 5px;
- }
-
- .counter-number {
- font-weight: 500;
- font-size: 18px;
- color: $primary-text-color;
- font-family: 'mastodon-font-display', sans-serif;
- }
- }
-
- .bio {
- font-size: 14px;
- line-height: 18px;
- padding: 0 15px;
- color: $ui-secondary-color;
- }
-
- .metadata {
- $meta-table-border: darken($classic-highlight-color, 20%);//#174f77;
-
- border-collapse: collapse;
- padding: 0;
- margin: 15px -15px -10px -15px;
- border: 0 none;
- border-top: 1px solid $meta-table-border;
- border-bottom: 1px solid $meta-table-border;
-
- td, th {
- padding: 10px;
- border: 0 none;
- border-bottom: 1px solid $meta-table-border;
- vertical-align: middle;
- }
-
- tr:last-child {
- td, th {
- border-bottom: 0 none;
- }
- }
-
- td {
- color: $ui-primary-color;
- width:100%; // makes it stretch
- padding-left: 0;
- }
-
- th {
- padding-left: 15px;
- font-weight: bold;
- text-align: left;
- width: 94px;
- color: $ui-secondary-color;
- background: darken($ui-base-color, 8%);
- //background: #131415;
- }
-
- a {
- color: $classic-highlight-color;
- }
- }
-
- @media screen and (max-width: 480px) {
- display: block;
-
- .card__bio {
- max-width: none;
- }
-
- .name,
- .roles {
- text-align: center;
- margin-bottom: 15px;
- }
-
- .bio {
- margin-bottom: 15px;
- }
- }
-}
-
-.pagination {
- padding: 30px 0;
- text-align: center;
- overflow: hidden;
-
- a,
- .current,
- .next,
- .prev,
- .page,
- .gap {
- font-size: 14px;
- color: $primary-text-color;
- font-weight: 500;
- display: inline-block;
- padding: 6px 10px;
- text-decoration: none;
- }
-
- .current {
- background: $simple-background-color;
- border-radius: 100px;
- color: $ui-base-color;
- cursor: default;
- margin: 0 10px;
- }
-
- .gap {
- cursor: default;
- }
-
- .prev,
- .next {
- text-transform: uppercase;
- color: $ui-secondary-color;
- }
-
- .prev {
- float: left;
- padding-left: 0;
-
- .fa {
- display: inline-block;
- margin-right: 5px;
- }
- }
-
- .next {
- float: right;
- padding-right: 0;
-
- .fa {
- display: inline-block;
- margin-left: 5px;
- }
- }
-
- .disabled {
- cursor: default;
- color: lighten($ui-base-color, 10%);
- }
-
- @media screen and (max-width: 700px) {
- padding: 30px 20px;
-
- .page {
- display: none;
- }
-
- .next,
- .prev {
- display: inline-block;
- }
- }
-}
-
-.accounts-grid {
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
- background: darken($simple-background-color, 8%);
- border-radius: 0 0 4px 4px;
- padding: 20px 5px;
- padding-bottom: 10px;
- overflow: hidden;
- display: flex;
- flex-wrap: wrap;
- z-index: 2;
- position: relative;
-
- @media screen and (max-width: 740px) {
- border-radius: 0;
- box-shadow: none;
- }
-
- .account-grid-card {
- box-sizing: border-box;
- width: 335px;
- background: $simple-background-color;
- border-radius: 4px;
- color: $ui-base-color;
- margin: 0 5px 10px;
- position: relative;
-
- @media screen and (max-width: 740px) {
- width: calc(100% - 10px);
- }
-
- .account-grid-card__header {
- overflow: hidden;
- height: 100px;
- border-radius: 4px 4px 0 0;
- background-color: lighten($ui-base-color, 4%);
- background-size: cover;
- background-position: center;
- position: relative;
-
- &::after {
- background: rgba(darken($ui-base-color, 8%), 0.5);
- display: block;
- content: "";
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- z-index: 1;
- }
- }
-
- .account-grid-card__avatar {
- box-sizing: border-box;
- padding: 15px;
- position: absolute;
- z-index: 2;
- top: 100px - (40px + 2px);
- left: -2px;
- }
-
- .avatar {
- @include avatar-size(80px);
-
- img {
- display: block;
- @include avatar-radius();
- @include avatar-size(80px);
- border: 2px solid $simple-background-color;
- background: $simple-background-color;
- }
- }
-
- .name {
- padding: 15px;
- padding-top: 10px;
- padding-left: 15px + 80px + 15px;
-
- a {
- display: block;
- color: $ui-base-color;
- text-decoration: none;
- text-overflow: ellipsis;
- overflow: hidden;
- font-weight: 500;
-
- &:hover {
- .display_name {
- text-decoration: underline;
- }
- }
- }
- }
-
- .display_name {
- font-size: 16px;
- display: block;
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
- .username {
- color: lighten($ui-base-color, 34%);
- font-size: 14px;
- font-weight: 400;
- }
-
- .note {
- padding: 10px 15px;
- padding-top: 15px;
- box-sizing: border-box;
- color: lighten($ui-base-color, 26%);
- word-wrap: break-word;
- min-height: 80px;
- }
- }
-}
-
-.nothing-here {
- width: 100%;
- display: block;
- color: $ui-primary-color;
- font-size: 14px;
- font-weight: 500;
- text-align: center;
- padding: 60px 0;
- padding-top: 55px;
- cursor: default;
-}
-
-.account-card {
- padding: 14px 10px;
- background: $simple-background-color;
- border-radius: 4px;
- text-align: left;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-
- .detailed-status__display-name {
- display: block;
- overflow: hidden;
- margin-bottom: 15px;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- & > div {
- @include avatar-size(48px);
- float: left;
- margin-right: 10px;
- }
-
- .avatar {
- @include avatar-radius();
- display: block;
- }
-
- .display-name {
- display: block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- cursor: default;
-
- strong {
- font-weight: 500;
- color: $ui-base-color;
- }
-
- span {
- font-size: 14px;
- color: $ui-primary-color;
- }
- }
-
- &:hover {
- .display-name {
- strong {
- text-decoration: none;
- }
- }
- }
- }
-
- .account__header__content {
- font-size: 14px;
- color: $ui-base-color;
- }
-}
-
-.activity-stream-tabs {
- background: $simple-background-color;
- border-bottom: 1px solid $ui-secondary-color;
- position: relative;
- z-index: 2;
-
- a {
- display: inline-block;
- padding: 15px;
- text-decoration: none;
- color: $ui-highlight-color;
- text-transform: uppercase;
- font-weight: 500;
-
- &:hover,
- &:active,
- &:focus {
- color: lighten($ui-highlight-color, 8%);
- }
-
- &.active {
- color: $ui-base-color;
- cursor: default;
- }
- }
-}
-
-.account-role {
- display: inline-block;
- padding: 4px 6px;
- cursor: default;
- border-radius: 3px;
- font-size: 12px;
- line-height: 12px;
- font-weight: 500;
- color: $ui-secondary-color;
- background-color: rgba($ui-secondary-color, 0.1);
- border: 1px solid rgba($ui-secondary-color, 0.5);
-
- &.moderator {
- color: $success-green;
- background-color: rgba($success-green, 0.1);
- border-color: rgba($success-green, 0.5);
- }
-
- &.admin {
- color: lighten($error-red, 12%);
- background-color: rgba(lighten($error-red, 12%), 0.1);
- border-color: rgba(lighten($error-red, 12%), 0.5);
- }
-}
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
deleted file mode 100644
index 87bc710af..000000000
--- a/app/javascript/styles/mastodon/admin.scss
+++ /dev/null
@@ -1,349 +0,0 @@
-.admin-wrapper {
- display: flex;
- justify-content: center;
- height: 100%;
-
- .sidebar-wrapper {
- flex: 1;
- height: 100%;
- background: $ui-base-color;
- display: flex;
- justify-content: flex-end;
- }
-
- .sidebar {
- width: 240px;
- height: 100%;
- padding: 0;
- overflow-y: auto;
-
- .logo {
- display: block;
- margin: 40px auto;
- width: 100px;
- height: 100px;
- }
-
- ul {
- list-style: none;
- border-radius: 4px 0 0 4px;
- overflow: hidden;
- margin-bottom: 20px;
-
- a {
- display: block;
- padding: 15px;
- color: rgba($primary-text-color, 0.7);
- text-decoration: none;
- transition: all 200ms linear;
- border-radius: 4px 0 0 4px;
-
- i.fa {
- margin-right: 5px;
- }
-
- &:hover {
- color: $primary-text-color;
- background-color: darken($ui-base-color, 5%);
- transition: all 100ms linear;
- }
-
- &.selected {
- background: darken($ui-base-color, 2%);
- border-radius: 4px 0 0;
- }
- }
-
- ul {
- background: darken($ui-base-color, 4%);
- border-radius: 0 0 0 4px;
- margin: 0;
-
- a {
- border: 0;
- padding: 15px 35px;
-
- &.selected {
- color: $primary-text-color;
- background-color: $ui-highlight-color;
- border-bottom: 0;
- border-radius: 0;
-
- &:hover {
- background-color: lighten($ui-highlight-color, 5%);
- }
- }
- }
- }
- }
- }
-
- .content-wrapper {
- flex: 2;
- overflow: auto;
- }
-
- .content {
- max-width: 700px;
- padding: 20px 15px;
- padding-top: 60px;
- padding-left: 25px;
-
- h2 {
- color: $ui-secondary-color;
- font-size: 24px;
- line-height: 28px;
- font-weight: 400;
- margin-bottom: 40px;
- }
-
- h3 {
- color: $ui-secondary-color;
- font-size: 20px;
- line-height: 28px;
- font-weight: 400;
- margin-bottom: 30px;
- }
-
- h6 {
- font-size: 16px;
- color: $ui-secondary-color;
- line-height: 28px;
- font-weight: 400;
- }
-
- & > p {
- font-size: 14px;
- line-height: 18px;
- color: $ui-secondary-color;
- margin-bottom: 20px;
-
- strong {
- color: $primary-text-color;
- font-weight: 500;
- }
- }
-
- hr {
- margin: 20px 0;
- border: 0;
- background: transparent;
- border-bottom: 1px solid $ui-base-color;
- }
-
- .muted-hint {
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- }
- }
-
- .positive-hint {
- color: $valid-value-color;
- font-weight: 500;
- }
- }
-
- .simple_form {
- max-width: 400px;
-
- &.edit_user,
- &.new_form_admin_settings,
- &.new_form_two_factor_confirmation,
- &.new_form_delete_confirmation,
- &.new_import,
- &.new_domain_block,
- &.edit_domain_block {
- max-width: none;
- }
-
- .form_two_factor_confirmation_code,
- .form_delete_confirmation_password {
- max-width: 400px;
- }
-
- .actions {
- max-width: 400px;
- }
- }
-
- @media screen and (max-width: 600px) {
- display: block;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
-
- .sidebar-wrapper,
- .content-wrapper {
- flex: 0 0 auto;
- height: auto;
- overflow: initial;
- }
-
- .sidebar {
- width: 100%;
- padding: 10px 0;
- height: auto;
-
- .logo {
- margin: 20px auto;
- }
- }
-
- .content {
- padding-top: 20px;
- }
- }
-}
-
-.filters {
- display: flex;
- flex-wrap: wrap;
-
- .filter-subset {
- flex: 0 0 auto;
- margin: 0 40px 10px 0;
-
- &:last-child {
- margin-bottom: 20px;
- }
-
- ul {
- margin-top: 5px;
- list-style: none;
-
- li {
- display: inline-block;
- margin-right: 5px;
- }
- }
-
- strong {
- font-weight: 500;
- text-transform: uppercase;
- font-size: 12px;
- }
-
- a {
- display: inline-block;
- color: rgba($primary-text-color, 0.7);
- text-decoration: none;
- text-transform: uppercase;
- font-size: 12px;
- font-weight: 500;
- border-bottom: 2px solid $ui-base-color;
-
- &:hover {
- color: $primary-text-color;
- border-bottom: 2px solid lighten($ui-base-color, 5%);
- }
-
- &.selected {
- color: $ui-highlight-color;
- border-bottom: 2px solid $ui-highlight-color;
- }
- }
- }
-}
-
-.report-accounts {
- display: flex;
- flex-wrap: wrap;
- margin-bottom: 20px;
-}
-
-.report-accounts__item {
- display: flex;
- flex: 250px;
- flex-direction: column;
- margin: 0 5px;
-
- & > strong {
- display: block;
- margin: 0 0 10px -5px;
- font-weight: 500;
- font-size: 14px;
- line-height: 18px;
- color: $ui-secondary-color;
- }
-
- .account-card {
- flex: 1 1 auto;
- }
-}
-
-.report-status,
-.account-status {
- display: flex;
- margin-bottom: 10px;
-
- .activity-stream {
- flex: 2 0 0;
- margin-right: 20px;
- max-width: calc(100% - 60px);
-
- .entry {
- border-radius: 4px;
- }
- }
-}
-
-.report-status__actions,
-.account-status__actions {
- flex: 0 0 auto;
- display: flex;
- flex-direction: column;
-
- .icon-button {
- font-size: 24px;
- width: 24px;
- text-align: center;
- margin-bottom: 10px;
- }
-}
-
-.batch-form-box {
- display: flex;
- flex-wrap: wrap;
- margin-bottom: 5px;
-
- #form_status_batch_action {
- margin: 0 5px 5px 0;
- font-size: 14px;
- }
-
- input.button {
- margin: 0 5px 5px 0;
- }
-
- .media-spoiler-toggle-buttons {
- margin-left: auto;
-
- .button {
- overflow: visible;
- margin: 0 0 5px 5px;
- float: right;
- }
- }
-}
-
-.batch-checkbox,
-.batch-checkbox-all {
- display: flex;
- align-items: center;
- margin-right: 5px;
-}
-
-.back-link {
- margin-bottom: 10px;
- font-size: 14px;
-
- a {
- color: $classic-highlight-color;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
deleted file mode 100644
index b5d77ff63..000000000
--- a/app/javascript/styles/mastodon/basics.scss
+++ /dev/null
@@ -1,122 +0,0 @@
-body {
- font-family: 'mastodon-font-sans-serif', sans-serif;
- background: $ui-base-color;
- background-size: cover;
- background-attachment: fixed;
- font-size: 13px;
- line-height: 18px;
- font-weight: 400;
- color: $primary-text-color;
- padding-bottom: 20px;
- text-rendering: optimizelegibility;
- font-feature-settings: "kern";
- text-size-adjust: none;
- -webkit-tap-highlight-color: rgba(0,0,0,0);
- -webkit-tap-highlight-color: transparent;
-
- &.system-font {
- // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
- // -apple-system => Safari <11 specific
- // BlinkMacSystemFont => Chrome <56 on macOS specific
- // Segoe UI => Windows 7/8/10
- // Oxygen => KDE
- // Ubuntu => Unity/Ubuntu
- // Cantarell => GNOME
- // Fira Sans => Firefox OS
- // Droid Sans => Older Androids (<4.0)
- // Helvetica Neue => Older macOS <10.11
- // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
- }
-
- &.app-body {
- position: absolute;
- width: 100%;
- height: 100%;
- padding: 0;
- background: $ui-base-color;
- }
-
- &.about-body {
- background: darken($ui-base-color, 8%);
- padding-bottom: 0;
- }
-
- &.tag-body {
- background: darken($ui-base-color, 8%);
- padding-bottom: 0;
- }
-
- &.embed {
- background: transparent;
- margin: 0;
- padding-bottom: 0;
-
- .container {
- position: absolute;
- width: 100%;
- height: 100%;
- overflow: hidden;
- }
- }
-
- &.admin {
- background: darken($ui-base-color, 4%);
- position: fixed;
- width: 100%;
- height: 100%;
- padding: 0;
- }
-
- &.error {
- position: absolute;
- text-align: center;
- color: $ui-primary-color;
- background: $ui-base-color;
- width: 100%;
- height: 100%;
- padding: 0;
- display: flex;
- justify-content: center;
- align-items: center;
-
- .dialog {
- vertical-align: middle;
- margin: 20px;
-
- img {
- display: block;
- max-width: 470px;
- width: 100%;
- height: auto;
- margin-top: -120px;
- }
-
- h1 {
- font-size: 20px;
- line-height: 28px;
- font-weight: 400;
- }
- }
- }
-}
-
-button {
- font-family: inherit;
- cursor: pointer;
-
- &:focus {
- outline: none;
- }
-}
-
-.app-holder {
- &,
- & > div {
- display: flex;
- width: 100%;
- height: 100%;
- align-items: center;
- justify-content: center;
- }
-}
diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss
deleted file mode 100644
index b07b72f8e..000000000
--- a/app/javascript/styles/mastodon/boost.scss
+++ /dev/null
@@ -1,28 +0,0 @@
-@function hex-color($color) {
- @if type-of($color) == 'color' {
- $color: str-slice(ie-hex-str($color), 4);
- }
- @return '%23' + unquote($color)
-}
-
-button.icon-button i.fa-retweet {
- background-image: url("data:image/svg+xml;utf8, ");
-
- &:hover {
- background-image: url("data:image/svg+xml;utf8, ");
- }
-}
-
-// Disabled variant
-button.icon-button.disabled i.fa-retweet {
- &, &:hover {
- background-image: url("data:image/svg+xml;utf8, ");
- }
-}
-
-// Disabled variant for use with DMs
-.status-direct button.icon-button.disabled i.fa-retweet {
- &, &:hover {
- background-image: url("data:image/svg+xml;utf8, ");
- }
-}
diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss
deleted file mode 100644
index 90d98cc8c..000000000
--- a/app/javascript/styles/mastodon/compact_header.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-.compact-header {
- h1 {
- font-size: 24px;
- line-height: 28px;
- color: $ui-primary-color;
- font-weight: 500;
- margin-bottom: 20px;
- padding: 0 10px;
- word-wrap: break-word;
-
- @media screen and (max-width: 740px) {
- text-align: center;
- padding: 20px 10px 0;
- }
-
- a {
- color: inherit;
- text-decoration: none;
- }
-
- small {
- font-weight: 400;
- color: $ui-secondary-color;
- }
-
- img {
- display: inline-block;
- margin-bottom: -5px;
- margin-right: 15px;
- width: 36px;
- height: 36px;
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
deleted file mode 100644
index 6a6d1bdca..000000000
--- a/app/javascript/styles/mastodon/components.scss
+++ /dev/null
@@ -1,4828 +0,0 @@
-@import 'variables';
-@import 'variables-glitch';
-
-@mixin fullwidth-gallery {
- &.full-width {
- margin-left: -22px;
- margin-right: -22px;
- width: inherit;
- }
-}
-
-.app-body {
- -webkit-overflow-scrolling: touch;
- -ms-overflow-style: -ms-autohiding-scrollbar;
-}
-
-.button {
- background-color: darken($ui-highlight-color, 3%);
- border: 10px none;
- border-radius: 4px;
- box-sizing: border-box;
- color: $primary-text-color;
- cursor: pointer;
- display: inline-block;
- font-family: inherit;
- font-size: 14px;
- font-weight: 500;
- height: 36px;
- letter-spacing: 0;
- line-height: 36px;
- overflow: hidden;
- padding: 0 16px;
- position: relative;
- text-align: center;
- text-transform: uppercase;
- text-decoration: none;
- text-overflow: ellipsis;
- transition: all 100ms ease-in;
- white-space: nowrap;
- width: auto;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-highlight-color, 7%);
- transition: all 200ms ease-out;
- }
-
- &:disabled {
- background-color: $ui-primary-color;
- cursor: default;
- }
-
- &.button-alternative {
- font-size: 16px;
- line-height: 36px;
- height: auto;
- color: $ui-base-color;
- background: $ui-primary-color;
- text-transform: none;
- padding: 4px 16px;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-primary-color, 4%);
- }
- }
-
- &.button-secondary {
- font-size: 16px;
- line-height: 36px;
- height: auto;
- color: $ui-primary-color;
- text-transform: none;
- background: transparent;
- padding: 3px 15px;
- border-radius: 4px;
- border: 1px solid $ui-primary-color;
-
- &:active,
- &:focus,
- &:hover {
- border-color: lighten($ui-primary-color, 4%);
- color: lighten($ui-primary-color, 4%);
- }
- }
-
- &.button--block {
- display: block;
- width: 100%;
- }
-}
-
-.column__wrapper {
- display: flex;
- flex: 1 1 auto;
- position: relative;
-}
-
-.column-icon {
- background: lighten($ui-base-color, 4%);
- color: $ui-primary-color;
- cursor: pointer;
- font-size: 16px;
- padding: 15px;
- position: absolute;
- right: 0;
- top: -48px;
- z-index: 3;
-
- &:hover {
- color: lighten($ui-primary-color, 7%);
- }
-}
-
-.icon-button {
- display: inline-block;
- padding: 0;
- color: $ui-base-lighter-color;
- border: none;
- background: transparent;
- cursor: pointer;
- transition: color 100ms ease-in;
-
- &:hover,
- &:active,
- &:focus {
- color: lighten($ui-base-color, 33%);
- transition: color 200ms ease-out;
- }
-
- &.disabled {
- color: lighten($ui-base-color, 13%);
- cursor: default;
- }
-
- &.active {
- color: $ui-highlight-color;
- }
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
-
- &.inverted {
- color: lighten($ui-base-color, 33%);
-
- &:hover,
- &:active,
- &:focus {
- color: $ui-base-lighter-color;
- }
-
- &.disabled {
- color: $ui-primary-color;
- }
-
- &.active {
- color: $ui-highlight-color;
-
- &.disabled {
- color: lighten($ui-highlight-color, 13%);
- }
- }
- }
-
- &.overlayed {
- box-sizing: content-box;
- background: rgba($base-overlay-background, 0.6);
- color: rgba($primary-text-color, 0.7);
- border-radius: 4px;
- padding: 2px;
-
- &:hover {
- background: rgba($base-overlay-background, 0.9);
- }
- }
-}
-
-.text-icon-button {
- color: lighten($ui-base-color, 33%);
- border: none;
- background: transparent;
- cursor: pointer;
- font-weight: 600;
- font-size: 11px;
- padding: 0 3px;
- line-height: 27px;
- outline: 0;
- transition: color 100ms ease-in;
-
- &:hover,
- &:active,
- &:focus {
- color: $ui-base-lighter-color;
- transition: color 200ms ease-out;
- }
-
- &.disabled {
- color: lighten($ui-base-color, 13%);
- cursor: default;
- }
-
- &.active {
- color: $ui-highlight-color;
- }
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
-}
-
-.dropdown-menu {
- position: absolute;
-}
-
-.dropdown--active .icon-button {
- color: $ui-highlight-color;
-}
-
-.dropdown--active::after {
- @media screen and (min-width: 631px) {
- content: "";
- display: block;
- position: absolute;
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 0 4.5px 7.8px;
- border-color: transparent transparent $ui-secondary-color;
- bottom: 8px;
- right: 104px;
- }
-}
-
-.invisible {
- font-size: 0;
- line-height: 0;
- display: inline-block;
- width: 0;
- height: 0;
- position: absolute;
-
- img,
- svg {
- margin: 0 !important;
- border: 0 !important;
- padding: 0 !important;
- width: 0 !important;
- height: 0 !important;
- }
-}
-
-.ellipsis {
- &::after {
- content: "…";
- }
-}
-
-.lightbox .icon-button {
- color: $ui-base-color;
-}
-
-.compose-form {
- padding: 10px;
-}
-
-.compose-form__warning {
- color: darken($ui-secondary-color, 65%);
- margin-bottom: 15px;
- background: $ui-primary-color;
- box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
- padding: 8px 10px;
- border-radius: 4px;
- font-size: 13px;
- font-weight: 400;
-
- strong {
- color: darken($ui-secondary-color, 65%);
- font-weight: 500;
- }
-
- a {
- color: darken($ui-primary-color, 33%);
- font-weight: 500;
- text-decoration: underline;
-
- &:hover,
- &:active,
- &:focus {
- text-decoration: none;
- }
- }
-}
-
-.compose-form__modifiers {
- color: $ui-base-color;
- font-family: inherit;
- font-size: 14px;
- background: $simple-background-color;
-}
-
-.compose-form__buttons-wrapper {
- display: flex;
- justify-content: space-between;
-}
-
-.compose-form__buttons {
- padding: 10px;
- background: darken($simple-background-color, 8%);
- box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
- border-radius: 0 0 4px 4px;
- display: flex;
-
- .icon-button {
- box-sizing: content-box;
- padding: 0 3px;
- }
-}
-
-.compose-form__buttons-separator {
- border-left: 1px solid #c3c3c3;
- margin: 0 3px;
-}
-
-.compose-form__upload-button-icon {
- line-height: 27px;
-}
-
-.compose-form__sensitive-button {
- display: none;
-
- &.compose-form__sensitive-button--visible {
- display: block;
- }
-
- .compose-form__sensitive-button__icon {
- line-height: 27px;
- }
-}
-
-.compose-form__upload-wrapper {
- overflow: hidden;
-}
-
-.compose-form__uploads-wrapper {
- display: flex;
- flex-direction: row;
- padding: 5px;
- flex-wrap: wrap;
-}
-
-.compose-form__upload {
- flex: 1 1 0;
- min-width: 40%;
- margin: 5px;
-
- &-description {
- position: absolute;
- z-index: 2;
- bottom: 0;
- left: 0;
- right: 0;
- box-sizing: border-box;
- background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
- padding: 10px;
- opacity: 0;
- transition: opacity .1s ease;
-
- input {
- background: transparent;
- color: $ui-secondary-color;
- border: 0;
- padding: 0;
- margin: 0;
- width: 100%;
- font-family: inherit;
- font-size: 14px;
- font-weight: 500;
-
- &:focus {
- color: $white;
- }
-
- &::placeholder {
- opacity: 0.54;
- color: $ui-secondary-color;
- }
- }
-
- &.active {
- opacity: 1;
- }
- }
-
- .icon-button {
- mix-blend-mode: difference;
- }
-}
-
-.compose-form__upload-thumbnail {
- border-radius: 4px;
- background-position: center;
- background-size: cover;
- background-repeat: no-repeat;
- height: 100px;
- width: 100%;
-}
-
-.compose-form__label {
- display: block;
- line-height: 24px;
- vertical-align: middle;
-
- &.with-border {
- border-top: 1px solid $ui-base-color;
- padding-top: 10px;
- }
-
- .compose-form__label__text {
- display: inline-block;
- vertical-align: middle;
- margin-bottom: 14px;
- margin-left: 8px;
- color: $ui-primary-color;
- }
-}
-
-.compose-form__textarea,
-.follow-form__input {
- background: $simple-background-color;
-
- &:disabled {
- background: $ui-secondary-color;
- }
-}
-
-.compose-form__autosuggest-wrapper {
- position: relative;
-
- .emoji-picker-dropdown {
- position: absolute;
- right: 5px;
- top: 5px;
-
- ::-webkit-scrollbar-track:hover,
- ::-webkit-scrollbar-track:active {
- background-color: rgba($base-overlay-background, 0.3);
- }
- }
-}
-
-.compose-form__publish {
- display: flex;
- justify-content: flex-end;
- min-width: 0;
-}
-
-.compose-form__publish-button-wrapper {
- overflow: hidden;
- padding-top: 10px;
- white-space: nowrap;
- display: flex;
-
- button {
- text-overflow: unset;
- }
-}
-
-.compose-form__publish__side-arm {
- padding: 0 !important;
- width: 36px;
- text-align: center;
- margin-right: 2px;
-}
-
-.compose-form__publish__primary {
- padding: 0 10px !important;
-}
-
-.emojione {
- display: inline-block;
- font-size: inherit;
- vertical-align: middle;
- object-fit: contain;
- margin: -.2ex .15em .2ex;
- width: 16px;
- height: 16px;
-
- img {
- width: auto;
- }
-}
-
-.reply-indicator {
- border-radius: 4px 4px 0 0;
- position: relative;
- bottom: -2px;
- background: $ui-primary-color;
- padding: 10px;
-}
-
-.reply-indicator__header {
- margin-bottom: 5px;
- overflow: hidden;
-}
-
-.reply-indicator__cancel {
- float: right;
- line-height: 24px;
-}
-
-.reply-indicator__display-name {
- color: $ui-base-color;
- display: block;
- max-width: 100%;
- line-height: 24px;
- overflow: hidden;
- padding-right: 25px;
- text-decoration: none;
-}
-
-.reply-indicator__display-avatar {
- float: left;
- margin-right: 5px;
-}
-
-.status__content--with-action {
- cursor: pointer;
-}
-
-.status-check-box {
- .status__content,
- .reply-indicator__content {
- color: #3a3a3a;
- a {
- color: #005aa9;
- }
- }
-}
-
-.status__content,
-.reply-indicator__content {
- position: relative;
- margin: 10px 0;
- padding: 0 12px;
- font-size: 15px;
- line-height: 20px;
- color: $primary-text-color;
- word-wrap: break-word;
- font-weight: 400;
- overflow: visible;
- white-space: pre-wrap;
- padding-top: 5px;
-
- &.status__content--with-spoiler {
- white-space: normal;
-
- .status__content__text {
- white-space: pre-wrap;
- }
- }
-
- .emojione {
- width: 20px;
- height: 20px;
- margin: -5px 0 0;
- }
-
- p {
- margin-bottom: 20px;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- a {
- color: $ui-secondary-color;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
-
- .fa {
- color: lighten($ui-base-color, 40%);
- }
- }
-
- &.mention {
- &:hover {
- text-decoration: none;
-
- span {
- text-decoration: underline;
- }
- }
- }
-
- .fa {
- color: lighten($ui-base-color, 30%);
- }
- }
-
- .status__content__spoiler {
- display: none;
-
- &.status__content__spoiler--visible {
- display: block;
- }
- }
-}
-
-.status__content__spoiler-link {
- display: inline-block;
- border-radius: 2px;
- background: lighten($ui-base-color, 30%);
- border: none;
- color: lighten($ui-base-color, 8%);
- font-weight: 500;
- font-size: 11px;
- padding: 0 5px;
- text-transform: uppercase;
- line-height: inherit;
- cursor: pointer;
- vertical-align: bottom;
-
- &:hover {
- background: lighten($ui-base-color, 33%);
- text-decoration: none;
- }
-
- .status__content__spoiler-icon {
- display: inline-block;
- margin: 0 0 0 5px;
- border-left: 1px solid currentColor;
- padding: 0 0 0 4px;
- font-size: 16px;
- vertical-align: -2px;
- }
-}
-
-.status__prepend-icon-wrapper {
- float: left;
- margin: 0 10px 0 -58px;
- width: 48px;
- text-align: right;
-}
-
-.notif-cleaning {
- .status, .notification-follow {
- padding-right: ($dismiss-overlay-width + 0.5rem);
- }
-}
-
-.notification-follow {
- position: relative;
-
- // same like Status
- border-bottom: 1px solid lighten($ui-base-color, 8%);
-
- .account {
- border-bottom: 0 none;
- }
-}
-
-.focusable {
- &:focus {
- outline: 0;
- background: lighten($ui-base-color, 4%);
-
- .status.status-direct {
- background: lighten($ui-base-color, 12%);
- }
-
- .detailed-status,
- .detailed-status__action-bar {
- background: lighten($ui-base-color, 8%);
- }
- }
-}
-
-.status {
- padding: 8px 10px;
- position: relative;
- height: auto;
- min-height: 48px;
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- cursor: default;
-
- @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
- // Add margin to avoid Edge auto-hiding scrollbar appearing over content.
- // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
- padding-right: 26px; // 10px + 16px
- }
-
- @keyframes fade {
- 0% { opacity: 0; }
- 100% { opacity: 1; }
- }
-
- opacity: 1;
- animation: fade 150ms linear;
-
- .video-player {
- margin-top: 8px;
- }
-
- &.status-direct {
- background: lighten($ui-base-color, 8%);
-
- .icon-button.disabled {
- color: lighten($ui-base-color, 16%);
- }
- }
-
- &.light {
- .status__relative-time {
- color: $ui-primary-color;
- }
-
- .status__display-name {
- color: $ui-base-color;
- }
-
- .display-name {
- strong {
- color: $ui-base-color;
- }
-
- span {
- color: $ui-primary-color;
- }
- }
-
- .status__content {
- color: $ui-base-color;
-
- a {
- color: $ui-highlight-color;
- }
-
- a.status__content__spoiler-link {
- color: $primary-text-color;
- background: $ui-primary-color;
-
- &:hover {
- background: lighten($ui-primary-color, 8%);
- }
- }
- }
- }
-
- &.collapsed {
- background-position: center;
- background-size: cover;
- user-select: none;
-
- &.has-background::before {
- display: block;
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));
- content: "";
- }
-
- .display-name:hover .display-name__html {
- text-decoration: none;
- }
-
- .status__content {
- height: 20px;
- overflow: hidden;
- text-overflow: ellipsis;
-
- a:hover {
- text-decoration: none;
- }
- }
- }
-
- .notification__message {
- margin: -10px -10px 10px;
- }
-}
-
-.notification-favourite {
- .status.status-direct {
- background: transparent;
-
- .icon-button.disabled {
- color: lighten($ui-base-color, 13%);
- }
- }
-}
-
-.status__relative-time {
- display: inline-block;
- margin-left: auto;
- padding-left: 18px;
- width: 120px;
- color: $ui-base-lighter-color;
- font-size: 14px;
- text-align: right;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.status__display-name {
- margin: 0 auto 0 0;
- color: $ui-base-lighter-color;
- overflow: hidden;
-}
-
-.status__info {
- display: flex;
- margin: 2px 0 5px;
- font-size: 15px;
- line-height: 24px;
-}
-
-.status__info__icons {
- flex: none;
- position: relative;
- color: lighten($ui-base-color, 26%);
-
- .status__visibility-icon {
- padding-left: 6px;
- }
-}
-
-.status-check-box {
- border-bottom: 1px solid $ui-secondary-color;
- display: flex;
-
- .status__content {
- flex: 1 1 auto;
- padding: 10px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-}
-
-.status-check-box-toggle {
- align-items: center;
- display: flex;
- flex: 0 0 auto;
- justify-content: center;
- padding: 10px;
-}
-
-.status__prepend {
- margin: -10px -10px 10px;
- color: $ui-base-lighter-color;
- padding: 8px 10px 0 68px;
- font-size: 14px;
- position: relative;
-
- .status__display-name strong {
- color: $ui-base-lighter-color;
- }
-
- > span {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-
-.status__action-bar {
- align-items: center;
- display: flex;
- margin: 10px 4px 0;
-}
-
-.status__action-bar-button {
- float: left;
- margin-right: 18px;
- flex: 0 0 auto;
-}
-
-.status__action-bar-dropdown {
- float: left;
- height: 23.15px;
- width: 23.15px;
-
- // Dropdown style override for centering on the icon
- .dropdown--active {
- position: relative;
-
- .dropdown__content.dropdown__right {
- left: calc(50% + 3px);
- right: initial;
- transform: translate(-50%, 0);
- top: 22px;
- }
-
- &::after {
- right: 1px;
- bottom: -2px;
- }
- }
-}
-
-.detailed-status__action-bar-dropdown {
- flex: 1 1 auto;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
-}
-
-.detailed-status {
- background: lighten($ui-base-color, 4%);
- padding: 14px 10px;
-
- .status__content {
- font-size: 19px;
- line-height: 24px;
-
- .emojione {
- width: 24px;
- height: 24px;
- margin: -5px 0 0;
- }
- }
-
- .video-player {
- margin-top: 8px;
- }
-}
-
-.detailed-status__meta {
- margin-top: 15px;
- color: $ui-base-lighter-color;
- font-size: 14px;
- line-height: 18px;
-}
-
-.detailed-status__action-bar {
- background: lighten($ui-base-color, 4%);
- border-top: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- display: flex;
- flex-direction: row;
- padding: 10px 0;
-}
-
-.detailed-status__link {
- color: inherit;
- text-decoration: none;
-}
-
-.detailed-status__favorites,
-.detailed-status__reblogs {
- display: inline-block;
- font-weight: 500;
- font-size: 12px;
- margin-left: 6px;
-}
-
-.reply-indicator__content {
- color: $ui-base-color;
- font-size: 14px;
-
- a {
- color: lighten($ui-base-color, 20%);
- }
-}
-
-.account {
- padding: 10px;
- border-bottom: 1px solid lighten($ui-base-color, 8%);
-
- .account__display-name {
- flex: 1 1 auto;
- display: block;
- color: $ui-primary-color;
- overflow: hidden;
- text-decoration: none;
- font-size: 14px;
- }
-}
-
-.account__wrapper {
- display: flex;
-}
-
-.account__avatar-wrapper {
- float: left;
- margin: 6px 16px 6px 6px;
-}
-
-.account__avatar {
- @include avatar-radius();
- position: relative;
- cursor: pointer;
-
- &-inline {
- display: inline-block;
- vertical-align: middle;
- margin-right: 5px;
- }
-}
-
-.account__avatar-overlay {
- position: relative;
- @include avatar-size(48px);
-
- &-base {
- @include avatar-radius();
- @include avatar-size(36px);
- }
-
- &-overlay {
- @include avatar-radius();
- @include avatar-size(24px);
-
- position: absolute;
- bottom: 0;
- right: 0;
- z-index: 1;
- }
-}
-
-.account__relationship {
- height: 18px;
- padding: 12px 10px;
- white-space: nowrap;
-}
-
-.account__header__wrapper {
- flex: 0 0 auto;
- background: lighten($ui-base-color, 4%);
-}
-
-.account__header {
- text-align: center;
- background-size: cover;
- background-position: center;
- position: relative;
-
- & > div {
- background: rgba(lighten($ui-base-color, 4%), 0.9);
- padding: 20px 10px;
- }
-
- .account__header__content {
- color: $ui-secondary-color;
- }
-
- .account__header__display-name {
- color: $primary-text-color;
- display: inline-block;
- width: 100%;
- font-size: 20px;
- line-height: 27px;
- font-weight: 500;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .account__header__username {
- color: $ui-highlight-color;
- font-size: 14px;
- font-weight: 400;
- display: block;
- margin-bottom: 10px;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-
-.account__disclaimer {
- padding: 10px;
- border-top: 1px solid lighten($ui-base-color, 8%);
- color: $ui-base-lighter-color;
-
- strong {
- font-weight: 500;
- }
-
- a {
- font-weight: 500;
- color: inherit;
- text-decoration: underline;
-
- &:hover,
- &:focus,
- &:active {
- text-decoration: none;
- }
- }
-}
-
-.account__header__content {
- color: $ui-primary-color;
- font-size: 14px;
- font-weight: 400;
- overflow: hidden;
- word-break: normal;
- word-wrap: break-word;
-
- p {
- margin-bottom: 20px;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- a {
- color: inherit;
- text-decoration: underline;
-
- &:hover {
- text-decoration: none;
- }
- }
-}
-
-.account__header__display-name {
- .emojione {
- width: 25px;
- height: 25px;
- }
-}
-
-.account__metadata {
- width: 100%;
- font-size: 15px;
- line-height: 20px;
- overflow: hidden;
- border-collapse: collapse;
-
- a {
- text-decoration: none;
-
- &:hover{
- text-decoration: underline;
- }
- }
-
- tr {
- border-top: 1px solid lighten($ui-base-color, 8%);
- }
-
- th, td {
- padding: 14px 20px;
- vertical-align: middle;
-
- & > div {
- max-height: 40px;
- overflow-y: auto;
- white-space: pre-wrap;
- text-overflow: ellipsis;
- }
- }
-
- th {
- color: $ui-primary-color;
- background: lighten($ui-base-color, 13%);
- font-variant: small-caps;
- max-width: 120px;
-
- a {
- color: $primary-text-color;
- }
- }
-
- td {
- flex: auto;
- color: $primary-text-color;
- background: $ui-base-color;
-
- a {
- color: $ui-highlight-color;
- }
- }
-}
-
-.account__action-bar {
- border-top: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- line-height: 36px;
- overflow: hidden;
- flex: 0 0 auto;
- display: flex;
-}
-
-.account__action-bar-dropdown {
- flex: 0 1 calc(50% - 140px);
- padding: 10px;
-
- .dropdown--active {
- .dropdown__content.dropdown__right {
- left: 6px;
- right: initial;
- }
-
- &::after {
- bottom: initial;
- margin-left: 11px;
- margin-top: -7px;
- right: initial;
- }
- }
-}
-
-.account__action-bar-links {
- display: flex;
- flex: 1 1 auto;
- line-height: 18px;
-}
-
-.account__action-bar__tab {
- text-decoration: none;
- overflow: hidden;
- flex: 0 1 80px;
- border-left: 1px solid lighten($ui-base-color, 8%);
- padding: 10px 5px;
-
- & > span {
- display: block;
- text-transform: uppercase;
- font-size: 11px;
- color: $ui-primary-color;
- }
-
- strong {
- display: block;
- font-size: 15px;
- font-weight: 500;
- color: $primary-text-color;
- }
-
- abbr {
- color: $ui-base-lighter-color;
- }
-}
-
-.account__header__avatar {
- @include avatar-radius();
- @include avatar-size(90px);
- display: block;
- margin: 0 auto 10px;
- overflow: hidden;
-}
-
-.account-authorize {
- padding: 14px 10px;
-
- .detailed-status__display-name {
- display: block;
- margin-bottom: 15px;
- overflow: hidden;
- }
-}
-
-.account-authorize__avatar {
- float: left;
- margin-right: 10px;
-}
-
-.status__display-name,
-.status__relative-time,
-.detailed-status__display-name,
-.detailed-status__datetime,
-.detailed-status__application,
-.account__display-name {
- text-decoration: none;
-}
-
-.status__display-name,
-.account__display-name {
- strong {
- color: $primary-text-color;
- }
-}
-
-.muted {
- .emojione {
- opacity: 0.5;
- }
-}
-
-.account__display-name strong {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.detailed-status__application,
-.detailed-status__datetime {
- color: inherit;
-}
-
-.detailed-status__display-name {
- color: $ui-secondary-color;
- display: block;
- line-height: 24px;
- margin-bottom: 15px;
- overflow: hidden;
-
- strong,
- span {
- display: block;
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
- strong {
- font-size: 16px;
- color: $primary-text-color;
- }
-}
-
-.detailed-status__display-avatar {
- float: left;
- margin-right: 10px;
-}
-
-.status__avatar {
- flex: none;
- margin: 0 10px 0 0;
- height: 48px;
- width: 48px;
-}
-
-.muted {
- .status__content p,
- .status__content a {
- color: $ui-base-lighter-color;
- }
-
- .status__display-name strong {
- color: $ui-base-lighter-color;
- }
-
- .status__avatar, .emojione {
- opacity: 0.5;
- }
-
- a.status__content__spoiler-link {
- background: $ui-base-lighter-color;
- color: lighten($ui-base-color, 4%);
-
- &:hover {
- background: lighten($ui-base-color, 29%);
- text-decoration: none;
- }
- }
-}
-
-.notification__message {
- padding: 8px 10px 0 68px;
- cursor: default;
- color: $ui-primary-color;
- font-size: 15px;
- position: relative;
-
- .fa {
- color: $ui-highlight-color;
- }
-
- > span {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-
-.notification__favourite-icon-wrapper {
- float: left;
- margin: 0 10px 0 -58px;
- width: 48px;
- text-align: right;
-
- .star-icon {
- color: $gold-star;
- }
-}
-
-.star-icon.active {
- color: $gold-star;
-}
-
-.notification__display-name {
- color: inherit;
- font-weight: 500;
- text-decoration: none;
-
- &:hover {
- color: $primary-text-color;
- text-decoration: underline;
- }
-}
-
-.display-name {
- display: block;
- padding: 6px 0;
- max-width: 100%;
- height: 36px;
- overflow: hidden;
-
- strong {
- display: block;
- height: 18px;
- font-size: 16px;
- font-weight: 500;
- line-height: 18px;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
-
- span {
- display: block;
- height: 18px;
- font-size: 15px;
- line-height: 18px;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
-
- &:hover {
- strong {
- text-decoration: underline;
- }
- }
-}
-
-.status__relative-time,
-.detailed-status__datetime {
- &:hover {
- text-decoration: underline;
- }
-}
-
-.image-loader {
- position: relative;
-
- &.image-loader--loading {
- .image-loader__preview-canvas {
- filter: blur(2px);
- }
- }
-
- .image-loader__img {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- max-width: 100%;
- max-height: 100%;
- background-image: none;
- }
-
- &.image-loader--amorphous {
- position: static;
-
- .image-loader__preview-canvas {
- display: none;
- }
-
- .image-loader__img {
- position: static;
- width: auto;
- height: auto;
- }
- }
-}
-
-.navigation-bar {
- padding: 10px;
- display: flex;
- flex-shrink: 0;
- cursor: default;
- color: $ui-primary-color;
-
- strong {
- color: $primary-text-color;
- }
-
- .permalink {
- text-decoration: none;
- }
-
- .icon-button {
- pointer-events: none;
- opacity: 0;
- }
-}
-
-.navigation-bar__profile {
- flex: 1 1 auto;
- margin-left: 8px;
- overflow: hidden;
-}
-
-.navigation-bar__profile-account {
- display: block;
- font-weight: 500;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.navigation-bar__profile-edit {
- color: inherit;
- text-decoration: none;
-}
-
-.dropdown {
- display: inline-block;
-}
-
-.dropdown__content {
- display: none;
- position: absolute;
-}
-
-.dropdown-menu__separator {
- border-bottom: 1px solid darken($ui-secondary-color, 8%);
- margin: 5px 7px 6px;
- height: 0;
-}
-
-.dropdown-menu {
- background: $ui-secondary-color;
- padding: 4px 0;
- border-radius: 4px;
- box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
-
- ul {
- list-style: none;
- }
-}
-
-.dropdown-menu__arrow {
- position: absolute;
- width: 0;
- height: 0;
- border: 0 solid transparent;
-
- &.left {
- right: -5px;
- margin-top: -5px;
- border-width: 5px 0 5px 5px;
- border-left-color: $ui-secondary-color;
- }
-
- &.top {
- bottom: -5px;
- margin-left: -13px;
- border-width: 5px 7px 0;
- border-top-color: $ui-secondary-color;
- }
-
- &.bottom {
- top: -5px;
- margin-left: -13px;
- border-width: 0 7px 5px;
- border-bottom-color: $ui-secondary-color;
- }
-
- &.right {
- left: -5px;
- margin-top: -5px;
- border-width: 5px 5px 5px 0;
- border-right-color: $ui-secondary-color;
- }
-}
-
-.dropdown-menu__item {
- a {
- font-size: 13px;
- line-height: 18px;
- display: block;
- padding: 4px 14px;
- box-sizing: border-box;
- text-decoration: none;
- background: $ui-secondary-color;
- color: $ui-base-color;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &:focus,
- &:hover,
- &:active {
- background: $ui-highlight-color;
- color: $ui-secondary-color;
- outline: 0;
- }
- }
-}
-
-.dropdown--active .dropdown__content {
- display: block;
- line-height: 18px;
- max-width: 311px;
- right: 0;
- text-align: left;
- z-index: 9999;
-
- & > ul {
- list-style: none;
- background: $ui-secondary-color;
- padding: 4px 0;
- border-radius: 4px;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
- min-width: 140px;
- position: relative;
- }
-
- &.dropdown__right {
- right: 0;
- }
-
- &.dropdown__left {
- & > ul {
- left: -98px;
- }
- }
-
- & > ul > li > a {
- font-size: 13px;
- line-height: 18px;
- display: block;
- padding: 4px 14px;
- box-sizing: border-box;
- text-decoration: none;
- background: $ui-secondary-color;
- color: $ui-base-color;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &:focus {
- outline: 0;
- }
-
- &:hover {
- background: $ui-highlight-color;
- color: $ui-secondary-color;
- }
- }
-}
-
-.dropdown__icon {
- vertical-align: middle;
-}
-
-.static-content {
- padding: 10px;
- padding-top: 20px;
- color: $ui-base-lighter-color;
-
- h1 {
- font-size: 16px;
- font-weight: 500;
- margin-bottom: 40px;
- text-align: center;
- }
-
- p {
- font-size: 13px;
- margin-bottom: 20px;
- }
-}
-
-.columns-area {
- display: flex;
- flex: 1 1 auto;
- flex-direction: row;
- justify-content: flex-start;
- overflow-x: auto;
- position: relative;
- padding: 10px;
-}
-
-@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
- .columns-area {
- padding: 0;
- }
-
- .react-swipeable-view-container .columns-area {
- height: calc(100% - 20px) !important;
- }
-}
-
-.react-swipeable-view-container {
- &,
- .columns-area,
- .drawer,
- .column {
- height: 100%;
- }
-}
-
-.react-swipeable-view-container > * {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
-}
-
-.column {
- width: 330px;
- position: relative;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-
- .wide & {
- flex: auto;
- min-width: 330px;
- max-width: 400px;
- }
-
- > .scrollable {
- background: $ui-base-color;
- }
-}
-
-.ui {
- flex: 0 0 auto;
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100%;
- background: darken($ui-base-color, 7%);
-}
-
-.drawer {
- width: 300px;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- overflow-y: auto;
-
- .wide & {
- flex: 1 1 200px;
- min-width: 300px;
- max-width: 400px;
- }
-}
-
-.drawer__tab {
- display: block;
- flex: 1 1 auto;
- padding: 15px 5px 13px;
- color: $ui-primary-color;
- text-decoration: none;
- text-align: center;
- font-size: 16px;
- border-bottom: 2px solid transparent;
- outline: none;
- cursor: pointer;
-}
-
-.column,
-.drawer {
- flex: 1 1 100%;
- overflow: hidden;
-}
-
-@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
- .tabs-bar {
- margin: 0;
- }
-
- .search {
- margin-bottom: 0;
- }
-}
-
-:root { // Overrides .wide stylings for mobile view
- @include single-column('screen and (max-width: 630px)', $parent: null) {
- .column,
- .drawer {
- flex: auto;
- width: 100%;
- min-width: 0;
- max-width: none;
- padding: 0;
- }
-
- .columns-area {
- flex-direction: column;
- }
-
- .search__input,
- .autosuggest-textarea__textarea {
- font-size: 16px;
- }
- }
-}
-
-@include multi-columns('screen and (min-width: 631px)', $parent: null) {
- .columns-area {
- padding: 0;
- }
-
- .column,
- .drawer {
- padding: 10px;
- padding-left: 5px;
- padding-right: 5px;
-
- &:first-child {
- padding-left: 10px;
- }
-
- &:last-child {
- padding-right: 10px;
- }
- }
-
- .columns-area > div {
- .column,
- .drawer {
- padding-left: 5px;
- padding-right: 5px;
- }
- }
-}
-
-.drawer__pager {
- box-sizing: border-box;
- padding: 0;
- flex: 1 1 auto;
- position: relative;
-}
-
-.drawer__inner {
- background: lighten($ui-base-color, 13%);
- box-sizing: border-box;
- padding: 0;
- position: absolute;
- height: 100%;
- width: 100%;
-
- &.darker {
- position: absolute;
- top: 0;
- left: 0;
- background: $ui-base-color;
- width: 100%;
- height: 100%;
- }
-}
-
-.pseudo-drawer {
- background: lighten($ui-base-color, 13%);
- font-size: 13px;
- text-align: left;
-}
-
-.drawer__header {
- flex: 0 0 auto;
- font-size: 16px;
- background: lighten($ui-base-color, 8%);
- margin-bottom: 10px;
- display: flex;
- flex-direction: row;
-
- a {
- transition: background 100ms ease-in;
-
- &:hover {
- background: lighten($ui-base-color, 3%);
- transition: background 200ms ease-out;
- }
- }
-}
-
-.tabs-bar {
- display: flex;
- background: lighten($ui-base-color, 8%);
- flex: 0 0 auto;
- overflow-y: auto;
- margin: 10px;
- margin-bottom: 0;
-}
-
-.tabs-bar__link {
- display: block;
- flex: 1 1 auto;
- padding: 15px 10px;
- color: $primary-text-color;
- text-decoration: none;
- text-align: center;
- font-size: 14px;
- font-weight: 500;
- border-bottom: 2px solid lighten($ui-base-color, 8%);
- transition: all 200ms linear;
-
- .fa {
- font-weight: 400;
- font-size: 16px;
- }
-
- &.active {
- border-bottom: 2px solid $ui-highlight-color;
- color: $ui-highlight-color;
- }
-
- &:hover,
- &:focus,
- &:active {
- @include multi-columns('screen and (min-width: 631px)') {
- background: lighten($ui-base-color, 14%);
- transition: all 100ms linear;
- }
- }
-
- span {
- margin-left: 5px;
- display: none;
- }
-}
-
-@include limited-single-column('screen and (max-width: 600px)', $parent: null) {
- .tabs-bar__link {
- span {
- display: inline;
- }
- }
-}
-
-@include multi-columns('screen and (min-width: 631px)', $parent: null) {
- .tabs-bar {
- display: none;
- }
-}
-
-.scrollable {
- overflow-y: scroll;
- overflow-x: hidden;
- flex: 1 1 auto;
- -webkit-overflow-scrolling: touch;
- will-change: transform; // improves perf in mobile Chrome
-
- &.optionally-scrollable {
- overflow-y: auto;
- }
-
- @supports(display: grid) { // hack to fix Chrome <57
- contain: strict;
- }
-}
-
-.scrollable.fullscreen {
- @supports(display: grid) { // hack to fix Chrome <57
- contain: none;
- }
-}
-
-.column-back-button {
- background: lighten($ui-base-color, 4%);
- color: $ui-highlight-color;
- cursor: pointer;
- flex: 0 0 auto;
- font-size: 16px;
- border: 0;
- text-align: unset;
- padding: 15px;
- margin: 0;
- z-index: 3;
-
- &:hover {
- text-decoration: underline;
- }
-}
-
-.column-header__back-button {
- background: lighten($ui-base-color, 4%);
- border: 0;
- font-family: inherit;
- color: $ui-highlight-color;
- cursor: pointer;
- flex: 0 0 auto;
- font-size: 16px;
- padding: 0 5px 0 0;
- z-index: 3;
-
- &:hover {
- text-decoration: underline;
- }
-
- &:last-child {
- padding: 0 15px 0 0;
- }
-}
-
-.column-back-button__icon {
- display: inline-block;
- margin-right: 5px;
-}
-
-.column-back-button--slim {
- position: relative;
-}
-
-.column-back-button--slim-button {
- cursor: pointer;
- flex: 0 0 auto;
- font-size: 16px;
- padding: 15px;
- position: absolute;
- right: 0;
- top: -48px;
-}
-
-.react-toggle {
- display: inline-block;
- position: relative;
- cursor: pointer;
- background-color: transparent;
- border: 0;
- padding: 0;
- user-select: none;
- -webkit-tap-highlight-color: rgba($base-overlay-background, 0);
- -webkit-tap-highlight-color: transparent;
-}
-
-.react-toggle-screenreader-only {
- border: 0;
- clip: rect(0 0 0 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- width: 1px;
-}
-
-.react-toggle--disabled {
- cursor: not-allowed;
- opacity: 0.5;
- transition: opacity 0.25s;
-}
-
-.react-toggle-track {
- width: 50px;
- height: 24px;
- padding: 0;
- border-radius: 30px;
- background-color: $ui-base-color;
- transition: all 0.2s ease;
-}
-
-.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
- background-color: darken($ui-base-color, 10%);
-}
-
-.react-toggle--checked .react-toggle-track {
- background-color: $ui-highlight-color;
-}
-
-.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
- background-color: lighten($ui-highlight-color, 10%);
-}
-
-.react-toggle-track-check {
- position: absolute;
- width: 14px;
- height: 10px;
- top: 0;
- bottom: 0;
- margin-top: auto;
- margin-bottom: auto;
- line-height: 0;
- left: 8px;
- opacity: 0;
- transition: opacity 0.25s ease;
-}
-
-.react-toggle--checked .react-toggle-track-check {
- opacity: 1;
- transition: opacity 0.25s ease;
-}
-
-.react-toggle-track-x {
- position: absolute;
- width: 10px;
- height: 10px;
- top: 0;
- bottom: 0;
- margin-top: auto;
- margin-bottom: auto;
- line-height: 0;
- right: 10px;
- opacity: 1;
- transition: opacity 0.25s ease;
-}
-
-.react-toggle--checked .react-toggle-track-x {
- opacity: 0;
-}
-
-.react-toggle-thumb {
- transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
- position: absolute;
- top: 1px;
- left: 1px;
- width: 22px;
- height: 22px;
- border: 1px solid $ui-base-color;
- border-radius: 50%;
- background-color: darken($simple-background-color, 2%);
- box-sizing: border-box;
- transition: all 0.25s ease;
-}
-
-.react-toggle--checked .react-toggle-thumb {
- left: 27px;
- border-color: $ui-highlight-color;
-}
-
-.column-link {
- background: lighten($ui-base-color, 8%);
- color: $primary-text-color;
- display: block;
- font-size: 16px;
- padding: 15px;
- text-decoration: none;
- cursor: pointer;
- outline: none;
-
- &:hover {
- background: lighten($ui-base-color, 11%);
- }
-}
-
-.column-link__icon {
- display: inline-block;
- margin-right: 5px;
-}
-
-.column-subheading {
- background: $ui-base-color;
- color: $ui-base-lighter-color;
- padding: 8px 20px;
- font-size: 12px;
- font-weight: 500;
- text-transform: uppercase;
- cursor: default;
-}
-
-.autosuggest-textarea,
-.spoiler-input {
- position: relative;
-}
-
-.autosuggest-textarea__textarea,
-.spoiler-input__input {
- display: block;
- box-sizing: border-box;
- width: 100%;
- margin: 0;
- color: $ui-base-color;
- background: $simple-background-color;
- padding: 10px;
- font-family: inherit;
- font-size: 14px;
- resize: vertical;
- border: 0;
- outline: 0;
-
- &:focus {
- outline: 0;
- }
-
- @include limited-single-column('screen and (max-width: 600px)') {
- font-size: 16px;
- }
-}
-
-.spoiler-input__input {
- border-radius: 4px;
-}
-
-.autosuggest-textarea__textarea {
- min-height: 100px;
- border-radius: 4px 4px 0 0;
- padding-bottom: 0;
- padding-right: 10px + 22px;
- resize: none;
-
- @include limited-single-column('screen and (max-width: 600px)') {
- height: 100px !important; // prevent auto-resize textarea
- resize: vertical;
- }
-}
-
-.autosuggest-textarea__suggestions {
- box-sizing: border-box;
- display: none;
- position: absolute;
- top: 100%;
- width: 100%;
- z-index: 99;
- box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
- background: $ui-secondary-color;
- border-radius: 0 0 4px 4px;
- color: $ui-base-color;
- font-size: 14px;
- padding: 6px;
-
- &.autosuggest-textarea__suggestions--visible {
- display: block;
- }
-}
-
-.autosuggest-textarea__suggestions__item {
- padding: 10px;
- cursor: pointer;
- border-radius: 4px;
-
- &:hover,
- &:focus,
- &:active,
- &.selected {
- background: darken($ui-secondary-color, 10%);
- }
-}
-
-.autosuggest-account,
-.autosuggest-emoji {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: flex-start;
- line-height: 18px;
- font-size: 14px;
-}
-
-.autosuggest-account-icon,
-.autosuggest-emoji img {
- display: block;
- margin-right: 8px;
- width: 16px;
- height: 16px;
-}
-
-.autosuggest-account .display-name__account {
- color: lighten($ui-base-color, 36%);
-}
-
-.character-counter__wrapper {
- line-height: 36px;
- margin: 0 16px 0 8px;
- padding-top: 10px;
-}
-
-.character-counter {
- cursor: default;
- font-size: 16px;
-}
-
-.character-counter--over {
- color: $warning-red;
-}
-
-.getting-started__wrapper {
- position: relative;
- overflow-y: auto;
-}
-
-.getting-started__footer {
- display: flex;
- flex-direction: column;
-}
-
-.getting-started {
- box-sizing: border-box;
- padding-bottom: 235px;
- background: url('../images/mastodon-getting-started.png') no-repeat 0 100%;
- flex: 1 0 auto;
-
- p {
- color: $ui-secondary-color;
- }
-
- a {
- color: $ui-base-lighter-color;
- }
-}
-
-.setting-text {
- color: $ui-primary-color;
- background: transparent;
- border: none;
- border-bottom: 2px solid $ui-primary-color;
- box-sizing: border-box;
- display: block;
- font-family: inherit;
- margin-bottom: 10px;
- padding: 7px 0;
- width: 100%;
-
- &:focus,
- &:active {
- color: $primary-text-color;
- border-bottom-color: $ui-highlight-color;
- }
-
- @include limited-single-column('screen and (max-width: 600px)') {
- font-size: 16px;
- }
-
- &.light {
- color: $ui-base-color;
- border-bottom: 2px solid lighten($ui-base-color, 27%);
-
- &:focus,
- &:active {
- color: $ui-base-color;
- border-bottom-color: $ui-highlight-color;
- }
- }
-}
-
-@import 'boost';
-
-button.icon-button i.fa-retweet {
- background-position: 0 0;
- height: 19px;
- transition: background-position 0.9s steps(10);
- transition-duration: 0s;
- vertical-align: middle;
- width: 22px;
-
- &::before {
- display: none !important;
- }
-}
-
-button.icon-button.active i.fa-retweet {
- transition-duration: 0.9s;
- background-position: 0 100%;
-}
-
-.status-card {
- display: flex;
- cursor: pointer;
- font-size: 14px;
- border: 1px solid lighten($ui-base-color, 8%);
- border-radius: 4px;
- color: $ui-base-lighter-color;
- margin-top: 14px;
- text-decoration: none;
- overflow: hidden;
-
- &:hover {
- background: lighten($ui-base-color, 8%);
- }
-}
-
-.status-card-video,
-.status-card-rich,
-.status-card-photo {
- margin-top: 14px;
- overflow: hidden;
-
- iframe {
- width: 100%;
- height: auto;
- }
-}
-
-.status-card-photo {
- display: block;
- text-decoration: none;
-
- img {
- display: block;
- width: 100%;
- height: auto;
- margin: 0;
- }
-}
-
-.status-card-video {
- iframe {
- width: 100%;
- height: 100%;
- }
-}
-
-.status-card__title {
- display: block;
- font-weight: 500;
- margin-bottom: 5px;
- color: $ui-primary-color;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.status-card__content {
- flex: 1 1 auto;
- overflow: hidden;
- padding: 14px 14px 14px 8px;
-}
-
-.status-card__description {
- color: $ui-primary-color;
-}
-
-.status-card__host {
- display: block;
- margin-top: 5px;
- font-size: 13px;
-}
-
-.status-card__image {
- flex: 0 0 100px;
- background: lighten($ui-base-color, 8%);
-}
-
-.status-card.horizontal {
- display: block;
-
- .status-card__image {
- width: 100%;
- }
-
- .status-card__image-image {
- border-radius: 4px 4px 0 0;
- }
-}
-
-.status-card__image-image {
- border-radius: 4px 0 0 4px;
- display: block;
- height: auto;
- margin: 0;
- width: 100%;
-}
-
-.load-more {
- display: block;
- color: $ui-base-lighter-color;
- background-color: transparent;
- border: 0;
- font-size: inherit;
- text-align: center;
- line-height: inherit;
- margin: 0;
- padding: 15px;
- width: 100%;
- clear: both;
-
- &:hover {
- background: lighten($ui-base-color, 2%);
- }
-}
-
-.missing-indicator {
- text-align: center;
- font-size: 16px;
- font-weight: 500;
- color: lighten($ui-base-color, 16%);
- background: $ui-base-color;
- cursor: default;
- display: flex;
- flex: 1 1 auto;
- align-items: center;
- justify-content: center;
-
- & > div {
- background: url('../images/mastodon-not-found.png') no-repeat center -50px;
- padding-top: 210px;
- width: 100%;
- }
-}
-
-.column-header__wrapper {
- position: relative;
- flex: 0 0 auto;
-
- &.active {
- &::before {
- display: block;
- content: "";
- position: absolute;
- top: 35px;
- left: 0;
- right: 0;
- margin: 0 auto;
- width: 60%;
- pointer-events: none;
- height: 28px;
- z-index: 1;
- background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
- }
- }
-}
-
-.column-header {
- display: flex;
- padding: 15px;
- font-size: 16px;
- background: lighten($ui-base-color, 4%);
- flex: 0 0 auto;
- cursor: pointer;
- position: relative;
- z-index: 2;
- outline: 0;
-
- &.active {
- box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
-
- .column-header__icon {
- color: $ui-highlight-color;
- text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);
- }
- }
-
- &:focus,
- &:active {
- outline: 0;
- }
-}
-
-.column-header__buttons {
- height: 48px;
- display: flex;
- margin: -15px;
- margin-left: 0;
-}
-
-.column-header__button {
- background: lighten($ui-base-color, 4%);
- border: 0;
- color: $ui-primary-color;
- cursor: pointer;
- font-size: 16px;
- padding: 0 15px;
-
- &:hover {
- color: lighten($ui-primary-color, 7%);
- }
-
- &.active {
- color: $primary-text-color;
- background: lighten($ui-base-color, 8%);
-
- &:hover {
- color: $primary-text-color;
- background: lighten($ui-base-color, 8%);
- }
- }
-
- // glitch - added focus ring for keyboard navigation
- &:focus {
- text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
- }
-}
-
-.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {
- border-top: 1px solid $ui-base-color;
-}
-
-.notification__dismiss-overlay {
- overflow: hidden;
- position: absolute;
- top: 0;
- right: 0;
- bottom: -1px;
- padding-left: 15px; // space for the box shadow to be visible
-
- z-index: 999;
- align-items: center;
- justify-content: flex-end;
- cursor: pointer;
-
- display: flex;
-
- .wrappy {
- width: $dismiss-overlay-width;
- align-self: stretch;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: lighten($ui-base-color, 8%);
- border-left: 1px solid lighten($ui-base-color, 20%);
- box-shadow: 0 0 5px black;
- border-bottom: 1px solid $ui-base-color;
- }
-
- .ckbox {
- border: 2px solid $ui-primary-color;
- border-radius: 2px;
- width: 30px;
- height: 30px;
- font-size: 20px;
- color: $ui-primary-color;
- text-shadow: 0 0 5px black;
- display: flex;
- justify-content: center;
- align-items: center;
- }
-
- &:focus {
- outline: 0 !important;
-
- .ckbox {
- box-shadow: 0 0 1px 1px $ui-highlight-color;
- }
- }
-}
-
-.column-header__notif-cleaning-buttons {
- display: flex;
- align-items: stretch;
- justify-content: space-around;
-
- button {
- @extend .column-header__button;
- background: transparent;
- text-align: center;
- padding: 10px 0;
- white-space: pre-wrap;
- }
-
- b {
- font-weight: bold;
- }
-}
-
-// The notifs drawer with no padding to have more space for the buttons
-.column-header__collapsible-inner.nopad-drawer {
- padding: 0;
-}
-
-.column-header__collapsible {
- max-height: 70vh;
- overflow: hidden;
- overflow-y: auto;
- color: $ui-primary-color;
- transition: max-height 150ms ease-in-out, opacity 300ms linear;
- opacity: 1;
-
- &.collapsed {
- max-height: 0;
- opacity: 0.5;
- }
-
- &.animating {
- overflow-y: hidden;
- }
-
- // notif cleaning drawer
- &.ncd {
- transition: none;
- &.collapsed {
- max-height: 0;
- opacity: 0.7;
- }
- }
-}
-
-.column-header__collapsible-inner {
- background: lighten($ui-base-color, 8%);
- padding: 15px;
-}
-
-.column-header__setting-btn {
- &:hover {
- color: lighten($ui-primary-color, 4%);
- text-decoration: underline;
- }
-}
-
-.column-header__setting-arrows {
- float: right;
-
- .column-header__setting-btn {
- padding: 0 10px;
-
- &:last-child {
- padding-right: 0;
- }
- }
-}
-
-.column-header__title {
- display: inline-block;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- flex: 1;
-}
-
-.text-btn {
- display: inline-block;
- padding: 0;
- font-family: inherit;
- font-size: inherit;
- color: inherit;
- border: 0;
- background: transparent;
- cursor: pointer;
-}
-
-.column-header__icon {
- display: inline-block;
- margin-right: 5px;
-}
-
-.loading-indicator {
- color: lighten($ui-base-color, 26%);
- font-size: 12px;
- font-weight: 400;
- text-transform: uppercase;
- overflow: visible;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
-
- span {
- display: block;
- float: left;
- margin-left: 50%;
- transform: translateX(-50%);
- margin: 82px 0 0 50%;
- white-space: nowrap;
- animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
- }
-}
-
-.loading-indicator__figure {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 0;
- height: 0;
- box-sizing: border-box;
- border: 0 solid lighten($ui-base-color, 26%);
- border-radius: 50%;
- animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
-}
-
-@keyframes loader-figure {
- 0% {
- width: 0;
- height: 0;
- background-color: lighten($ui-base-color, 26%);
- }
-
- 29% {
- background-color: lighten($ui-base-color, 26%);
- }
-
- 30% {
- width: 42px;
- height: 42px;
- background-color: transparent;
- border-width: 21px;
- opacity: 1;
- }
-
- 100% {
- width: 42px;
- height: 42px;
- border-width: 0;
- opacity: 0;
- background-color: transparent;
- }
-}
-
-@keyframes loader-label {
- 0% { opacity: 0.25; }
- 30% { opacity: 1; }
- 100% { opacity: 0.25; }
-}
-
-.video-error-cover {
- align-items: center;
- background: $base-overlay-background;
- color: $primary-text-color;
- cursor: pointer;
- display: flex;
- flex-direction: column;
- height: 100%;
- justify-content: center;
- margin-top: 8px;
- position: relative;
- text-align: center;
- z-index: 100;
-}
-
-.media-spoiler {
- background: $base-overlay-background;
- color: $ui-primary-color;
- border: 0;
- width: 100%;
- height: 100%;
- justify-content: center;
- position: relative;
- text-align: center;
- z-index: 100;
- display: flex;
- flex-direction: column;
-
- .status__content > & {
- margin-top: 15px; // Add margin when used bare for NSFW video player
- }
-
- @include fullwidth-gallery;
-}
-
-.media-spoiler__warning {
- display: block;
- font-size: 14px;
-}
-
-.media-spoiler__trigger {
- display: block;
- font-size: 11px;
- font-weight: 500;
-}
-
-.spoiler-button {
- display: none;
- left: 4px;
- position: absolute;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
- top: 4px;
- z-index: 100;
-
- &.spoiler-button--visible {
- display: block;
- }
-}
-
-.modal-container--preloader {
- background: lighten($ui-base-color, 8%);
-}
-
-.account--panel {
- background: lighten($ui-base-color, 4%);
- border-top: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- display: flex;
- flex-direction: row;
- padding: 10px 0;
-}
-
-.account--panel__button,
-.detailed-status__button {
- flex: 1 1 auto;
- text-align: center;
-}
-
-.column-settings__outer {
- background: lighten($ui-base-color, 8%);
- padding: 15px;
-}
-
-.column-settings__section {
- color: $ui-primary-color;
- cursor: default;
- display: block;
- font-weight: 500;
- margin-bottom: 10px;
-}
-
-.column-settings__row {
- .text-btn {
- margin-bottom: 15px;
- }
-}
-
-.modal-container__nav {
- align-items: center;
- background: rgba($base-overlay-background, 0.5);
- box-sizing: border-box;
- border: 0;
- color: $primary-text-color;
- cursor: pointer;
- display: flex;
- font-size: 24px;
- height: 100%;
- padding: 30px 15px;
- position: absolute;
- top: 0;
-}
-
-.modal-container__nav--left {
- left: -61px;
-}
-
-.modal-container__nav--right {
- right: -61px;
-}
-
-.account--follows-info {
- color: $primary-text-color;
- position: absolute;
- top: 10px;
- left: 10px;
- opacity: 0.7;
- display: inline-block;
- vertical-align: top;
- background-color: rgba($base-overlay-background, 0.4);
- text-transform: uppercase;
- font-size: 11px;
- font-weight: 500;
- padding: 4px;
- border-radius: 4px;
-}
-
-.account--action-button {
- position: absolute;
- top: 10px;
- right: 20px;
-}
-
-.setting-toggle {
- display: block;
- line-height: 24px;
-}
-
-.setting-toggle__label,
-.setting-meta__label {
- color: $ui-primary-color;
- display: inline-block;
- margin-bottom: 14px;
- margin-left: 8px;
- vertical-align: middle;
-}
-
-.setting-meta__label {
- color: $ui-primary-color;
- float: right;
-}
-
-.empty-column-indicator,
-.error-column {
- color: lighten($ui-base-color, 20%);
- background: $ui-base-color;
- text-align: center;
- padding: 20px;
- font-size: 15px;
- font-weight: 400;
- cursor: default;
- display: flex;
- flex: 1 1 auto;
- align-items: center;
- justify-content: center;
- @supports(display: grid) { // hack to fix Chrome <57
- contain: strict;
- }
-
- a {
- color: $ui-highlight-color;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-.error-column {
- flex-direction: column;
-}
-
-@keyframes heartbeat {
- from {
- transform: scale(1);
- transform-origin: center center;
- animation-timing-function: ease-out;
- }
-
- 10% {
- transform: scale(0.91);
- animation-timing-function: ease-in;
- }
-
- 17% {
- transform: scale(0.98);
- animation-timing-function: ease-out;
- }
-
- 33% {
- transform: scale(0.87);
- animation-timing-function: ease-in;
- }
-
- 45% {
- transform: scale(1);
- animation-timing-function: ease-out;
- }
-}
-
-.pulse-loading {
- animation: heartbeat 1.5s ease-in-out infinite both;
-}
-
-.emoji-picker-dropdown__menu {
- background: $simple-background-color;
- position: absolute;
- box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
- border-radius: 4px;
- margin-top: 5px;
-
- .emoji-mart-scroll {
- transition: opacity 200ms ease;
- }
-
- &.selecting .emoji-mart-scroll {
- opacity: 0.5;
- }
-}
-
-.emoji-picker-dropdown__modifiers {
- position: absolute;
- top: 60px;
- right: 11px;
- cursor: pointer;
-}
-
-.emoji-picker-dropdown__modifiers__menu {
- position: absolute;
- z-index: 4;
- top: -4px;
- left: -8px;
- background: $simple-background-color;
- border-radius: 4px;
- box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
- overflow: hidden;
-
- button {
- display: block;
- cursor: pointer;
- border: 0;
- padding: 4px 8px;
- background: transparent;
-
- &:hover,
- &:focus,
- &:active {
- background: rgba($ui-secondary-color, 0.4);
- }
- }
-
- .emoji-mart-emoji {
- height: 22px;
- }
-}
-
-.emoji-mart-emoji {
- span {
- background-repeat: no-repeat;
- }
-}
-
-.upload-area {
- align-items: center;
- background: rgba($base-overlay-background, 0.8);
- display: flex;
- height: 100%;
- justify-content: center;
- left: 0;
- opacity: 0;
- position: absolute;
- top: 0;
- visibility: hidden;
- width: 100%;
- z-index: 2000;
-
- * {
- pointer-events: none;
- }
-}
-
-.upload-area__drop {
- width: 320px;
- height: 160px;
- display: flex;
- box-sizing: border-box;
- position: relative;
- padding: 8px;
-}
-
-.upload-area__background {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: -1;
- border-radius: 4px;
- background: $ui-base-color;
- box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
-}
-
-.upload-area__content {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: $ui-secondary-color;
- font-size: 18px;
- font-weight: 500;
- border: 2px dashed $ui-base-lighter-color;
- border-radius: 4px;
-}
-
-.upload-progress {
- padding: 10px;
- color: $ui-base-lighter-color;
- overflow: hidden;
- display: flex;
-
- .fa {
- font-size: 34px;
- margin-right: 10px;
- }
-
- span {
- font-size: 12px;
- text-transform: uppercase;
- font-weight: 500;
- display: block;
- }
-}
-
-.upload-progess__message {
- flex: 1 1 auto;
-}
-
-.upload-progress__backdrop {
- width: 100%;
- height: 6px;
- border-radius: 6px;
- background: $ui-base-lighter-color;
- position: relative;
- margin-top: 5px;
-}
-
-.upload-progress__tracker {
- position: absolute;
- left: 0;
- top: 0;
- height: 6px;
- background: $ui-highlight-color;
- border-radius: 6px;
-}
-
-.emoji-button {
- display: block;
- font-size: 24px;
- line-height: 24px;
- margin-left: 2px;
- width: 24px;
- outline: 0;
- cursor: pointer;
-
- &:active,
- &:focus {
- outline: 0 !important;
- }
-
- img {
- filter: grayscale(100%);
- opacity: 0.8;
- display: block;
- margin: 0;
- width: 22px;
- height: 22px;
- margin-top: 2px;
- }
-
- &:hover,
- &:active,
- &:focus {
- img {
- opacity: 1;
- filter: none;
- }
- }
-}
-
-.dropdown--active .emoji-button img {
- opacity: 1;
- filter: none;
-}
-
-.privacy-dropdown__dropdown {
- position: absolute;
- background: $simple-background-color;
- box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
- border-radius: 4px;
- margin-left: 40px;
- overflow: hidden;
-}
-
-.privacy-dropdown__option {
- color: $ui-base-color;
- padding: 10px;
- cursor: pointer;
- display: flex;
-
- &:hover,
- &.active {
- background: $ui-highlight-color;
- color: $primary-text-color;
-
- .privacy-dropdown__option__content {
- color: $primary-text-color;
-
- strong {
- color: $primary-text-color;
- }
- }
- }
-
- &.active:hover {
- background: lighten($ui-highlight-color, 4%);
- }
-}
-
-.privacy-dropdown__option__icon {
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 10px;
-}
-
-.privacy-dropdown__option__content {
- flex: 1 1 auto;
- color: darken($ui-primary-color, 24%);
-
- strong {
- font-weight: 500;
- display: block;
- color: $ui-base-color;
- }
-}
-
-.privacy-dropdown.active {
- .privacy-dropdown__value {
- background: $simple-background-color;
- border-radius: 4px 4px 0 0;
- box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
-
- .icon-button {
- transition: none;
- }
-
- &.active {
- background: $ui-highlight-color;
-
- .icon-button {
- color: $primary-text-color;
- }
- }
- }
-
- .privacy-dropdown__dropdown {
- display: block;
- box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
- }
-}
-
-.advanced-options-dropdown {
- position: relative;
-}
-
-.advanced-options-dropdown__dropdown {
- display: none;
- position: absolute;
- left: 0;
- top: 27px;
- width: 210px;
- background: $simple-background-color;
- border-radius: 0 4px 4px;
- z-index: 2;
- overflow: hidden;
-}
-
-.advanced-options-dropdown__option {
- color: $ui-base-color;
- padding: 10px;
- cursor: pointer;
- display: flex;
-
- &:hover,
- &.active {
- background: $ui-highlight-color;
- color: $primary-text-color;
-
- .advanced-options-dropdown__option__content {
- color: $primary-text-color;
-
- strong {
- color: $primary-text-color;
- }
- }
- }
-
- &.active:hover {
- background: lighten($ui-highlight-color, 4%);
- }
-}
-
-.advanced-options-dropdown__option__toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 10px;
-}
-
-.advanced-options-dropdown__option__content {
- flex: 1 1 auto;
- color: darken($ui-primary-color, 24%);
-
- strong {
- font-weight: 500;
- display: block;
- color: $ui-base-color;
- }
-}
-
-.advanced-options-dropdown.open {
- .advanced-options-dropdown__value {
- background: $simple-background-color;
- border-radius: 4px 4px 0 0;
- box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
- }
-
- .advanced-options-dropdown__dropdown {
- display: block;
- box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
- }
-}
-
-
-.search {
- position: relative;
- margin-bottom: 10px;
-}
-
-.search__input {
- outline: 0;
- box-sizing: border-box;
- display: block;
- width: 100%;
- border: none;
- padding: 10px;
- padding-right: 30px;
- font-family: inherit;
- background: $ui-base-color;
- color: $ui-primary-color;
- font-size: 14px;
- margin: 0;
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
-
- &:focus {
- background: lighten($ui-base-color, 4%);
- }
-
- @include limited-single-column('screen and (max-width: 600px)') {
- font-size: 16px;
- }
-}
-
-.search__icon {
- .fa {
- position: absolute;
- top: 10px;
- right: 10px;
- z-index: 2;
- display: inline-block;
- opacity: 0;
- transition: all 100ms linear;
- font-size: 18px;
- width: 18px;
- height: 18px;
- color: $ui-secondary-color;
- cursor: default;
- pointer-events: none;
-
- &.active {
- pointer-events: auto;
- opacity: 0.3;
- }
- }
-
- .fa-search {
- transform: rotate(90deg);
-
- &.active {
- pointer-events: none;
- transform: rotate(0deg);
- }
- }
-
- .fa-times-circle {
- top: 11px;
- transform: rotate(0deg);
- cursor: pointer;
-
- &.active {
- transform: rotate(90deg);
- }
-
- &:hover {
- color: $primary-text-color;
- }
- }
-}
-
-.search-results__header {
- color: $ui-base-lighter-color;
- background: lighten($ui-base-color, 2%);
- border-bottom: 1px solid darken($ui-base-color, 4%);
- padding: 15px 10px;
- font-size: 14px;
- font-weight: 500;
-}
-
-.search-results__section {
- background: $ui-base-color;
-}
-
-.search-results__hashtag {
- display: block;
- padding: 10px;
- color: $ui-secondary-color;
- text-decoration: none;
-
- &:hover,
- &:active,
- &:focus {
- color: lighten($ui-secondary-color, 4%);
- text-decoration: underline;
- }
-}
-
-.modal-root {
- transition: opacity 0.3s linear;
- will-change: opacity;
- z-index: 9999;
-}
-
-.modal-root__overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba($base-overlay-background, 0.7);
-}
-
-.modal-root__container {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- align-content: space-around;
- z-index: 9999;
- pointer-events: none;
- user-select: none;
-}
-
-.modal-root__modal {
- pointer-events: auto;
- display: flex;
- z-index: 9999;
-}
-
-.media-modal {
- max-width: 80vw;
- max-height: 80vh;
- position: relative;
-
- .extended-video-player,
- img,
- canvas,
- video {
- max-width: 80vw;
- max-height: 80vh;
- width: auto;
- height: auto;
- margin: auto;
- }
-
- .extended-video-player,
- video {
- display: flex;
- width: 80vw;
- height: 80vh;
- }
-
- img,
- canvas {
- display: block;
- background: url('../images/void.png') repeat;
- object-fit: contain;
- }
-
- .react-swipeable-view-container {
- max-width: 80vw;
- }
-}
-
-.media-modal__content {
- background: $base-overlay-background;
-}
-
-.media-modal__pagination {
- width: 100%;
- text-align: center;
- position: absolute;
- left: 0;
- bottom: -40px;
-}
-
-.media-modal__page-dot {
- display: inline-block;
-}
-
-.media-modal__button {
- background-color: $white;
- height: 12px;
- width: 12px;
- border-radius: 6px;
- margin: 10px;
- padding: 0;
- border: 0;
- font-size: 0;
-}
-
-.media-modal__button--active {
- background-color: $ui-highlight-color;
-}
-
-.media-modal__close {
- position: absolute;
- right: 4px;
- top: 4px;
- z-index: 100;
-}
-
-.onboarding-modal,
-.error-modal,
-.embed-modal {
- background: $ui-secondary-color;
- color: $ui-base-color;
- border-radius: 8px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.onboarding-modal__pager {
- height: 80vh;
- width: 80vw;
- max-width: 520px;
- max-height: 420px;
-
- .react-swipeable-view-container > div {
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 25px;
- display: none;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- display: flex;
- user-select: text;
- }
-}
-
-.error-modal__body {
- height: 80vh;
- width: 80vw;
- max-width: 520px;
- max-height: 420px;
- position: relative;
-
- & > div {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 25px;
- display: none;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- display: flex;
- opacity: 0;
- user-select: text;
- }
-}
-
-.error-modal__body {
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- text-align: center;
-}
-
-@media screen and (max-width: 550px) {
- .onboarding-modal {
- width: 100%;
- height: 100%;
- border-radius: 0;
- }
-
- .onboarding-modal__pager {
- width: 100%;
- height: auto;
- max-width: none;
- max-height: none;
- flex: 1 1 auto;
- }
-}
-
-.onboarding-modal__paginator,
-.error-modal__footer {
- flex: 0 0 auto;
- background: darken($ui-secondary-color, 8%);
- display: flex;
- padding: 25px;
-
- & > div {
- min-width: 33px;
- }
-
- .onboarding-modal__nav,
- .error-modal__nav {
- color: darken($ui-secondary-color, 34%);
- background-color: transparent;
- border: 0;
- font-size: 14px;
- font-weight: 500;
- padding: 0;
- line-height: inherit;
- height: auto;
-
- &:hover,
- &:focus,
- &:active {
- color: darken($ui-secondary-color, 38%);
- }
-
- &.onboarding-modal__done,
- &.onboarding-modal__next {
- color: $ui-highlight-color;
- }
- }
-}
-
-.error-modal__footer {
- justify-content: center;
-}
-
-.onboarding-modal__dots {
- flex: 1 1 auto;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.onboarding-modal__dot {
- width: 14px;
- height: 14px;
- border-radius: 14px;
- background: darken($ui-secondary-color, 16%);
- margin: 0 3px;
- cursor: pointer;
-
- &:hover {
- background: darken($ui-secondary-color, 18%);
- }
-
- &.active {
- cursor: default;
- background: darken($ui-secondary-color, 24%);
- }
-}
-
-.onboarding-modal__page__wrapper {
- pointer-events: none;
-
- &.onboarding-modal__page__wrapper--active {
- pointer-events: auto;
- }
-}
-
-.onboarding-modal__page {
- cursor: default;
- line-height: 21px;
-
- h1 {
- font-size: 18px;
- font-weight: 500;
- color: $ui-base-color;
- margin-bottom: 20px;
- }
-
- a {
- color: $ui-highlight-color;
-
- &:hover,
- &:focus,
- &:active {
- color: lighten($ui-highlight-color, 4%);
- }
- }
-
- p {
- font-size: 16px;
- color: lighten($ui-base-color, 8%);
- margin-top: 10px;
- margin-bottom: 10px;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- strong {
- font-weight: 500;
- background: $ui-base-color;
- color: $ui-secondary-color;
- border-radius: 4px;
- font-size: 14px;
- padding: 3px 6px;
- }
- }
-}
-
-.onboarding-modal__page-one {
- display: flex;
- align-items: center;
-}
-
-.onboarding-modal__page-one__elephant-friend {
- background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
- width: 155px;
- height: 193px;
- margin-right: 15px;
-}
-
-@media screen and (max-width: 400px) {
- .onboarding-modal__page-one {
- flex-direction: column;
- align-items: normal;
- }
-
- .onboarding-modal__page-one__elephant-friend {
- width: 100%;
- height: 30vh;
- max-height: 160px;
- margin-bottom: 5vh;
- }
-}
-
-.onboarding-modal__page-two,
-.onboarding-modal__page-three,
-.onboarding-modal__page-four,
-.onboarding-modal__page-five {
- p {
- text-align: left;
- }
-
- .figure {
- background: darken($ui-base-color, 8%);
- color: $ui-secondary-color;
- margin-bottom: 20px;
- border-radius: 4px;
- padding: 10px;
- text-align: center;
- font-size: 14px;
- box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);
-
- .onboarding-modal__image {
- border-radius: 4px;
- margin-bottom: 10px;
- }
-
- &.non-interactive {
- pointer-events: none;
- text-align: left;
- }
- }
-}
-
-.onboarding-modal__page-four__columns {
- .row {
- display: flex;
- margin-bottom: 20px;
-
- & > div {
- flex: 1 1 0;
- margin: 0 10px;
-
- &:first-child {
- margin-left: 0;
- }
-
- &:last-child {
- margin-right: 0;
- }
-
- p {
- text-align: center;
- }
- }
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- .column-header {
- color: $primary-text-color;
- }
-}
-
-@media screen and (max-width: 320px) and (max-height: 600px) {
- .onboarding-modal__page p {
- font-size: 14px;
- line-height: 20px;
- }
-
- .onboarding-modal__page-two .figure,
- .onboarding-modal__page-three .figure,
- .onboarding-modal__page-four .figure,
- .onboarding-modal__page-five .figure {
- font-size: 12px;
- margin-bottom: 10px;
- }
-
- .onboarding-modal__page-four__columns .row {
- margin-bottom: 10px;
- }
-
- .onboarding-modal__page-four__columns .column-header {
- padding: 5px;
- font-size: 12px;
- }
-}
-
-.onboarding-modal__image {
- border-radius: 8px;
- width: 70vw;
- max-width: 450px;
- max-height: auto;
- display: block;
- margin: auto;
- margin-bottom: 20px;
-}
-
-.onboard-sliders {
- display: inline-block;
- max-width: 30px;
- max-height: auto;
- margin-left: 10px;
-}
-
-.boost-modal,
-.confirmation-modal,
-.report-modal,
-.actions-modal,
-.mute-modal {
- background: lighten($ui-secondary-color, 8%);
- color: $ui-base-color;
- border-radius: 8px;
- overflow: hidden;
- max-width: 90vw;
- width: 480px;
- position: relative;
- flex-direction: column;
-
- .status__display-name {
- display: flex;
- }
-}
-
-.actions-modal {
- .status {
- background: $white;
- border-bottom-color: $ui-secondary-color;
- padding-top: 10px;
- padding-bottom: 10px;
- }
-
- .dropdown-menu__separator {
- border-bottom-color: $ui-secondary-color;
- }
-}
-
-.boost-modal__container {
- overflow-x: scroll;
- padding: 10px;
-
- .status {
- user-select: text;
- border-bottom: 0;
- }
-}
-
-.boost-modal__action-bar,
-.confirmation-modal__action-bar,
-.mute-modal__action-bar,
-.report-modal__action-bar {
- display: flex;
- justify-content: space-between;
- background: $ui-secondary-color;
- padding: 10px;
- line-height: 36px;
-
- & > div {
- flex: 1 1 auto;
- text-align: right;
- color: lighten($ui-base-color, 33%);
- padding-right: 10px;
- }
-
- .button {
- flex: 0 0 auto;
- }
-}
-
-.boost-modal__status-header {
- font-size: 15px;
-}
-
-.boost-modal__status-time {
- float: right;
- font-size: 14px;
-}
-
-.confirmation-modal {
- max-width: 85vw;
-
- @media screen and (min-width: 480px) {
- max-width: 380px;
- }
-}
-
-.mute-modal {
- line-height: 24px;
-}
-
-.mute-modal .react-toggle {
- vertical-align: middle;
-}
-
-.report-modal__statuses,
-.report-modal__comment {
- padding: 10px;
-}
-
-.report-modal__statuses {
- min-height: 20vh;
- max-height: 40vh;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.report-modal__comment {
- .setting-text {
- margin-top: 10px;
- }
-}
-
-.actions-modal {
- .status {
- overflow-y: auto;
- max-height: 300px;
- }
-
- max-height: 80vh;
- max-width: 80vw;
-
- .actions-modal__item-label {
- font-weight: 500;
- }
-
- ul {
- overflow-y: auto;
- flex-shrink: 0;
-
- li:empty {
- margin: 0;
- }
-
- li:not(:empty) {
- a {
- color: $ui-base-color;
- display: flex;
- padding: 12px 16px;
- font-size: 15px;
- align-items: center;
- text-decoration: none;
-
- &,
- button {
- transition: none;
- }
-
- &.active,
- &:hover,
- &:active,
- &:focus {
- &,
- button {
- background: $ui-highlight-color;
- color: $primary-text-color;
- }
- }
-
- button:first-child {
- margin-right: 10px;
- }
- }
- }
- }
-}
-
-.confirmation-modal__action-bar,
-.mute-modal__action-bar {
- .confirmation-modal__cancel-button,
- .mute-modal__cancel-button {
- background-color: transparent;
- color: darken($ui-secondary-color, 34%);
- font-size: 14px;
- font-weight: 500;
-
- &:hover,
- &:focus,
- &:active {
- color: darken($ui-secondary-color, 38%);
- }
- }
-}
-
-.confirmation-modal__container,
-.mute-modal__container,
-.report-modal__target {
- padding: 30px;
- font-size: 16px;
- text-align: center;
-
- strong {
- font-weight: 500;
- }
-}
-
-.loading-bar {
- background-color: $ui-highlight-color;
- height: 3px;
- position: absolute;
- top: 0;
- left: 0;
-}
-
-.media-gallery__gifv__label {
- display: block;
- position: absolute;
- color: $primary-text-color;
- background: rgba($base-overlay-background, 0.5);
- bottom: 6px;
- left: 6px;
- padding: 2px 6px;
- border-radius: 2px;
- font-size: 11px;
- font-weight: 600;
- z-index: 1;
- pointer-events: none;
- opacity: 0.9;
- transition: opacity 0.1s ease;
-}
-
-.media-gallery__gifv {
- &.autoplay {
- .media-gallery__gifv__label {
- display: none;
- }
- }
-
- &:hover {
- .media-gallery__gifv__label {
- opacity: 1;
- }
- }
-}
-
-.attachment-list {
- display: flex;
- font-size: 14px;
- border: 1px solid lighten($ui-base-color, 8%);
- border-radius: 4px;
- margin-top: 14px;
- overflow: hidden;
-}
-
-.attachment-list__icon {
- flex: 0 0 auto;
- color: $ui-base-lighter-color;
- padding: 8px 18px;
- cursor: default;
- border-right: 1px solid lighten($ui-base-color, 8%);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: 26px;
-
- .fa {
- display: block;
- }
-}
-
-.attachment-list__list {
- list-style: none;
- padding: 4px 0;
- padding-left: 8px;
- display: flex;
- flex-direction: column;
- justify-content: center;
-
- li {
- display: block;
- padding: 4px 0;
- }
-
- a {
- text-decoration: none;
- color: $ui-base-lighter-color;
- font-weight: 500;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-/* Media Gallery */
-.media-gallery {
- box-sizing: border-box;
- margin-top: 15px;
- overflow: hidden;
- position: relative;
- background: $base-shadow-color;
- width: 100%;
-
- .detailed-status & {
- margin-left:-10px;
- width: calc(100% + 22px);
- }
-
- @include fullwidth-gallery;
-}
-
-.media-gallery__item {
- border: none;
- box-sizing: border-box;
- display: block;
- float: left;
- position: relative;
-
- &.standalone {
- .media-gallery__item-gifv-thumbnail {
- transform: none;
- }
- }
-}
-
-.media-gallery__item-thumbnail {
- cursor: zoom-in;
- text-decoration: none;
- width: 100%;
- height: 100%;
- line-height: 0;
- display: flex;
-
- img {
- width: 100%;
- object-fit: contain;
-
- &:not(.letterbox) {
- height: 100%;
- object-fit: cover;
- }
- }
-}
-
-.media-gallery__gifv {
- height: 100%;
- overflow: hidden;
- position: relative;
- width: 100%;
- display: flex;
- justify-content: center;
-}
-
-.media-gallery__item-gifv-thumbnail {
- cursor: zoom-in;
- height: 100%;
- position: relative;
- z-index: 1;
- object-fit: contain;
-
- &:not(.letterbox) {
- height: 100%;
- object-fit: cover;
- }
-}
-
-.media-gallery__item-thumbnail-label {
- clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
- clip: rect(1px, 1px, 1px, 1px);
- overflow: hidden;
- position: absolute;
-}
-/* End Media Gallery */
-
-/* Status Video Player */
-.status__video-player {
- display: flex;
- align-items: center;
- background: $base-shadow-color;
- box-sizing: border-box;
- cursor: default; /* May not be needed */
- margin-top: 15px;
- overflow: hidden;
- position: relative;
- width: 100%;
-
- @include fullwidth-gallery;
-}
-
-.status__video-player-video {
- position: relative;
- width: 100%;
- z-index: 1;
-
- &:not(.letterbox) {
- height: 100%;
- object-fit: cover;
- }
-}
-
-.status__video-player-expand,
-.status__video-player-mute {
- color: $primary-text-color;
- opacity: 0.8;
- position: absolute;
- right: 4px;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-}
-
-.status__video-player-spoiler {
- display: none;
- color: $primary-text-color;
- left: 4px;
- position: absolute;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
- top: 4px;
- z-index: 100;
-
- &.status__video-player-spoiler--visible {
- display: block;
- }
-}
-
-.status__video-player-expand {
- bottom: 4px;
- z-index: 100;
-}
-
-.status__video-player-mute {
- top: 4px;
- z-index: 5;
-}
-
-.video-player {
- overflow: hidden;
- position: relative;
- background: $base-shadow-color;
- max-width: 100%;
-
- video {
- height: 100%;
- width: 100%;
- z-index: 1;
- }
-
- &.fullscreen {
- width: 100% !important;
- height: 100% !important;
- margin: 0;
-
- video {
- max-width: 100% !important;
- max-height: 100% !important;
- }
- }
-
- &.inline {
- video {
- object-fit: cover;
- position: relative;
- top: 50%;
- transform: translateY(-50%);
- }
- }
-
- &__controls {
- position: absolute;
- z-index: 2;
- bottom: 0;
- left: 0;
- right: 0;
- box-sizing: border-box;
- background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 60%, transparent);
- padding: 0 10px;
- opacity: 0;
- transition: opacity .1s ease;
-
- &.active {
- opacity: 1;
- }
- }
-
- &.inactive {
- video,
- .video-player__controls {
- visibility: hidden;
- }
- }
-
- &__spoiler {
- display: none;
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 4;
- border: 0;
- background: $base-shadow-color;
- color: $ui-primary-color;
- transition: none;
- pointer-events: none;
-
- &.active {
- display: block;
- pointer-events: auto;
-
- &:hover,
- &:active,
- &:focus {
- color: lighten($ui-primary-color, 8%);
- }
- }
-
- &__title {
- display: block;
- font-size: 14px;
- }
-
- &__subtitle {
- display: block;
- font-size: 11px;
- font-weight: 500;
- }
- }
-
- &__buttons {
- padding-bottom: 10px;
- font-size: 16px;
-
- &.left {
- float: left;
-
- button {
- padding-right: 10px;
- }
- }
-
- &.right {
- float: right;
-
- button {
- padding-left: 10px;
- }
- }
-
- button {
- background: transparent;
- padding: 0;
- border: 0;
- color: $white;
-
- &:active,
- &:hover,
- &:focus {
- color: $ui-highlight-color;
- }
- }
- }
-
- &__seek {
- cursor: pointer;
- height: 24px;
- position: relative;
-
- &::before {
- content: "";
- width: 100%;
- background: rgba($white, 0.35);
- display: block;
- position: absolute;
- height: 4px;
- top: 10px;
- }
-
- &__progress,
- &__buffer {
- display: block;
- position: absolute;
- height: 4px;
- top: 10px;
- background: $ui-highlight-color;
- }
-
- &__buffer {
- background: rgba($white, 0.2);
- }
-
- &__handle {
- position: absolute;
- z-index: 3;
- opacity: 0;
- border-radius: 50%;
- width: 12px;
- height: 12px;
- top: 6px;
- margin-left: -6px;
- transition: opacity .1s ease;
- background: $ui-highlight-color;
- pointer-events: none;
-
- &.active {
- opacity: 1;
- }
- }
-
- &:hover {
- .video-player__seek__handle {
- opacity: 1;
- }
- }
- }
-}
-
-.media-spoiler-video {
- background-size: cover;
- background-repeat: no-repeat;
- background-position: center;
- cursor: pointer;
- margin-top: 15px;
- position: relative;
- width: 100%;
-
- @include fullwidth-gallery;
-
- border: 0;
- display: block;
-}
-
-.media-spoiler-video-play-icon {
- border-radius: 100px;
- color: rgba($primary-text-color, 0.8);
- font-size: 36px;
- left: 50%;
- padding: 5px;
- position: absolute;
- top: 50%;
- transform: translate(-50%, -50%);
-}
-/* End Video Player */
-
-.account-gallery__container {
- margin: -2px;
- padding: 4px;
- display: flex;
- flex-wrap: wrap;
-}
-
-.account-gallery__item {
- flex: 1 1 auto;
- width: calc(100% / 3 - 4px);
- height: 95px;
- margin: 2px;
-
- a {
- display: block;
- width: 100%;
- height: 100%;
- background-color: $base-overlay-background;
- background-size: cover;
- background-position: center;
- position: relative;
- color: inherit;
- text-decoration: none;
-
- &:hover,
- &:active,
- &:focus {
- outline: 0;
- }
- }
-}
-
-.account-section-headline {
- color: $ui-base-lighter-color;
- background: lighten($ui-base-color, 2%);
- border-bottom: 1px solid lighten($ui-base-color, 4%);
- padding: 15px 10px;
- font-size: 14px;
- font-weight: 500;
- position: relative;
- cursor: default;
-
- &::before,
- &::after {
- display: block;
- content: "";
- position: absolute;
- bottom: 0;
- left: 18px;
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 0 10px 10px;
- border-color: transparent transparent lighten($ui-base-color, 4%);
- }
-
- &::after {
- bottom: -1px;
- border-color: transparent transparent $ui-base-color;
- }
-}
-
-::-webkit-scrollbar-thumb {
- border-radius: 0;
-}
-
-.search-popout {
- background: $simple-background-color;
- border-radius: 4px;
- padding: 10px 14px;
- padding-bottom: 14px;
- margin-top: 10px;
- color: $ui-primary-color;
- box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
-
- h4 {
- text-transform: uppercase;
- color: $ui-primary-color;
- font-size: 13px;
- font-weight: 500;
- margin-bottom: 10px;
- }
-
- li {
- padding: 4px 0;
- }
-
- ul {
- margin-bottom: 10px;
- }
-
- em {
- font-weight: 500;
- color: $ui-base-color;
- }
-}
-
-noscript {
- text-align: center;
-
- img {
- width: 200px;
- opacity: 0.5;
- animation: flicker 4s infinite;
- }
-
- div {
- font-size: 14px;
- margin: 30px auto;
- color: $ui-secondary-color;
- max-width: 400px;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
-
- &:hover {
- text-decoration: none;
- }
- }
- }
-}
-
-@keyframes flicker {
- 0% { opacity: 1; }
- 30% { opacity: 0.75; }
- 100% { opacity: 1; }
-}
-
-@media screen and (max-width: 630px) and (max-height: 400px) {
- $duration: 400ms;
- $delay: 100ms;
-
- .tabs-bar,
- .search {
- will-change: margin-top;
- transition: margin-top $duration $delay;
- }
-
- .navigation-bar {
- will-change: padding-bottom;
- transition: padding-bottom $duration $delay;
- }
-
- .navigation-bar {
- & > a:first-child {
- will-change: margin-top, margin-left, width;
- transition: margin-top $duration $delay, margin-left $duration ($duration + $delay);
- }
-
- & > .navigation-bar__profile-edit {
- will-change: margin-top;
- transition: margin-top $duration $delay;
- }
-
- & > .icon-button {
- will-change: opacity;
- transition: opacity $duration $delay;
- }
- }
-
- .is-composing {
- .tabs-bar,
- .search {
- margin-top: -50px;
- }
-
- .navigation-bar {
- padding-bottom: 0;
-
- & > a:first-child {
- margin-top: -50px;
- margin-left: -40px;
- }
-
- .navigation-bar__profile {
- padding-top: 2px;
- }
-
- .navigation-bar__profile-edit {
- position: absolute;
- margin-top: -50px;
- }
-
- .icon-button {
- pointer-events: auto;
- opacity: 1;
- }
- }
- }
-
- // fixes for the navbar-under mode
- .is-composing.navbar-under {
- .search {
- margin-top: -20px;
- margin-bottom: -20px;
- .search__icon {
- display: none;
- }
- }
- }
-}
-
-// more fixes for the navbar-under mode
-@mixin fix-margins-for-navbar-under {
- .tabs-bar {
- margin-top: 0 !important;
- margin-bottom: -6px !important;
- }
-}
-
-.single-column.navbar-under {
- @include fix-margins-for-navbar-under;
-}
-
-.auto-columns.navbar-under {
- @media screen and (max-width: 360px) {
- @include fix-margins-for-navbar-under;
- }
-}
-
-.auto-columns.navbar-under .react-swipeable-view-container .columns-area,
-.single-column.navbar-under .react-swipeable-view-container .columns-area {
- @media screen and (max-width: 360px) {
- height: 100% !important;
- }
-}
-
-.embed-modal {
- max-width: 80vw;
- max-height: 80vh;
-
- h4 {
- padding: 30px;
- font-weight: 500;
- font-size: 16px;
- text-align: center;
- }
-
- .embed-modal__container {
- padding: 10px;
-
- .hint {
- margin-bottom: 15px;
- }
-
- .embed-modal__html {
- color: $ui-secondary-color;
- outline: 0;
- box-sizing: border-box;
- display: block;
- width: 100%;
- border: none;
- padding: 10px;
- font-family: 'mastodon-font-monospace', monospace;
- background: $ui-base-color;
- color: $ui-primary-color;
- font-size: 14px;
- margin: 0;
- margin-bottom: 15px;
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
-
- &:focus {
- background: lighten($ui-base-color, 4%);
- }
-
- @media screen and (max-width: 600px) {
- font-size: 16px;
- }
- }
-
- .embed-modal__iframe {
- width: 400px;
- max-width: 100%;
- overflow: hidden;
- border: 0;
- }
- }
-}
-
-@import 'doodle';
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
deleted file mode 100644
index af2589e23..000000000
--- a/app/javascript/styles/mastodon/containers.scss
+++ /dev/null
@@ -1,116 +0,0 @@
-.container {
- width: 700px;
- margin: 0 auto;
- margin-top: 40px;
-
- @media screen and (max-width: 740px) {
- width: 100%;
- margin: 0;
- }
-}
-
-.logo-container {
- margin: 100px auto;
- margin-bottom: 50px;
-
- @media screen and (max-width: 400px) {
- margin: 30px auto;
- margin-bottom: 20px;
- }
-
- h1 {
- display: flex;
- justify-content: center;
- align-items: center;
-
- img {
- height: 42px;
- margin-right: 10px;
- }
-
- a {
- display: flex;
- justify-content: center;
- align-items: center;
- color: $primary-text-color;
- text-decoration: none;
- outline: 0;
- padding: 12px 16px;
- line-height: 32px;
- font-family: 'mastodon-font-display', sans-serif;
- font-weight: 500;
- font-size: 14px;
- }
- }
-}
-
-.compose-standalone {
- .compose-form {
- width: 400px;
- margin: 0 auto;
- padding: 20px 0;
- margin-top: 40px;
- box-sizing: border-box;
-
- @media screen and (max-width: 400px) {
- width: 100%;
- margin-top: 0;
- padding: 20px;
- }
- }
-}
-
-.account-header {
- width: 400px;
- margin: 0 auto;
- display: flex;
- font-size: 13px;
- line-height: 18px;
- box-sizing: border-box;
- padding: 20px 0;
- padding-bottom: 0;
- margin-bottom: -30px;
- margin-top: 40px;
-
- @media screen and (max-width: 440px) {
- width: 100%;
- margin: 0;
- margin-bottom: 10px;
- padding: 20px;
- padding-bottom: 0;
- }
-
- .avatar {
- width: 40px;
- height: 40px;
- margin-right: 8px;
-
- img {
- width: 100%;
- height: 100%;
- display: block;
- margin: 0;
- border-radius: 4px;
- }
- }
-
- .name {
- flex: 1 1 auto;
- color: $ui-secondary-color;
- width: calc(100% - 88px);
-
- .username {
- display: block;
- font-weight: 500;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- }
-
- .logout-link {
- display: block;
- font-size: 32px;
- line-height: 40px;
- margin-left: 8px;
- }
-}
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
deleted file mode 100644
index 2b46d30fc..000000000
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ /dev/null
@@ -1,199 +0,0 @@
-.emoji-mart {
- &,
- * {
- box-sizing: border-box;
- line-height: 1.15;
- }
-
- font-size: 13px;
- display: inline-block;
- color: $ui-base-color;
-
- .emoji-mart-emoji {
- padding: 6px;
- }
-}
-
-.emoji-mart-bar {
- border: 0 solid darken($ui-secondary-color, 8%);
-
- &:first-child {
- border-bottom-width: 1px;
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- background: $ui-secondary-color;
- }
-
- &:last-child {
- border-top-width: 1px;
- border-bottom-left-radius: 5px;
- border-bottom-right-radius: 5px;
- display: none;
- }
-}
-
-.emoji-mart-anchors {
- display: flex;
- justify-content: space-between;
- padding: 0 6px;
- color: $ui-primary-color;
- line-height: 0;
-}
-
-.emoji-mart-anchor {
- position: relative;
- flex: 1;
- text-align: center;
- padding: 12px 4px;
- overflow: hidden;
- transition: color .1s ease-out;
- cursor: pointer;
-
- &:hover {
- color: darken($ui-primary-color, 4%);
- }
-}
-
-.emoji-mart-anchor-selected {
- color: darken($ui-highlight-color, 3%);
-
- &:hover {
- color: darken($ui-highlight-color, 3%);
- }
-
- .emoji-mart-anchor-bar {
- bottom: 0;
- }
-}
-
-.emoji-mart-anchor-bar {
- position: absolute;
- bottom: -3px;
- left: 0;
- width: 100%;
- height: 3px;
- background-color: darken($ui-highlight-color, 3%);
-}
-
-.emoji-mart-anchors {
- i {
- display: inline-block;
- width: 100%;
- max-width: 22px;
- }
-
- svg {
- fill: currentColor;
- max-height: 18px;
- }
-}
-
-.emoji-mart-scroll {
- overflow-y: scroll;
- height: 270px;
- max-height: 35vh;
- padding: 0 6px 6px;
- background: $simple-background-color;
- will-change: transform;
-}
-
-.emoji-mart-search {
- padding: 10px;
- padding-right: 45px;
- background: $simple-background-color;
-
- input {
- font-size: 14px;
- font-weight: 400;
- padding: 7px 9px;
- font-family: inherit;
- display: block;
- width: 100%;
- background: rgba($ui-secondary-color, 0.3);
- color: $ui-primary-color;
- border: 1px solid $ui-secondary-color;
- border-radius: 4px;
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
- }
-}
-
-.emoji-mart-category .emoji-mart-emoji {
- cursor: pointer;
-
- span {
- z-index: 1;
- position: relative;
- text-align: center;
- }
-
- &:hover::before {
- z-index: 0;
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba($ui-secondary-color, 0.7);
- border-radius: 100%;
- }
-}
-
-.emoji-mart-category-label {
- z-index: 2;
- position: relative;
- position: -webkit-sticky;
- position: sticky;
- top: 0;
-
- span {
- display: block;
- width: 100%;
- font-weight: 500;
- padding: 5px 6px;
- background: $simple-background-color;
- }
-}
-
-.emoji-mart-emoji {
- position: relative;
- display: inline-block;
- font-size: 0;
-
- span {
- width: 22px;
- height: 22px;
- }
-}
-
-.emoji-mart-no-results {
- font-size: 14px;
- text-align: center;
- padding-top: 70px;
- color: $ui-primary-color;
-
- .emoji-mart-category-label {
- display: none;
- }
-
- .emoji-mart-no-results-label {
- margin-top: .2em;
- }
-
- .emoji-mart-emoji:hover::before {
- content: none;
- }
-}
-
-.emoji-mart-preview {
- display: none;
-}
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
deleted file mode 100644
index 2d953b34e..000000000
--- a/app/javascript/styles/mastodon/footer.scss
+++ /dev/null
@@ -1,30 +0,0 @@
-.footer {
- text-align: center;
- margin-top: 30px;
- font-size: 12px;
- color: darken($ui-secondary-color, 25%);
-
- .domain {
- font-weight: 500;
-
- a {
- color: inherit;
- text-decoration: none;
- }
- }
-
- .powered-by,
- .single-user-login {
- font-weight: 400;
-
- a {
- color: inherit;
- text-decoration: underline;
- font-weight: 500;
-
- &:hover {
- text-decoration: none;
- }
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
deleted file mode 100644
index 61fcf286f..000000000
--- a/app/javascript/styles/mastodon/forms.scss
+++ /dev/null
@@ -1,540 +0,0 @@
-code {
- font-family: 'mastodon-font-monospace', monospace;
- font-weight: 400;
-}
-
-.form-container {
- max-width: 400px;
- padding: 20px;
- margin: 0 auto;
-}
-
-.simple_form {
- .input {
- margin-bottom: 15px;
- overflow: hidden;
- }
-
- span.hint {
- display: block;
- color: $ui-primary-color;
- font-size: 12px;
- margin-top: 4px;
- }
-
- h4 {
- text-transform: uppercase;
- font-size: 13px;
- font-weight: 500;
- color: $ui-primary-color;
- padding-bottom: 8px;
- margin-bottom: 8px;
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- }
-
- p.hint {
- margin-bottom: 15px;
- color: $ui-primary-color;
-
- &.subtle-hint {
- text-align: center;
- font-size: 12px;
- line-height: 18px;
- margin-top: 15px;
- margin-bottom: 0;
- color: $ui-primary-color;
-
- a {
- color: $ui-highlight-color;
- }
- }
- }
-
- .card {
- margin-bottom: 15px;
- }
-
- strong {
- font-weight: 500;
- }
-
- .label_input {
- display: flex;
-
- label {
- flex: 0 0 auto;
- }
-
- input {
- flex: 1 1 auto;
- }
- }
-
- .input.with_label {
- padding: 15px 0;
- margin-bottom: 0;
-
- .label_input {
- flex-wrap: wrap;
- align-items: flex-start;
- }
-
- &.select .label_input {
- align-items: initial;
- }
-
- .label_input > label {
- font-family: inherit;
- font-size: 16px;
- color: $primary-text-color;
- display: block;
- padding-top: 5px;
- margin-bottom: 5px;
- flex: 1;
- min-width: 150px;
- word-wrap: break-word;
-
- &.select {
- flex: 0;
- }
-
- & ~ * {
- margin-left: 10px;
- }
- }
-
- ul {
- flex: 390px;
- }
-
- &.boolean {
- padding: initial;
- margin-bottom: initial;
-
- .label_input > label {
- font-family: inherit;
- font-size: 14px;
- color: $primary-text-color;
- display: block;
- width: auto;
- }
-
- label.checkbox {
- position: relative;
- padding-left: 25px;
- flex: 1 1 auto;
- }
- }
- }
-
- .input.with_block_label {
- & > label {
- font-family: inherit;
- font-size: 16px;
- color: $primary-text-color;
- display: block;
- padding-top: 5px;
- }
-
- .hint {
- margin-bottom: 15px;
- }
-
- li {
- float: left;
- width: 50%;
- }
- }
-
- .fields-group {
- margin-bottom: 25px;
- }
-
- .input.radio_buttons .radio label {
- margin-bottom: 5px;
- font-family: inherit;
- font-size: 14px;
- color: $primary-text-color;
- display: block;
- width: auto;
- }
-
- .input.boolean {
- margin-bottom: 5px;
-
- label {
- font-family: inherit;
- font-size: 14px;
- color: $primary-text-color;
- display: block;
- width: auto;
- }
-
- label.checkbox {
- position: relative;
- padding-left: 25px;
- flex: 1 1 auto;
- }
-
- input[type=checkbox] {
- position: absolute;
- left: 0;
- top: 5px;
- margin: 0;
- }
-
- .hint {
- padding-left: 25px;
- margin-left: 0;
- }
- }
-
- .check_boxes {
- .checkbox {
- label {
- font-family: inherit;
- font-size: 14px;
- color: $primary-text-color;
- display: block;
- width: auto;
- position: relative;
- padding-top: 5px;
- padding-left: 25px;
- flex: 1 1 auto;
- }
-
- input[type=checkbox] {
- position: absolute;
- left: 0;
- top: 5px;
- margin: 0;
- }
- }
- }
-
- input[type=text],
- input[type=number],
- input[type=email],
- input[type=password],
- textarea {
- background: transparent;
- box-sizing: border-box;
- border: 0;
- border-bottom: 2px solid $ui-primary-color;
- border-radius: 2px 2px 0 0;
- padding: 7px 4px;
- font-size: 16px;
- color: $primary-text-color;
- display: block;
- width: 100%;
- outline: 0;
- font-family: inherit;
- resize: vertical;
-
- &:invalid {
- box-shadow: none;
- }
-
- &:focus:invalid {
- border-bottom-color: $error-value-color;
- }
-
- &:required:valid {
- border-bottom-color: $valid-value-color;
- }
-
- &:active,
- &:focus {
- border-bottom-color: $ui-highlight-color;
- background: rgba($base-overlay-background, 0.1);
- }
- }
-
- .input.field_with_errors {
- label {
- color: $error-value-color;
- }
-
- input[type=text],
- input[type=email],
- input[type=password] {
- border-bottom-color: $error-value-color;
- }
-
- .error {
- display: block;
- font-weight: 500;
- color: $error-value-color;
- margin-top: 4px;
- }
- }
-
- .actions {
- margin-top: 30px;
- display: flex;
- }
-
- button,
- .button,
- .block-button {
- display: block;
- width: 100%;
- border: 0;
- border-radius: 4px;
- background: $ui-highlight-color;
- color: $primary-text-color;
- font-size: 18px;
- line-height: inherit;
- height: auto;
- padding: 10px;
- text-transform: uppercase;
- text-decoration: none;
- text-align: center;
- box-sizing: border-box;
- cursor: pointer;
- font-weight: 500;
- outline: 0;
- margin-bottom: 10px;
- margin-right: 10px;
-
- &:last-child {
- margin-right: 0;
- }
-
- &:hover {
- background-color: lighten($ui-highlight-color, 5%);
- }
-
- &:active,
- &:focus {
- background-color: darken($ui-highlight-color, 5%);
- }
-
- &.negative {
- background: $error-value-color;
-
- &:hover {
- background-color: lighten($error-value-color, 5%);
- }
-
- &:active,
- &:focus {
- background-color: darken($error-value-color, 5%);
- }
- }
- }
-
- select {
- font-size: 16px;
- max-height: 29px;
- }
-
- .input-with-append {
- position: relative;
-
- .input input {
- padding-right: 127px;
- }
-
- .append {
- position: absolute;
- right: 0;
- top: 0;
- padding: 7px 4px;
- padding-bottom: 9px;
- font-size: 16px;
- color: $ui-base-lighter-color;
- font-family: inherit;
- pointer-events: none;
- cursor: default;
- }
- }
-}
-
-.flash-message {
- background: lighten($ui-base-color, 8%);
- color: $ui-primary-color;
- border-radius: 4px;
- padding: 15px 10px;
- margin-bottom: 30px;
- box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
- text-align: center;
-
- p {
- margin-bottom: 15px;
- }
-
- .oauth-code {
- color: $ui-secondary-color;
- outline: 0;
- box-sizing: border-box;
- display: block;
- width: 100%;
- border: none;
- padding: 10px;
- font-family: 'mastodon-font-monospace', monospace;
- background: $ui-base-color;
- color: $ui-primary-color;
- font-size: 14px;
- margin: 0;
-
- &::-moz-focus-inner {
- border: 0;
- }
-
- &::-moz-focus-inner,
- &:focus,
- &:active {
- outline: 0 !important;
- }
-
- &:focus {
- background: lighten($ui-base-color, 4%);
- }
- }
-
- strong {
- font-weight: 500;
- }
-
- @media screen and (max-width: 740px) and (min-width: 441px) {
- margin-top: 40px;
- }
-}
-
-.form-footer {
- margin-top: 30px;
- text-align: center;
-
- a {
- color: $ui-primary-color;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-.oauth-prompt,
-.follow-prompt {
- margin-bottom: 30px;
- text-align: center;
- color: $ui-primary-color;
-
- h2 {
- font-size: 16px;
- margin-bottom: 30px;
- }
-
- strong {
- color: $ui-secondary-color;
- font-weight: 500;
- }
-
- @media screen and (max-width: 740px) and (min-width: 441px) {
- margin-top: 40px;
- }
-}
-
-.qr-wrapper {
- display: flex;
- flex-wrap: wrap;
- align-items: flex-start;
-}
-
-.qr-code {
- flex: 0 0 auto;
- background: $simple-background-color;
- padding: 4px;
- margin: 0 10px 20px 0;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
- display: inline-block;
-
- svg {
- display: block;
- margin: 0;
- }
-}
-
-.qr-alternative {
- margin-bottom: 20px;
- color: $ui-secondary-color;
- flex: 150px;
-
- samp {
- display: block;
- font-size: 14px;
- }
-}
-
-.table-form {
- p {
- margin-bottom: 15px;
-
- strong {
- font-weight: 500;
- }
- }
-}
-
-.simple_form,
-.table-form {
- .warning {
- box-sizing: border-box;
- background: rgba($error-value-color, 0.5);
- color: $primary-text-color;
- text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
- box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
- border-radius: 4px;
- padding: 10px;
- margin-bottom: 15px;
-
- a {
- color: $primary-text-color;
- text-decoration: underline;
-
- &:hover,
- &:focus,
- &:active {
- text-decoration: none;
- }
- }
-
- strong {
- font-weight: 600;
- display: block;
- margin-bottom: 5px;
-
- .fa {
- font-weight: 400;
- }
- }
- }
-}
-
-.action-pagination {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
-
- .actions,
- .pagination {
- flex: 1 1 auto;
- }
-
- .actions {
- padding: 30px 0;
- padding-right: 20px;
- flex: 0 0 auto;
- }
-}
-
-.post-follow-actions {
- text-align: center;
- color: $ui-primary-color;
-
- div {
- margin-bottom: 4px;
- }
-}
diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss
deleted file mode 100644
index 0bf9daafd..000000000
--- a/app/javascript/styles/mastodon/landing_strip.scss
+++ /dev/null
@@ -1,36 +0,0 @@
-.landing-strip,
-.memoriam-strip {
- background: rgba(darken($ui-base-color, 7%), 0.8);
- color: $ui-primary-color;
- font-weight: 400;
- padding: 14px;
- border-radius: 4px;
- margin-bottom: 20px;
- display: flex;
- align-items: center;
-
- strong,
- a {
- font-weight: 500;
- }
-
- a {
- color: inherit;
- text-decoration: underline;
- }
-
- .logo {
- width: 30px;
- height: 30px;
- flex: 0 0 auto;
- margin-right: 15px;
- }
-
- @media screen and (max-width: 740px) {
- margin-bottom: 0;
- }
-}
-
-.memoriam-strip {
- background: rgba($base-shadow-color, 0.7);
-}
diff --git a/app/javascript/styles/mastodon/lists.scss b/app/javascript/styles/mastodon/lists.scss
deleted file mode 100644
index 6019cd800..000000000
--- a/app/javascript/styles/mastodon/lists.scss
+++ /dev/null
@@ -1,19 +0,0 @@
-.no-list {
- list-style: none;
-
- li {
- display: inline-block;
- margin: 0 5px;
- }
-}
-
-.recovery-codes {
- list-style: none;
- margin: 0 auto;
-
- li {
- font-size: 125%;
- line-height: 1.5;
- letter-spacing: 1px;
- }
-}
diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss
deleted file mode 100644
index cc5ba9d7c..000000000
--- a/app/javascript/styles/mastodon/reset.scss
+++ /dev/null
@@ -1,91 +0,0 @@
-/* http://meyerweb.com/eric/tools/css/reset/
- v2.0 | 20110126
- License: none (public domain)
-*/
-
-html, body, div, span, applet, object, iframe,
-h1, h2, h3, h4, h5, h6, p, blockquote, pre,
-a, abbr, acronym, address, big, cite, code,
-del, dfn, em, img, ins, kbd, q, s, samp,
-small, strike, strong, sub, sup, tt, var,
-b, u, i, center,
-dl, dt, dd, ol, ul, li,
-fieldset, form, label, legend,
-table, caption, tbody, tfoot, thead, tr, th, td,
-article, aside, canvas, details, embed,
-figure, figcaption, footer, header, hgroup,
-menu, nav, output, ruby, section, summary,
-time, mark, audio, video {
- margin: 0;
- padding: 0;
- border: 0;
- font-size: 100%;
- font: inherit;
- vertical-align: baseline;
-}
-
-/* HTML5 display-role reset for older browsers */
-article, aside, details, figcaption, figure,
-footer, header, hgroup, menu, nav, section {
- display: block;
-}
-
-body {
- line-height: 1;
-}
-
-ol, ul {
- list-style: none;
-}
-
-blockquote, q {
- quotes: none;
-}
-
-blockquote:before, blockquote:after,
-q:before, q:after {
- content: '';
- content: none;
-}
-
-table {
- border-collapse: collapse;
- border-spacing: 0;
-}
-
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-thumb {
- background: lighten($ui-base-color, 4%);
- border: 0px none $base-border-color;
- border-radius: 50px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: lighten($ui-base-color, 6%);
-}
-
-::-webkit-scrollbar-thumb:active {
- background: lighten($ui-base-color, 4%);
-}
-
-::-webkit-scrollbar-track {
- border: 0px none $base-border-color;
- border-radius: 0;
- background: rgba($base-overlay-background, 0.1);
-}
-
-::-webkit-scrollbar-track:hover {
- background: $ui-base-color;
-}
-
-::-webkit-scrollbar-track:active {
- background: $ui-base-color;
-}
-
-::-webkit-scrollbar-corner {
- background: transparent;
-}
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
deleted file mode 100644
index 67bfa8a38..000000000
--- a/app/javascript/styles/mastodon/rtl.scss
+++ /dev/null
@@ -1,254 +0,0 @@
-body.rtl {
- direction: rtl;
-
- .column-link__icon,
- .column-header__icon {
- margin-right: 0;
- margin-left: 5px;
- }
-
- .character-counter__wrapper {
- margin-right: 8px;
- margin-left: 16px;
- }
-
- .navigation-bar__profile {
- margin-left: 0;
- margin-right: 8px;
- }
-
- .search__input {
- padding-right: 10px;
- padding-left: 30px;
- }
-
- .search__icon .fa {
- right: auto;
- left: 10px;
- }
-
- .column-header__buttons {
- left: 0;
- right: auto;
- }
-
- .column-header__back-button {
- padding-left: 5px;
- padding-right: 0;
- }
-
- .column-header__setting-arrows {
- float: left;
- }
-
- .compose-form__modifiers {
- border-radius: 0 0 0 4px;
- }
-
- .setting-toggle {
- margin-left: 0;
- margin-right: 8px;
- }
-
- .setting-meta__label {
- float: left;
- }
-
- .status__avatar {
- left: auto;
- right: 10px;
- }
-
- .status,
- .activity-stream .status.light {
- padding-left: 10px;
- padding-right: 68px;
- }
-
- .status__info .status__display-name,
- .activity-stream .status.light .status__display-name {
- padding-left: 25px;
- padding-right: 0;
- }
-
- .activity-stream .pre-header {
- padding-right: 68px;
- padding-left: 0;
- }
-
- .status__prepend {
- margin-left: 0;
- margin-right: 68px;
- }
-
- .status__prepend-icon-wrapper {
- left: auto;
- right: -26px;
- }
-
- .activity-stream .pre-header .pre-header__icon {
- left: auto;
- right: 42px;
- }
-
- .account__avatar-overlay-overlay {
- right: auto;
- left: 0;
- }
-
- .column-back-button--slim-button {
- right: auto;
- left: 0;
- }
-
- .status__relative-time,
- .activity-stream .status.light .status__header .status__meta {
- float: left;
- }
-
- .activity-stream .detailed-status.light .detailed-status__display-name > div {
- float: right;
- margin-right: 0;
- margin-left: 10px;
- }
-
- .activity-stream .detailed-status.light .detailed-status__meta span > span {
- margin-left: 0;
- margin-right: 6px;
- }
-
- .status__action-bar-button {
- float: right;
- margin-right: 0;
- margin-left: 18px;
- }
-
- .status__action-bar-dropdown {
- float: right;
- }
-
- .privacy-dropdown__dropdown {
- margin-left: 0;
- margin-right: 40px;
- }
-
- .privacy-dropdown__option__icon {
- margin-left: 10px;
- margin-right: 0;
- }
-
- .detailed-status__display-avatar {
- margin-right: 0;
- margin-left: 10px;
- float: right;
- }
-
- .detailed-status__favorites,
- .detailed-status__reblogs {
- margin-left: 0;
- margin-right: 6px;
- }
-
- .fa-ul {
- margin-left: 0;
- margin-left: 2.14285714em;
- }
-
- .fa-li {
- left: auto;
- right: -2.14285714em;
- }
-
- .admin-wrapper .sidebar ul a i.fa,
- a.table-action-link i.fa {
- margin-right: 0;
- margin-left: 5px;
- }
-
- .simple_form .check_boxes .checkbox label,
- .simple_form .input.with_label.boolean label.checkbox {
- padding-left: 0;
- padding-right: 25px;
- }
-
- .simple_form .check_boxes .checkbox input[type="checkbox"],
- .simple_form .input.boolean input[type="checkbox"] {
- left: auto;
- right: 0;
- }
-
- .simple_form .input-with-append .input input {
- padding-left: 127px;
- padding-right: 0;
- }
-
- .simple_form .input-with-append .append {
- right: auto;
- left: 0;
- }
-
- .table th,
- .table td {
- text-align: right;
- }
-
- .filters .filter-subset {
- margin-right: 0;
- margin-left: 45px;
- }
-
- .landing-page .header-wrapper .mascot {
- right: 60px;
- left: auto;
- }
-
- .landing-page .header .hero .floats .float-1 {
- left: -120px;
- right: auto;
- }
-
- .landing-page .header .hero .floats .float-2 {
- left: 210px;
- right: auto;
- }
-
- .landing-page .header .hero .floats .float-3 {
- left: 110px;
- right: auto;
- }
-
- .landing-page .header .links .brand img {
- left: 0;
- }
-
- .landing-page .fa-external-link {
- padding-right: 5px;
- padding-left: 0 !important;
- }
-
- .landing-page .features #mastodon-timeline {
- margin-right: 0;
- margin-left: 30px;
- }
-
- @media screen and (min-width: 631px) {
- .column,
- .drawer {
- padding-left: 5px;
- padding-right: 5px;
-
- &:first-child {
- padding-left: 5px;
- padding-right: 10px;
- }
- }
-
- .columns-area > div {
- .column,
- .drawer {
- padding-left: 5px;
- padding-right: 5px;
- }
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss
deleted file mode 100644
index 453070b7c..000000000
--- a/app/javascript/styles/mastodon/stream_entries.scss
+++ /dev/null
@@ -1,335 +0,0 @@
-.activity-stream {
- clear: both;
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-
- .entry {
- background: $simple-background-color;
-
- .detailed-status.light,
- .status.light {
- border-bottom: 1px solid $ui-secondary-color;
- animation: none;
- }
-
- &:last-child {
- &,
- .detailed-status.light,
- .status.light {
- border-bottom: 0;
- border-radius: 0 0 4px 4px;
- }
- }
-
- &:first-child {
- &,
- .detailed-status.light,
- .status.light {
- border-radius: 4px 4px 0 0;
- }
-
- &:last-child {
- &,
- .detailed-status.light,
- .status.light {
- border-radius: 4px;
- }
- }
- }
-
- @media screen and (max-width: 740px) {
- &,
- .detailed-status.light,
- .status.light {
- border-radius: 0 !important;
- }
- }
- }
-
- &.with-header {
- .entry {
- &:first-child {
- &,
- .detailed-status.light,
- .status.light {
- border-radius: 0;
- }
-
- &:last-child {
- &,
- .detailed-status.light,
- .status.light {
- border-radius: 0 0 4px 4px;
- }
- }
- }
- }
- }
-
- .status.light {
- padding: 14px 14px 14px (48px + 14px * 2);
- position: relative;
- min-height: 48px;
- cursor: default;
-
- .status__header {
- font-size: 15px;
-
- .status__meta {
- float: right;
- font-size: 14px;
-
- .status__relative-time {
- color: $ui-primary-color;
- }
- }
- }
-
- .status__display-name {
- display: block;
- max-width: 100%;
- padding-right: 25px;
- color: $ui-base-color;
- }
-
- .status__avatar {
- position: absolute;
- @include avatar-size(48px);
- margin-left: -62px;
-
- & > div {
- @include avatar-size(48px);
- }
-
- img {
- @include avatar-radius();
- display: block;
- }
- }
-
- .display-name {
- display: block;
- max-width: 100%;
- //overflow: hidden;
- //white-space: nowrap;
- //text-overflow: ellipsis;
-
- strong {
- font-weight: 500;
- color: $ui-base-color;
- }
-
- span {
- font-size: 14px;
- color: $ui-primary-color;
- }
- }
-
- .status__content {
- color: $ui-base-color;
-
- a {
- color: $ui-highlight-color;
- }
-
- a.status__content__spoiler-link {
- color: $primary-text-color;
- background: $ui-primary-color;
-
- &:hover {
- background: lighten($ui-primary-color, 8%);
- }
- }
- }
- }
-
- .detailed-status.light {
- padding: 14px;
- background: $simple-background-color;
- cursor: default;
-
- .detailed-status__display-name {
- display: block;
- overflow: hidden;
- margin-bottom: 15px;
-
- & > div {
- float: left;
- margin-right: 10px;
- }
-
- .display-name {
- display: block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-
- strong {
- font-weight: 500;
- color: $ui-base-color;
- }
-
- span {
- font-size: 14px;
- color: $ui-primary-color;
- }
- }
- }
-
- .avatar {
- @include avatar-size(48px);
-
- img {
- @include avatar-radius();
- display: block;
- }
- }
-
- .status__content {
- color: $ui-base-color;
-
- a {
- color: $ui-highlight-color;
- }
-
- a.status__content__spoiler-link {
- color: $primary-text-color;
- background: $ui-primary-color;
-
- &:hover {
- background: lighten($ui-primary-color, 8%);
- }
- }
- }
-
- .detailed-status__meta {
- margin-top: 15px;
- color: $ui-primary-color;
- font-size: 14px;
- line-height: 18px;
-
- a {
- color: inherit;
- }
-
- span > span {
- font-weight: 500;
- font-size: 12px;
- margin-left: 6px;
- display: inline-block;
- }
- }
-
- .status-card {
- border-color: lighten($ui-secondary-color, 4%);
- color: darken($ui-primary-color, 4%);
-
- &:hover {
- background: lighten($ui-secondary-color, 4%);
- }
- }
-
- .status-card__title,
- .status-card__description {
- color: $ui-base-color;
- }
-
- .status-card__image {
- background: $ui-secondary-color;
- }
- }
-
- .media-spoiler {
- background: $ui-primary-color;
- color: $white;
- transition: all 100ms linear;
-
- &:hover,
- &:active,
- &:focus {
- background: darken($ui-primary-color, 5%);
- color: unset;
- }
- }
-
- .pre-header {
- padding: 14px 0;
- padding-left: (48px + 14px * 2);
- padding-bottom: 0;
- margin-bottom: -4px;
- color: $ui-primary-color;
- font-size: 14px;
- position: relative;
-
- .pre-header__icon {
- position: absolute;
- left: (48px + 14px * 2 - 30px);
- }
-
- .status__display-name.muted strong {
- color: $ui-primary-color;
- }
- }
-
- .open-in-web-link {
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-.embed {
- .activity-stream {
- box-shadow: none;
-
- .entry {
-
- .detailed-status.light {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- align-items: flex-start;
-
- .detailed-status__display-name {
- flex: 1;
- margin: 0 5px 15px 0;
- }
-
- .button.button-secondary.logo-button {
- flex: 0 auto;
- font-size: 14px;
-
- svg {
- width: 20px;
- height: auto;
- vertical-align: middle;
- margin-right: 5px;
-
- path:first-child {
- fill: $ui-primary-color;
- }
-
- path:last-child {
- fill: $simple-background-color;
- }
- }
-
- &:active,
- &:focus,
- &:hover {
- svg path:first-child {
- fill: lighten($ui-primary-color, 4%);
- }
- }
- }
-
- .status__content,
- .detailed-status__meta {
- flex: 100%;
- }
- }
- }
- }
-}
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
deleted file mode 100644
index ad46f5f9f..000000000
--- a/app/javascript/styles/mastodon/tables.scss
+++ /dev/null
@@ -1,76 +0,0 @@
-.table {
- width: 100%;
- max-width: 100%;
- border-spacing: 0;
- border-collapse: collapse;
-
- th,
- td {
- padding: 8px;
- line-height: 18px;
- vertical-align: top;
- border-top: 1px solid $ui-base-color;
- text-align: left;
- }
-
- & > thead > tr > th {
- vertical-align: bottom;
- border-bottom: 2px solid $ui-base-color;
- border-top: 0;
- font-weight: 500;
- }
-
- & > tbody > tr > th {
- font-weight: 500;
- }
-
- & > tbody > tr:nth-child(odd) > td,
- & > tbody > tr:nth-child(odd) > th {
- background: $ui-base-color;
- }
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
-
- &:hover {
- text-decoration: none;
- }
- }
-
- strong {
- font-weight: 500;
- }
-
- &.inline-table > tbody > tr:nth-child(odd) > td,
- &.inline-table > tbody > tr:nth-child(odd) > th {
- background: transparent;
- }
-}
-
-.table-wrapper {
- overflow: auto;
- margin-bottom: 20px;
-}
-
-samp {
- font-family: 'mastodon-font-monospace', monospace;
-}
-
-a.table-action-link {
- text-decoration: none;
- display: inline-block;
- margin-right: 5px;
- padding: 0 10px;
- color: rgba($primary-text-color, 0.7);
- font-weight: 500;
-
- &:hover {
- color: $primary-text-color;
- }
-
- i.fa {
- font-weight: 400;
- margin-right: 5px;
- }
-}
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
deleted file mode 100644
index 090706ff5..000000000
--- a/app/javascript/styles/mastodon/variables.scss
+++ /dev/null
@@ -1,32 +0,0 @@
-// Commonly used web colors
-$black: #000000; // Black
-$white: #ffffff; // White
-$success-green: #79bd9a; // Padua
-$error-red: #df405a; // Cerise
-$warning-red: #ff5050; // Sunset Orange
-$gold-star: #ca8f04; // Dark Goldenrod
-
-// Values from the classic Mastodon UI
-$classic-base-color: #282c37; // Midnight Express
-$classic-primary-color: #9baec8; // Echo Blue
-$classic-secondary-color: #d9e1e8; // Pattens Blue
-$classic-highlight-color: #2b90d9; // Summer Sky
-
-// Variables for defaults in UI
-$base-shadow-color: $black !default;
-$base-overlay-background: $black !default;
-$base-border-color: $white !default;
-$simple-background-color: $white !default;
-$primary-text-color: $white !default;
-$valid-value-color: $success-green !default;
-$error-value-color: $error-red !default;
-
-// Tell UI to use selected colors
-$ui-base-color: $classic-base-color !default; // Darkest
-$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
-$ui-primary-color: $classic-primary-color !default; // Lighter
-$ui-secondary-color: $classic-secondary-color !default; // Lightest
-$ui-highlight-color: $classic-highlight-color !default; // Vibrant
-
-// Avatar border size (8% default, 100% for rounded avatars)
-$ui-avatar-border-size: 8%;
diff --git a/app/javascript/styles/variables-glitch.scss b/app/javascript/styles/variables-glitch.scss
deleted file mode 100644
index 44d3322f2..000000000
--- a/app/javascript/styles/variables-glitch.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-// glitch-soc added variables
-
-$dismiss-overlay-width: 4rem;
diff --git a/app/javascript/themes/default/theme.yml b/app/javascript/themes/default/theme.yml
deleted file mode 100644
index 0b262cc82..000000000
--- a/app/javascript/themes/default/theme.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-# (REQUIRED) The location of the pack file inside `pack_directory`.
-pack: application.js
-
-# (OPTIONAL) The directory which contains the pack file.
-# Defaults to the theme directory (`app/javascript/themes/[theme]`),
-# but in the case of the vanilla Mastodon theme the pack file is
-# somewhere else.
-pack_directory: app/javascript/packs
-
-# (OPTIONAL) Additional javascript resources to preload, for use with
-# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you
-# derive these pathnames from `themes/[your-theme]` to ensure that
-# they stay unique. (Of course, vanilla doesn't do this ^^;;)
-preload:
-- features/getting_started
-- features/compose
-- features/home_timeline
-- features/notifications
diff --git a/app/javascript/themes/glitch/actions/accounts.js b/app/javascript/themes/glitch/actions/accounts.js
new file mode 100644
index 000000000..f1a8c5471
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/accounts.js
@@ -0,0 +1,661 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchRelationships([id]));
+
+ if (getState().getIn(['accounts', id], null) !== null) {
+ return;
+ }
+
+ dispatch(fetchAccountRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+ dispatch(fetchAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchAccountFail(id, error));
+ });
+ };
+};
+
+export function fetchAccountRequest(id) {
+ return {
+ type: ACCOUNT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchAccountSuccess(account) {
+ return {
+ type: ACCOUNT_FETCH_SUCCESS,
+ account,
+ };
+};
+
+export function fetchAccountFail(id, error) {
+ return {
+ type: ACCOUNT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function followAccount(id, reblogs = true) {
+ return (dispatch, getState) => {
+ const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
+ dispatch(followAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+ dispatch(followAccountSuccess(response.data, alreadyFollowing));
+ }).catch(error => {
+ dispatch(followAccountFail(error));
+ });
+ };
+};
+
+export function unfollowAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unfollowAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+ dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(unfollowAccountFail(error));
+ });
+ };
+};
+
+export function followAccountRequest(id) {
+ return {
+ type: ACCOUNT_FOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function followAccountSuccess(relationship, alreadyFollowing) {
+ return {
+ type: ACCOUNT_FOLLOW_SUCCESS,
+ relationship,
+ alreadyFollowing,
+ };
+};
+
+export function followAccountFail(error) {
+ return {
+ type: ACCOUNT_FOLLOW_FAIL,
+ error,
+ };
+};
+
+export function unfollowAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNFOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function unfollowAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_UNFOLLOW_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function unfollowAccountFail(error) {
+ return {
+ type: ACCOUNT_UNFOLLOW_FAIL,
+ error,
+ };
+};
+
+export function blockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(blockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(blockAccountFail(id, error));
+ });
+ };
+};
+
+export function unblockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unblockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+ dispatch(unblockAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unblockAccountFail(id, error));
+ });
+ };
+};
+
+export function blockAccountRequest(id) {
+ return {
+ type: ACCOUNT_BLOCK_REQUEST,
+ id,
+ };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_BLOCK_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function blockAccountFail(error) {
+ return {
+ type: ACCOUNT_BLOCK_FAIL,
+ error,
+ };
+};
+
+export function unblockAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNBLOCK_REQUEST,
+ id,
+ };
+};
+
+export function unblockAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNBLOCK_SUCCESS,
+ relationship,
+ };
+};
+
+export function unblockAccountFail(error) {
+ return {
+ type: ACCOUNT_UNBLOCK_FAIL,
+ error,
+ };
+};
+
+
+export function muteAccount(id, notifications) {
+ return (dispatch, getState) => {
+ dispatch(muteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(muteAccountFail(id, error));
+ });
+ };
+};
+
+export function unmuteAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+ dispatch(unmuteAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unmuteAccountFail(id, error));
+ });
+ };
+};
+
+export function muteAccountRequest(id) {
+ return {
+ type: ACCOUNT_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_MUTE_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function muteAccountFail(error) {
+ return {
+ type: ACCOUNT_MUTE_FAIL,
+ error,
+ };
+};
+
+export function unmuteAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNMUTE_SUCCESS,
+ relationship,
+ };
+};
+
+export function unmuteAccountFail(error) {
+ return {
+ type: ACCOUNT_UNMUTE_FAIL,
+ error,
+ };
+};
+
+
+export function fetchFollowers(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowersRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowersFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowers(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowersRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowersFail(id, error));
+ });
+ };
+};
+
+export function expandFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchFollowing(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowingRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowingFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowingRequest(id) {
+ return {
+ type: FOLLOWING_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowing(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowingRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowingFail(id, error));
+ });
+ };
+};
+
+export function expandFollowingRequest(id) {
+ return {
+ type: FOLLOWING_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchRelationships(accountIds) {
+ return (dispatch, getState) => {
+ const loadedRelationships = getState().get('relationships');
+ const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+ if (newAccountIds.length === 0) {
+ return;
+ }
+
+ dispatch(fetchRelationshipsRequest(newAccountIds));
+
+ api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+ dispatch(fetchRelationshipsSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchRelationshipsFail(error));
+ });
+ };
+};
+
+export function fetchRelationshipsRequest(ids) {
+ return {
+ type: RELATIONSHIPS_FETCH_REQUEST,
+ ids,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+ return {
+ type: RELATIONSHIPS_FETCH_SUCCESS,
+ relationships,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsFail(error) {
+ return {
+ type: RELATIONSHIPS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ };
+};
+
+export function fetchFollowRequests() {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowRequestsRequest());
+
+ api(getState).get('/api/v1/follow_requests').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+ };
+};
+
+export function fetchFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_REQUEST,
+ };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandFollowRequests() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowRequestsRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(expandFollowRequestsFail(error)));
+ };
+};
+
+export function expandFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_REQUEST,
+ };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function authorizeFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(authorizeFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/authorize`)
+ .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+ .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+ };
+};
+
+export function authorizeFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+ id,
+ };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+ id,
+ };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+ id,
+ error,
+ };
+};
+
+
+export function rejectFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(rejectFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/reject`)
+ .then(() => dispatch(rejectFollowRequestSuccess(id)))
+ .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+ };
+};
+
+export function rejectFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_REQUEST,
+ id,
+ };
+};
+
+export function rejectFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_SUCCESS,
+ id,
+ };
+};
+
+export function rejectFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/alerts.js b/app/javascript/themes/glitch/actions/alerts.js
new file mode 100644
index 000000000..f37fdeeb6
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/alerts.js
@@ -0,0 +1,24 @@
+export const ALERT_SHOW = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR = 'ALERT_CLEAR';
+
+export function dismissAlert(alert) {
+ return {
+ type: ALERT_DISMISS,
+ alert,
+ };
+};
+
+export function clearAlert() {
+ return {
+ type: ALERT_CLEAR,
+ };
+};
+
+export function showAlert(title, message) {
+ return {
+ type: ALERT_SHOW,
+ title,
+ message,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/blocks.js b/app/javascript/themes/glitch/actions/blocks.js
new file mode 100644
index 000000000..6ba9460f0
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/blocks.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+ return (dispatch, getState) => {
+ dispatch(fetchBlocksRequest());
+
+ api(getState).get('/api/v1/blocks').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchBlocksFail(error)));
+ };
+};
+
+export function fetchBlocksRequest() {
+ return {
+ type: BLOCKS_FETCH_REQUEST,
+ };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+ return {
+ type: BLOCKS_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchBlocksFail(error) {
+ return {
+ type: BLOCKS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandBlocks() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandBlocksRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandBlocksFail(error)));
+ };
+};
+
+export function expandBlocksRequest() {
+ return {
+ type: BLOCKS_EXPAND_REQUEST,
+ };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+ return {
+ type: BLOCKS_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandBlocksFail(error) {
+ return {
+ type: BLOCKS_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/bundles.js b/app/javascript/themes/glitch/actions/bundles.js
new file mode 100644
index 000000000..ecc9c8f7d
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/bundles.js
@@ -0,0 +1,25 @@
+export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
+export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
+export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
+
+export function fetchBundleRequest(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_REQUEST,
+ skipLoading,
+ };
+}
+
+export function fetchBundleSuccess(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_SUCCESS,
+ skipLoading,
+ };
+}
+
+export function fetchBundleFail(error, skipLoading) {
+ return {
+ type: BUNDLE_FETCH_FAIL,
+ error,
+ skipLoading,
+ };
+}
diff --git a/app/javascript/themes/glitch/actions/cards.js b/app/javascript/themes/glitch/actions/cards.js
new file mode 100644
index 000000000..2a1bc369a
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/cards.js
@@ -0,0 +1,52 @@
+import api from 'themes/glitch/util/api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+ return (dispatch, getState) => {
+ if (getState().getIn(['cards', id], null) !== null) {
+ return;
+ }
+
+ dispatch(fetchStatusCardRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+ if (!response.data.url) {
+ return;
+ }
+
+ dispatch(fetchStatusCardSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchStatusCardFail(id, error));
+ });
+ };
+};
+
+export function fetchStatusCardRequest(id) {
+ return {
+ type: STATUS_CARD_FETCH_REQUEST,
+ id,
+ skipLoading: true,
+ };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+ return {
+ type: STATUS_CARD_FETCH_SUCCESS,
+ id,
+ card,
+ skipLoading: true,
+ };
+};
+
+export function fetchStatusCardFail(id, error) {
+ return {
+ type: STATUS_CARD_FETCH_FAIL,
+ id,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/columns.js b/app/javascript/themes/glitch/actions/columns.js
new file mode 100644
index 000000000..bcb0cdf98
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/columns.js
@@ -0,0 +1,40 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD = 'COLUMN_ADD';
+export const COLUMN_REMOVE = 'COLUMN_REMOVE';
+export const COLUMN_MOVE = 'COLUMN_MOVE';
+
+export function addColumn(id, params) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_ADD,
+ id,
+ params,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function removeColumn(uuid) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_REMOVE,
+ uuid,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function moveColumn(uuid, direction) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_MOVE,
+ uuid,
+ direction,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/compose.js b/app/javascript/themes/glitch/actions/compose.js
new file mode 100644
index 000000000..07c469477
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/compose.js
@@ -0,0 +1,398 @@
+import api from 'themes/glitch/util/api';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from 'themes/glitch/util/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
+
+import {
+ updateTimeline,
+ refreshHomeTimeline,
+ refreshCommunityTimeline,
+ refreshPublicTimeline,
+ refreshDirectTimeline,
+} from './timelines';
+
+export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
+export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION = 'COMPOSE_MENTION';
+export const COMPOSE_RESET = 'COMPOSE_RESET';
+export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
+export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET';
+
+export function changeCompose(text) {
+ return {
+ type: COMPOSE_CHANGE,
+ text: text,
+ };
+};
+
+export function replyCompose(status, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_REPLY,
+ status: status,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function cancelReplyCompose() {
+ return {
+ type: COMPOSE_REPLY_CANCEL,
+ };
+};
+
+export function resetCompose() {
+ return {
+ type: COMPOSE_RESET,
+ };
+};
+
+export function mentionCompose(account, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_MENTION,
+ account: account,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function submitCompose() {
+ return function (dispatch, getState) {
+ let status = getState().getIn(['compose', 'text'], '');
+
+ if (!status || !status.length) {
+ return;
+ }
+
+ dispatch(submitComposeRequest());
+ if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
+ status = status + ' 👁️';
+ }
+ api(getState).post('/api/v1/statuses', {
+ status,
+ in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+ media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+ sensitive: getState().getIn(['compose', 'sensitive']),
+ spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+ visibility: getState().getIn(['compose', 'privacy']),
+ }, {
+ headers: {
+ 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+ },
+ }).then(function (response) {
+ dispatch(submitComposeSuccess({ ...response.data }));
+
+ // To make the app more responsive, immediately get the status into the columns
+
+ const insertOrRefresh = (timelineId, refreshAction) => {
+ if (getState().getIn(['timelines', timelineId, 'online'])) {
+ dispatch(updateTimeline(timelineId, { ...response.data }));
+ } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+ dispatch(refreshAction());
+ }
+ };
+
+ insertOrRefresh('home', refreshHomeTimeline);
+
+ if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+ insertOrRefresh('community', refreshCommunityTimeline);
+ insertOrRefresh('public', refreshPublicTimeline);
+ } else if (response.data.visibility === 'direct') {
+ insertOrRefresh('direct', refreshDirectTimeline);
+ }
+ }).catch(function (error) {
+ dispatch(submitComposeFail(error));
+ });
+ };
+};
+
+export function submitComposeRequest() {
+ return {
+ type: COMPOSE_SUBMIT_REQUEST,
+ };
+};
+
+export function submitComposeSuccess(status) {
+ return {
+ type: COMPOSE_SUBMIT_SUCCESS,
+ status: status,
+ };
+};
+
+export function submitComposeFail(error) {
+ return {
+ type: COMPOSE_SUBMIT_FAIL,
+ error: error,
+ };
+};
+
+export function doodleSet(options) {
+ return {
+ type: COMPOSE_DOODLE_SET,
+ options: options,
+ };
+};
+
+export function uploadCompose(files) {
+ return function (dispatch, getState) {
+ if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+ return;
+ }
+
+ dispatch(uploadComposeRequest());
+
+ let data = new FormData();
+ data.append('file', files[0]);
+
+ api(getState).post('/api/v1/media', data, {
+ onUploadProgress: function (e) {
+ dispatch(uploadComposeProgress(e.loaded, e.total));
+ },
+ }).then(function (response) {
+ dispatch(uploadComposeSuccess(response.data));
+ }).catch(function (error) {
+ dispatch(uploadComposeFail(error));
+ });
+ };
+};
+
+export function changeUploadCompose(id, description) {
+ return (dispatch, getState) => {
+ dispatch(changeUploadComposeRequest());
+
+ api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+ dispatch(changeUploadComposeSuccess(response.data));
+ }).catch(error => {
+ dispatch(changeUploadComposeFail(id, error));
+ });
+ };
+};
+
+export function changeUploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+ skipLoading: true,
+ };
+};
+export function changeUploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function changeUploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_REQUEST,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeProgress(loaded, total) {
+ return {
+ type: COMPOSE_UPLOAD_PROGRESS,
+ loaded: loaded,
+ total: total,
+ };
+};
+
+export function uploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function undoUploadCompose(media_id) {
+ return {
+ type: COMPOSE_UPLOAD_UNDO,
+ media_id: media_id,
+ };
+};
+
+export function clearComposeSuggestions() {
+ return {
+ type: COMPOSE_SUGGESTIONS_CLEAR,
+ };
+};
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+ api(getState).get('/api/v1/accounts/search', {
+ params: {
+ q: token.slice(1),
+ resolve: false,
+ limit: 4,
+ },
+ }).then(response => {
+ dispatch(readyComposeSuggestionsAccounts(token, response.data));
+ });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+ const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+ dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+export function fetchComposeSuggestions(token) {
+ return (dispatch, getState) => {
+ if (token[0] === ':') {
+ fetchComposeSuggestionsEmojis(dispatch, getState, token);
+ } else {
+ fetchComposeSuggestionsAccounts(dispatch, getState, token);
+ }
+ };
+};
+
+export function readyComposeSuggestionsEmojis(token, emojis) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ emojis,
+ };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ accounts,
+ };
+};
+
+export function selectComposeSuggestion(position, token, suggestion) {
+ return (dispatch, getState) => {
+ let completion, startPosition;
+
+ if (typeof suggestion === 'object' && suggestion.id) {
+ completion = suggestion.native || suggestion.colons;
+ startPosition = position - 1;
+
+ dispatch(useEmoji(suggestion));
+ } else {
+ completion = getState().getIn(['accounts', suggestion, 'acct']);
+ startPosition = position;
+ }
+
+ dispatch({
+ type: COMPOSE_SUGGESTION_SELECT,
+ position: startPosition,
+ token,
+ completion,
+ });
+ };
+};
+
+export function mountCompose() {
+ return {
+ type: COMPOSE_MOUNT,
+ };
+};
+
+export function unmountCompose() {
+ return {
+ type: COMPOSE_UNMOUNT,
+ };
+};
+
+export function toggleComposeAdvancedOption(option) {
+ return {
+ type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
+ option: option,
+ };
+}
+
+export function changeComposeSensitivity() {
+ return {
+ type: COMPOSE_SENSITIVITY_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerness() {
+ return {
+ type: COMPOSE_SPOILERNESS_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerText(text) {
+ return {
+ type: COMPOSE_SPOILER_TEXT_CHANGE,
+ text,
+ };
+};
+
+export function changeComposeVisibility(value) {
+ return {
+ type: COMPOSE_VISIBILITY_CHANGE,
+ value,
+ };
+};
+
+export function insertEmojiCompose(position, emoji) {
+ return {
+ type: COMPOSE_EMOJI_INSERT,
+ position,
+ emoji,
+ };
+};
+
+export function changeComposing(value) {
+ return {
+ type: COMPOSE_COMPOSING_CHANGE,
+ value,
+ };
+}
diff --git a/app/javascript/themes/glitch/actions/domain_blocks.js b/app/javascript/themes/glitch/actions/domain_blocks.js
new file mode 100644
index 000000000..0a880394a
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/domain_blocks.js
@@ -0,0 +1,117 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+
+export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
+export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
+export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
+
+export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
+export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
+export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
+
+export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
+export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
+
+export function blockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(blockDomainRequest(domain));
+
+ api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+ dispatch(blockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(blockDomainFail(domain, err));
+ });
+ };
+};
+
+export function blockDomainRequest(domain) {
+ return {
+ type: DOMAIN_BLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function blockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_BLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function blockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_BLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function unblockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(unblockDomainRequest(domain));
+
+ api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+ dispatch(unblockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(unblockDomainFail(domain, err));
+ });
+ };
+};
+
+export function unblockDomainRequest(domain) {
+ return {
+ type: DOMAIN_UNBLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function unblockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_UNBLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function unblockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_UNBLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function fetchDomainBlocks() {
+ return (dispatch, getState) => {
+ dispatch(fetchDomainBlocksRequest());
+
+ api(getState).get().then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
+ }).catch(err => {
+ dispatch(fetchDomainBlocksFail(err));
+ });
+ };
+};
+
+export function fetchDomainBlocksRequest() {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_REQUEST,
+ };
+};
+
+export function fetchDomainBlocksSuccess(domains, next) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_SUCCESS,
+ domains,
+ next,
+ };
+};
+
+export function fetchDomainBlocksFail(error) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/emojis.js b/app/javascript/themes/glitch/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+ return dispatch => {
+ dispatch({
+ type: EMOJI_USE,
+ emoji,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/favourites.js b/app/javascript/themes/glitch/actions/favourites.js
new file mode 100644
index 000000000..e9b3559af
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/favourites.js
@@ -0,0 +1,83 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritedStatusesRequest());
+
+ api(getState).get('/api/v1/favourites').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(fetchFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandFavouritedStatuses() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFavouritedStatusesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function expandFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_REQUEST,
+ };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function expandFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/height_cache.js b/app/javascript/themes/glitch/actions/height_cache.js
new file mode 100644
index 000000000..4c752993f
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+ return {
+ type: HEIGHT_CACHE_SET,
+ key,
+ id,
+ height,
+ };
+};
+
+export function clearHeight () {
+ return {
+ type: HEIGHT_CACHE_CLEAR,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/interactions.js b/app/javascript/themes/glitch/actions/interactions.js
new file mode 100644
index 000000000..d61a7ba2a
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/interactions.js
@@ -0,0 +1,313 @@
+import api from 'themes/glitch/util/api';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL = 'UNPIN_FAIL';
+
+export function reblog(status) {
+ return function (dispatch, getState) {
+ dispatch(reblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+ // The reblog API method returns a new status wrapped around the original. In this case we are only
+ // interested in how the original is modified, hence passing it skipping the wrapper
+ dispatch(reblogSuccess(status, response.data.reblog));
+ }).catch(function (error) {
+ dispatch(reblogFail(status, error));
+ });
+ };
+};
+
+export function unreblog(status) {
+ return (dispatch, getState) => {
+ dispatch(unreblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+ dispatch(unreblogSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unreblogFail(status, error));
+ });
+ };
+};
+
+export function reblogRequest(status) {
+ return {
+ type: REBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function reblogSuccess(status, response) {
+ return {
+ type: REBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function reblogFail(status, error) {
+ return {
+ type: REBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unreblogRequest(status) {
+ return {
+ type: UNREBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function unreblogSuccess(status, response) {
+ return {
+ type: UNREBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unreblogFail(status, error) {
+ return {
+ type: UNREBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function favourite(status) {
+ return function (dispatch, getState) {
+ dispatch(favouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+ dispatch(favouriteSuccess(status, response.data));
+ }).catch(function (error) {
+ dispatch(favouriteFail(status, error));
+ });
+ };
+};
+
+export function unfavourite(status) {
+ return (dispatch, getState) => {
+ dispatch(unfavouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+ dispatch(unfavouriteSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unfavouriteFail(status, error));
+ });
+ };
+};
+
+export function favouriteRequest(status) {
+ return {
+ type: FAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function favouriteSuccess(status, response) {
+ return {
+ type: FAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function favouriteFail(status, error) {
+ return {
+ type: FAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unfavouriteRequest(status) {
+ return {
+ type: UNFAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function unfavouriteSuccess(status, response) {
+ return {
+ type: UNFAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unfavouriteFail(status, error) {
+ return {
+ type: UNFAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function fetchReblogs(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchReblogsRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+ dispatch(fetchReblogsSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchReblogsFail(id, error));
+ });
+ };
+};
+
+export function fetchReblogsRequest(id) {
+ return {
+ type: REBLOGS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+ return {
+ type: REBLOGS_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchReblogsFail(id, error) {
+ return {
+ type: REBLOGS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function fetchFavourites(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritesRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+ dispatch(fetchFavouritesSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchFavouritesFail(id, error));
+ });
+ };
+};
+
+export function fetchFavouritesRequest(id) {
+ return {
+ type: FAVOURITES_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+ return {
+ type: FAVOURITES_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchFavouritesFail(id, error) {
+ return {
+ type: FAVOURITES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function pin(status) {
+ return (dispatch, getState) => {
+ dispatch(pinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+ dispatch(pinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(pinFail(status, error));
+ });
+ };
+};
+
+export function pinRequest(status) {
+ return {
+ type: PIN_REQUEST,
+ status,
+ };
+};
+
+export function pinSuccess(status, response) {
+ return {
+ type: PIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function pinFail(status, error) {
+ return {
+ type: PIN_FAIL,
+ status,
+ error,
+ };
+};
+
+export function unpin (status) {
+ return (dispatch, getState) => {
+ dispatch(unpinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+ dispatch(unpinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unpinFail(status, error));
+ });
+ };
+};
+
+export function unpinRequest(status) {
+ return {
+ type: UNPIN_REQUEST,
+ status,
+ };
+};
+
+export function unpinSuccess(status, response) {
+ return {
+ type: UNPIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function unpinFail(status, error) {
+ return {
+ type: UNPIN_FAIL,
+ status,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/local_settings.js b/app/javascript/themes/glitch/actions/local_settings.js
new file mode 100644
index 000000000..28660a4e8
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/local_settings.js
@@ -0,0 +1,24 @@
+export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
+
+export function changeLocalSetting(key, value) {
+ return dispatch => {
+ dispatch({
+ type: LOCAL_SETTING_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveLocalSettings());
+ };
+};
+
+// __TODO :__
+// Right now `saveLocalSettings()` doesn't keep track of which user
+// is currently signed in, but it might be better to give each user
+// their *own* local settings.
+export function saveLocalSettings() {
+ return (_, getState) => {
+ const localSettings = getState().get('local_settings').toJS();
+ localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/modal.js b/app/javascript/themes/glitch/actions/modal.js
new file mode 100644
index 000000000..80e15c28e
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+ return {
+ type: MODAL_OPEN,
+ modalType: type,
+ modalProps: props,
+ };
+};
+
+export function closeModal() {
+ return {
+ type: MODAL_CLOSE,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/mutes.js b/app/javascript/themes/glitch/actions/mutes.js
new file mode 100644
index 000000000..bb19e8657
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/mutes.js
@@ -0,0 +1,103 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+import { fetchRelationships } from './accounts';
+import { openModal } from 'themes/glitch/actions/modal';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+ return (dispatch, getState) => {
+ dispatch(fetchMutesRequest());
+
+ api(getState).get('/api/v1/mutes').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchMutesFail(error)));
+ };
+};
+
+export function fetchMutesRequest() {
+ return {
+ type: MUTES_FETCH_REQUEST,
+ };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchMutesFail(error) {
+ return {
+ type: MUTES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandMutes() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandMutesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandMutesFail(error)));
+ };
+};
+
+export function expandMutesRequest() {
+ return {
+ type: MUTES_EXPAND_REQUEST,
+ };
+};
+
+export function expandMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandMutesFail(error) {
+ return {
+ type: MUTES_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function initMuteModal(account) {
+ return dispatch => {
+ dispatch({
+ type: MUTES_INIT_MODAL,
+ account,
+ });
+
+ dispatch(openModal('MUTE'));
+ };
+}
+
+export function toggleHideNotifications() {
+ return dispatch => {
+ dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+ };
+}
diff --git a/app/javascript/themes/glitch/actions/notifications.js b/app/javascript/themes/glitch/actions/notifications.js
new file mode 100644
index 000000000..fbf06f7c4
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/notifications.js
@@ -0,0 +1,265 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+import { List as ImmutableList } from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchRelationships } from './accounts';
+import { defineMessages } from 'react-intl';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+// tracking the notif cleaning request
+export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
+export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
+export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
+export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
+export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
+// Unmark notifications (when the cleaning mode is left)
+export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
+// Mark one for delete
+export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+defineMessages({
+ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+ const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+ if (accountIds > 0) {
+ dispatch(fetchRelationships(accountIds));
+ }
+};
+
+const unescapeHTML = (html) => {
+ const wrapper = document.createElement('div');
+ html = html.replace(/ | |\n/, ' ');
+ wrapper.innerHTML = html;
+ return wrapper.textContent;
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+ return (dispatch, getState) => {
+ const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+ const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+ dispatch({
+ type: NOTIFICATIONS_UPDATE,
+ notification,
+ account: notification.account,
+ status: notification.status,
+ meta: playSound ? { sound: 'boop' } : undefined,
+ });
+
+ fetchRelatedRelationships(dispatch, [notification]);
+
+ // Desktop notifications
+ if (typeof window.Notification !== 'undefined' && showAlert) {
+ const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+ const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+ const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+ notify.addEventListener('click', () => {
+ window.focus();
+ notify.close();
+ });
+ }
+ };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+ return (dispatch, getState) => {
+ const params = {};
+ const ids = getState().getIn(['notifications', 'items']);
+
+ let skipLoading = false;
+
+ if (ids.size > 0) {
+ params.since_id = ids.first().get('id');
+ }
+
+ if (getState().getIn(['notifications', 'loaded'])) {
+ skipLoading = true;
+ }
+
+ params.exclude_types = excludeTypesFromSettings(getState());
+
+ dispatch(refreshNotificationsRequest(skipLoading));
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(refreshNotificationsFail(error, skipLoading));
+ });
+ };
+};
+
+export function refreshNotificationsRequest(skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_REQUEST,
+ skipLoading,
+ };
+};
+
+export function refreshNotificationsSuccess(notifications, skipLoading, next) {
+ return {
+ type: NOTIFICATIONS_REFRESH_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ skipLoading,
+ next,
+ };
+};
+
+export function refreshNotificationsFail(error, skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_FAIL,
+ error,
+ skipLoading,
+ };
+};
+
+export function expandNotifications() {
+ return (dispatch, getState) => {
+ const items = getState().getIn(['notifications', 'items'], ImmutableList());
+
+ if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+ return;
+ }
+
+ const params = {
+ max_id: items.last().get('id'),
+ limit: 20,
+ exclude_types: excludeTypesFromSettings(getState()),
+ };
+
+ dispatch(expandNotificationsRequest());
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(expandNotificationsFail(error));
+ });
+ };
+};
+
+export function expandNotificationsRequest() {
+ return {
+ type: NOTIFICATIONS_EXPAND_REQUEST,
+ };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+ return {
+ type: NOTIFICATIONS_EXPAND_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ next,
+ };
+};
+
+export function expandNotificationsFail(error) {
+ return {
+ type: NOTIFICATIONS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function clearNotifications() {
+ return (dispatch, getState) => {
+ dispatch({
+ type: NOTIFICATIONS_CLEAR,
+ });
+
+ api(getState).post('/api/v1/notifications/clear');
+ };
+};
+
+export function scrollTopNotifications(top) {
+ return {
+ type: NOTIFICATIONS_SCROLL_TOP,
+ top,
+ };
+};
+
+export function deleteMarkedNotifications() {
+ return (dispatch, getState) => {
+ dispatch(deleteMarkedNotificationsRequest());
+
+ let ids = [];
+ getState().getIn(['notifications', 'items']).forEach((n) => {
+ if (n.get('markedForDelete')) {
+ ids.push(n.get('id'));
+ }
+ });
+
+ if (ids.length === 0) {
+ return;
+ }
+
+ api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
+ dispatch(deleteMarkedNotificationsSuccess());
+ }).catch(error => {
+ console.error(error);
+ dispatch(deleteMarkedNotificationsFail(error));
+ });
+ };
+};
+
+export function enterNotificationClearingMode(yes) {
+ return {
+ type: NOTIFICATIONS_ENTER_CLEARING_MODE,
+ yes: yes,
+ };
+};
+
+export function markAllNotifications(yes) {
+ return {
+ type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+ yes: yes, // true, false or null. null = invert
+ };
+};
+
+export function deleteMarkedNotificationsRequest() {
+ return {
+ type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
+ };
+};
+
+export function deleteMarkedNotificationsFail() {
+ return {
+ type: NOTIFICATIONS_DELETE_MARKED_FAIL,
+ };
+};
+
+export function markNotificationForDelete(id, yes) {
+ return {
+ type: NOTIFICATION_MARK_FOR_DELETE,
+ id: id,
+ yes: yes,
+ };
+};
+
+export function deleteMarkedNotificationsSuccess() {
+ return {
+ type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/onboarding.js b/app/javascript/themes/glitch/actions/onboarding.js
new file mode 100644
index 000000000..a161c50ef
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/onboarding.js
@@ -0,0 +1,14 @@
+import { openModal } from './modal';
+import { changeSetting, saveSettings } from './settings';
+
+export function showOnboardingOnce() {
+ return (dispatch, getState) => {
+ const alreadySeen = getState().getIn(['settings', 'onboarded']);
+
+ if (!alreadySeen) {
+ dispatch(openModal('ONBOARDING'));
+ dispatch(changeSetting(['onboarded'], true));
+ dispatch(saveSettings());
+ }
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/pin_statuses.js b/app/javascript/themes/glitch/actions/pin_statuses.js
new file mode 100644
index 000000000..b3e064e58
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/pin_statuses.js
@@ -0,0 +1,40 @@
+import api from 'themes/glitch/util/api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+import { me } from 'themes/glitch/util/initial_state';
+
+export function fetchPinnedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchPinnedStatusesRequest());
+
+ api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+ dispatch(fetchPinnedStatusesSuccess(response.data, null));
+ }).catch(error => {
+ dispatch(fetchPinnedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchPinnedStatusesRequest() {
+ return {
+ type: PINNED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+ return {
+ type: PINNED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchPinnedStatusesFail(error) {
+ return {
+ type: PINNED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/push_notifications.js b/app/javascript/themes/glitch/actions/push_notifications.js
new file mode 100644
index 000000000..55661d2b0
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/push_notifications.js
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
+
+export function setBrowserSupport (value) {
+ return {
+ type: SET_BROWSER_SUPPORT,
+ value,
+ };
+}
+
+export function setSubscription (subscription) {
+ return {
+ type: SET_SUBSCRIPTION,
+ subscription,
+ };
+}
+
+export function clearSubscription () {
+ return {
+ type: CLEAR_SUBSCRIPTION,
+ };
+}
+
+export function changeAlerts(key, value) {
+ return dispatch => {
+ dispatch({
+ type: ALERTS_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+}
+
+export function saveSettings() {
+ return (_, getState) => {
+ const state = getState().get('push_notifications');
+ const subscription = state.get('subscription');
+ const alerts = state.get('alerts');
+
+ axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+ data: {
+ alerts,
+ },
+ });
+ };
+}
diff --git a/app/javascript/themes/glitch/actions/reports.js b/app/javascript/themes/glitch/actions/reports.js
new file mode 100644
index 000000000..93f9085b2
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/reports.js
@@ -0,0 +1,80 @@
+import api from 'themes/glitch/util/api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+
+export function initReport(account, status) {
+ return dispatch => {
+ dispatch({
+ type: REPORT_INIT,
+ account,
+ status,
+ });
+
+ dispatch(openModal('REPORT'));
+ };
+};
+
+export function cancelReport() {
+ return {
+ type: REPORT_CANCEL,
+ };
+};
+
+export function toggleStatusReport(statusId, checked) {
+ return {
+ type: REPORT_STATUS_TOGGLE,
+ statusId,
+ checked,
+ };
+};
+
+export function submitReport() {
+ return (dispatch, getState) => {
+ dispatch(submitReportRequest());
+
+ api(getState).post('/api/v1/reports', {
+ account_id: getState().getIn(['reports', 'new', 'account_id']),
+ status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+ comment: getState().getIn(['reports', 'new', 'comment']),
+ }).then(response => {
+ dispatch(closeModal());
+ dispatch(submitReportSuccess(response.data));
+ }).catch(error => dispatch(submitReportFail(error)));
+ };
+};
+
+export function submitReportRequest() {
+ return {
+ type: REPORT_SUBMIT_REQUEST,
+ };
+};
+
+export function submitReportSuccess(report) {
+ return {
+ type: REPORT_SUBMIT_SUCCESS,
+ report,
+ };
+};
+
+export function submitReportFail(error) {
+ return {
+ type: REPORT_SUBMIT_FAIL,
+ error,
+ };
+};
+
+export function changeReportComment(comment) {
+ return {
+ type: REPORT_COMMENT_CHANGE,
+ comment,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/search.js b/app/javascript/themes/glitch/actions/search.js
new file mode 100644
index 000000000..414e4755e
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/search.js
@@ -0,0 +1,73 @@
+import api from 'themes/glitch/util/api';
+
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR = 'SEARCH_CLEAR';
+export const SEARCH_SHOW = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
+
+export function changeSearch(value) {
+ return {
+ type: SEARCH_CHANGE,
+ value,
+ };
+};
+
+export function clearSearch() {
+ return {
+ type: SEARCH_CLEAR,
+ };
+};
+
+export function submitSearch() {
+ return (dispatch, getState) => {
+ const value = getState().getIn(['search', 'value']);
+
+ if (value.length === 0) {
+ return;
+ }
+
+ dispatch(fetchSearchRequest());
+
+ api(getState).get('/api/v1/search', {
+ params: {
+ q: value,
+ resolve: true,
+ },
+ }).then(response => {
+ dispatch(fetchSearchSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchSearchFail(error));
+ });
+ };
+};
+
+export function fetchSearchRequest() {
+ return {
+ type: SEARCH_FETCH_REQUEST,
+ };
+};
+
+export function fetchSearchSuccess(results) {
+ return {
+ type: SEARCH_FETCH_SUCCESS,
+ results,
+ accounts: results.accounts,
+ statuses: results.statuses,
+ };
+};
+
+export function fetchSearchFail(error) {
+ return {
+ type: SEARCH_FETCH_FAIL,
+ error,
+ };
+};
+
+export function showSearch() {
+ return {
+ type: SEARCH_SHOW,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/settings.js b/app/javascript/themes/glitch/actions/settings.js
new file mode 100644
index 000000000..79adca18c
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/settings.js
@@ -0,0 +1,31 @@
+import axios from 'axios';
+import { debounce } from 'lodash';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE = 'SETTING_SAVE';
+
+export function changeSetting(key, value) {
+ return dispatch => {
+ dispatch({
+ type: SETTING_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+ if (getState().getIn(['settings', 'saved'])) {
+ return;
+ }
+
+ const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+
+ axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+ return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/javascript/themes/glitch/actions/statuses.js b/app/javascript/themes/glitch/actions/statuses.js
new file mode 100644
index 000000000..702f4e9b6
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/statuses.js
@@ -0,0 +1,217 @@
+import api from 'themes/glitch/util/api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+ return {
+ type: STATUS_FETCH_REQUEST,
+ id,
+ skipLoading,
+ };
+};
+
+export function fetchStatus(id) {
+ return (dispatch, getState) => {
+ const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+ dispatch(fetchContext(id));
+ dispatch(fetchStatusCard(id));
+
+ if (skipLoading) {
+ return;
+ }
+
+ dispatch(fetchStatusRequest(id, skipLoading));
+
+ api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+ dispatch(fetchStatusSuccess(response.data, skipLoading));
+ }).catch(error => {
+ dispatch(fetchStatusFail(id, error, skipLoading));
+ });
+ };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+ return {
+ type: STATUS_FETCH_SUCCESS,
+ status,
+ skipLoading,
+ };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+ return {
+ type: STATUS_FETCH_FAIL,
+ id,
+ error,
+ skipLoading,
+ skipAlert: true,
+ };
+};
+
+export function deleteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(deleteStatusRequest(id));
+
+ api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+ dispatch(deleteStatusSuccess(id));
+ dispatch(deleteFromTimelines(id));
+ }).catch(error => {
+ dispatch(deleteStatusFail(id, error));
+ });
+ };
+};
+
+export function deleteStatusRequest(id) {
+ return {
+ type: STATUS_DELETE_REQUEST,
+ id: id,
+ };
+};
+
+export function deleteStatusSuccess(id) {
+ return {
+ type: STATUS_DELETE_SUCCESS,
+ id: id,
+ };
+};
+
+export function deleteStatusFail(id, error) {
+ return {
+ type: STATUS_DELETE_FAIL,
+ id: id,
+ error: error,
+ };
+};
+
+export function fetchContext(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchContextRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+ dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+ }).catch(error => {
+ if (error.response && error.response.status === 404) {
+ dispatch(deleteFromTimelines(id));
+ }
+
+ dispatch(fetchContextFail(id, error));
+ });
+ };
+};
+
+export function fetchContextRequest(id) {
+ return {
+ type: CONTEXT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+ return {
+ type: CONTEXT_FETCH_SUCCESS,
+ id,
+ ancestors,
+ descendants,
+ statuses: ancestors.concat(descendants),
+ };
+};
+
+export function fetchContextFail(id, error) {
+ return {
+ type: CONTEXT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function muteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(muteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+ dispatch(muteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(muteStatusFail(id, error));
+ });
+ };
+};
+
+export function muteStatusRequest(id) {
+ return {
+ type: STATUS_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteStatusSuccess(id) {
+ return {
+ type: STATUS_MUTE_SUCCESS,
+ id,
+ };
+};
+
+export function muteStatusFail(id, error) {
+ return {
+ type: STATUS_MUTE_FAIL,
+ id,
+ error,
+ };
+};
+
+export function unmuteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+ dispatch(unmuteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(unmuteStatusFail(id, error));
+ });
+ };
+};
+
+export function unmuteStatusRequest(id) {
+ return {
+ type: STATUS_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteStatusSuccess(id) {
+ return {
+ type: STATUS_UNMUTE_SUCCESS,
+ id,
+ };
+};
+
+export function unmuteStatusFail(id, error) {
+ return {
+ type: STATUS_UNMUTE_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/store.js b/app/javascript/themes/glitch/actions/store.js
new file mode 100644
index 000000000..a1db0fdd5
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/store.js
@@ -0,0 +1,17 @@
+import { Iterable, fromJS } from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+ fromJS(rawState, (k, v) =>
+ Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+ const state = convertState(rawState);
+
+ return {
+ type: STORE_HYDRATE,
+ state,
+ };
+};
diff --git a/app/javascript/themes/glitch/actions/streaming.js b/app/javascript/themes/glitch/actions/streaming.js
new file mode 100644
index 000000000..ccf6c27d8
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/streaming.js
@@ -0,0 +1,54 @@
+import { connectStream } from 'themes/glitch/util/stream';
+import {
+ updateTimeline,
+ deleteFromTimelines,
+ refreshHomeTimeline,
+ connectTimeline,
+ disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from 'mastodon/locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+
+ return connectStream (path, pollingRefresh, (dispatch, getState) => {
+ const locale = getState().getIn(['meta', 'locale']);
+ return {
+ onConnect() {
+ dispatch(connectTimeline(timelineId));
+ },
+
+ onDisconnect() {
+ dispatch(disconnectTimeline(timelineId));
+ },
+
+ onReceive (data) {
+ switch(data.event) {
+ case 'update':
+ dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+ break;
+ case 'delete':
+ dispatch(deleteFromTimelines(data.payload));
+ break;
+ case 'notification':
+ dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+ break;
+ }
+ },
+ };
+ });
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+ dispatch(refreshHomeTimeline());
+ dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
diff --git a/app/javascript/themes/glitch/actions/timelines.js b/app/javascript/themes/glitch/actions/timelines.js
new file mode 100644
index 000000000..5ce14fbe9
--- /dev/null
+++ b/app/javascript/themes/glitch/actions/timelines.js
@@ -0,0 +1,208 @@
+import api, { getLinks } from 'themes/glitch/util/api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
+
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
+ return {
+ type: TIMELINE_REFRESH_SUCCESS,
+ timeline,
+ statuses,
+ skipLoading,
+ next,
+ };
+};
+
+export function updateTimeline(timeline, status) {
+ return (dispatch, getState) => {
+ const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+ const parents = [];
+
+ if (status.in_reply_to_id) {
+ let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+ while (parent && parent.get('in_reply_to_id')) {
+ parents.push(parent.get('id'));
+ parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+ }
+ }
+
+ dispatch({
+ type: TIMELINE_UPDATE,
+ timeline,
+ status,
+ references,
+ });
+
+ if (parents.length > 0) {
+ dispatch({
+ type: TIMELINE_CONTEXT_UPDATE,
+ status,
+ references: parents,
+ });
+ }
+ };
+};
+
+export function deleteFromTimelines(id) {
+ return (dispatch, getState) => {
+ const accountId = getState().getIn(['statuses', id, 'account']);
+ const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+ const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
+
+ dispatch({
+ type: TIMELINE_DELETE,
+ id,
+ accountId,
+ references,
+ reblogOf,
+ });
+ };
+};
+
+export function refreshTimelineRequest(timeline, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_REQUEST,
+ timeline,
+ skipLoading,
+ };
+};
+
+export function refreshTimeline(timelineId, path, params = {}) {
+ return function (dispatch, getState) {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+
+ if (timeline.get('isLoading') || timeline.get('online')) {
+ return;
+ }
+
+ const ids = timeline.get('items', ImmutableList());
+ const newestId = ids.size > 0 ? ids.first() : null;
+
+ let skipLoading = timeline.get('loaded');
+
+ if (newestId !== null) {
+ params.since_id = newestId;
+ }
+
+ dispatch(refreshTimelineRequest(timelineId, skipLoading));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(refreshTimelineFail(timelineId, error, skipLoading));
+ });
+ };
+};
+
+export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
+export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
+export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct');
+export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_FAIL,
+ timeline,
+ error,
+ skipLoading,
+ skipAlert: error.response && error.response.status === 404,
+ };
+};
+
+export function expandTimeline(timelineId, path, params = {}) {
+ return (dispatch, getState) => {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+ const ids = timeline.get('items', ImmutableList());
+
+ if (timeline.get('isLoading') || ids.size === 0) {
+ return;
+ }
+
+ params.max_id = ids.last();
+ params.limit = 10;
+
+ dispatch(expandTimelineRequest(timelineId));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandTimelineFail(timelineId, error));
+ });
+ };
+};
+
+export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
+export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
+export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct');
+export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function expandTimelineRequest(timeline) {
+ return {
+ type: TIMELINE_EXPAND_REQUEST,
+ timeline,
+ };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+ return {
+ type: TIMELINE_EXPAND_SUCCESS,
+ timeline,
+ statuses,
+ next,
+ };
+};
+
+export function expandTimelineFail(timeline, error) {
+ return {
+ type: TIMELINE_EXPAND_FAIL,
+ timeline,
+ error,
+ };
+};
+
+export function scrollTopTimeline(timeline, top) {
+ return {
+ type: TIMELINE_SCROLL_TOP,
+ timeline,
+ top,
+ };
+};
+
+export function connectTimeline(timeline) {
+ return {
+ type: TIMELINE_CONNECT,
+ timeline,
+ };
+};
+
+export function disconnectTimeline(timeline) {
+ return {
+ type: TIMELINE_DISCONNECT,
+ timeline,
+ };
+};
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
new file mode 100644
index 000000000..4005c860f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Autoplay renders a animated avatar 1`] = `
+
+`;
+
+exports[` Still renders a still avatar 1`] = `
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
new file mode 100644
index 000000000..d9e5e5252
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
+
+
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
new file mode 100644
index 000000000..707cbf673
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
@@ -0,0 +1,130 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` adds class "button-secondary" if props.secondary given 1`] = `
+
+`;
+
+exports[` renders a button element 1`] = `
+
+`;
+
+exports[` renders a disabled attribute if props.disabled given 1`] = `
+
+`;
+
+exports[` renders class="button--block" if props.block given 1`] = `
+
+`;
+
+exports[` renders the children 1`] = `
+
+
+ children
+
+
+`;
+
+exports[` renders the given text 1`] = `
+
+ foo
+
+`;
+
+exports[` renders the props.text instead of children 1`] = `
+
+ foo
+
+`;
+
+exports[` renders title if props.title is given 1`] = `
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
new file mode 100644
index 000000000..533359ffe
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders display name + account name 1`] = `
+
+ Foo",
+ }
+ }
+ />
+
+
+ @
+ bar@baz
+
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar-test.js b/app/javascript/themes/glitch/components/__tests__/avatar-test.js
new file mode 100644
index 000000000..dd3f7b7d2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/avatar-test.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import Avatar from '../avatar';
+
+describe(' ', () => {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const size = 100;
+
+ describe('Autoplay', () => {
+ it('renders a animated avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ describe('Still', () => {
+ it('renders a still avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ // TODO add autoplay test if possible
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js b/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
new file mode 100644
index 000000000..44addea83
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import AvatarOverlay from '../avatar_overlay';
+
+describe(' {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const friend = fromJS({
+ username: 'eve',
+ acct: 'eve@blackhat.lair',
+ display_name: 'Evelyn',
+ avatar: '/animated/eve.gif',
+ avatar_static: '/static/eve.jpg',
+ });
+
+ it('renders a overlay avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/button-test.js b/app/javascript/themes/glitch/components/__tests__/button-test.js
new file mode 100644
index 000000000..924ba39dc
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/button-test.js
@@ -0,0 +1,82 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Button from '../button';
+
+describe(' ', () => {
+ it('renders a button element', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the given text', () => {
+ const text = 'foo';
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('handles click events using the given handler', () => {
+ const handler = jest.fn();
+ const button = shallow( );
+ button.find('button').simulate('click');
+
+ expect(handler.mock.calls.length).toEqual(1);
+ });
+
+ it('does not handle click events if props.disabled given', () => {
+ const handler = jest.fn();
+ const button = shallow( );
+ button.find('button').simulate('click');
+
+ expect(handler.mock.calls.length).toEqual(0);
+ });
+
+ it('renders a disabled attribute if props.disabled given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the children', () => {
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the props.text instead of children', () => {
+ const text = 'foo';
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders class="button--block" if props.block given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('adds class "button-secondary" if props.secondary given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders title if props.title is given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/display_name-test.js b/app/javascript/themes/glitch/components/__tests__/display_name-test.js
new file mode 100644
index 000000000..0d040c4cd
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/display_name-test.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import DisplayName from '../display_name';
+
+describe(' ', () => {
+ it('renders display name + account name', () => {
+ const account = fromJS({
+ username: 'bar',
+ acct: 'bar@baz',
+ display_name_html: 'Foo
',
+ });
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/account.js b/app/javascript/themes/glitch/components/account.js
new file mode 100644
index 000000000..d0ff77050
--- /dev/null
+++ b/app/javascript/themes/glitch/components/account.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
+ unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
+});
+
+@injectIntl
+export default class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hidden: PropTypes.bool,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ handleMuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, true);
+ }
+
+ handleUnmuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, false);
+ }
+
+ render () {
+ const { account, intl, hidden } = this.props;
+
+ if (!account) {
+ return
;
+ }
+
+ if (hidden) {
+ return (
+
+ {account.get('display_name')}
+ {account.get('username')}
+
+ );
+ }
+
+ let buttons;
+
+ if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ const following = account.getIn(['relationship', 'following']);
+ const requested = account.getIn(['relationship', 'requested']);
+ const blocking = account.getIn(['relationship', 'blocking']);
+ const muting = account.getIn(['relationship', 'muting']);
+
+ if (requested) {
+ buttons = ;
+ } else if (blocking) {
+ buttons = ;
+ } else if (muting) {
+ let hidingNotificationsButton;
+ if (muting.get('notifications')) {
+ hidingNotificationsButton = ;
+ } else {
+ hidingNotificationsButton = ;
+ }
+ buttons = (
+
+
+ {hidingNotificationsButton}
+
+ );
+ } else {
+ buttons = ;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {buttons}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/attachment_list.js b/app/javascript/themes/glitch/components/attachment_list.js
new file mode 100644
index 000000000..b3d00b335
--- /dev/null
+++ b/app/javascript/themes/glitch/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { media } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/autosuggest_emoji.js b/app/javascript/themes/glitch/components/autosuggest_emoji.js
new file mode 100644
index 000000000..3c6f915e4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/autosuggest_emoji.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from 'themes/glitch/util/emoji/emoji_unicode_mapping_light';
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { emoji } = this.props;
+ let url;
+
+ if (emoji.custom) {
+ url = emoji.imageUrl;
+ } else {
+ const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+ if (!mapping) {
+ return null;
+ }
+
+ url = `${assetHost}/emoji/${mapping.filename}.svg`;
+ }
+
+ return (
+
+
+
+ {emoji.colons}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/autosuggest_textarea.js b/app/javascript/themes/glitch/components/autosuggest_textarea.js
new file mode 100644
index 000000000..fa93847a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/autosuggest_textarea.js
@@ -0,0 +1,222 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'themes/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'themes/glitch/util/rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+ let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
+ return [null, null];
+ }
+
+ word = word.trim().toLowerCase();
+
+ if (word.length > 0) {
+ return [left + 1, word];
+ } else {
+ return [null, null];
+ }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ autoFocus: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ };
+
+ state = {
+ suggestionsHidden: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+ if (token !== null && this.state.lastToken !== token) {
+ this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+ this.props.onSuggestionsFetchRequested(token);
+ } else if (token === null) {
+ this.setState({ lastToken: null });
+ this.props.onSuggestionsClearRequested();
+ }
+
+ this.props.onChange(e);
+ }
+
+ onKeyDown = (e) => {
+ const { suggestions, disabled } = this.props;
+ const { selectedSuggestion, suggestionsHidden } = this.state;
+
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (!suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ suggestionsHidden: true });
+ }
+
+ break;
+ case 'ArrowDown':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+
+ break;
+ case 'ArrowUp':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+
+ break;
+ case 'Enter':
+ case 'Tab':
+ // Select suggestion
+ if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ }
+
+ break;
+ }
+
+ if (e.defaultPrevented || !this.props.onKeyDown) {
+ return;
+ }
+
+ this.props.onKeyDown(e);
+ }
+
+ onKeyUp = e => {
+ if (e.key === 'Escape' && this.state.suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ }
+
+ if (this.props.onKeyUp) {
+ this.props.onKeyUp(e);
+ }
+ }
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true });
+ }
+
+ onSuggestionClick = (e) => {
+ const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+ this.textarea.focus();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setTextarea = (c) => {
+ this.textarea = c;
+ }
+
+ onPaste = (e) => {
+ if (e.clipboardData && e.clipboardData.files.length === 1) {
+ this.props.onPaste(e.clipboardData.files);
+ e.preventDefault();
+ }
+ }
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (typeof suggestion === 'object') {
+ inner = ;
+ key = suggestion.id;
+ } else {
+ inner = ;
+ key = suggestion;
+ }
+
+ return (
+
+ {inner}
+
+ );
+ }
+
+ render () {
+ const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
+ const { suggestionsHidden } = this.state;
+ const style = { direction: 'ltr' };
+
+ if (isRtl(value)) {
+ style.direction = 'rtl';
+ }
+
+ return (
+
+
+ {placeholder}
+
+
+
+
+
+ {suggestions.map(this.renderSuggestion)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/avatar.js b/app/javascript/themes/glitch/components/avatar.js
new file mode 100644
index 000000000..dd155f059
--- /dev/null
+++ b/app/javascript/themes/glitch/components/avatar.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class Avatar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ size: PropTypes.number.isRequired,
+ style: PropTypes.object,
+ animate: PropTypes.bool,
+ inline: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ animate: false,
+ size: 20,
+ inline: false,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: true });
+ }
+
+ handleMouseLeave = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: false });
+ }
+
+ render () {
+ const { account, size, animate, inline } = this.props;
+ const { hovering } = this.state;
+
+ const src = account.get('avatar');
+ const staticSrc = account.get('avatar_static');
+
+ let className = 'account__avatar';
+
+ if (inline) {
+ className = className + ' account__avatar-inline';
+ }
+
+ const style = {
+ ...this.props.style,
+ width: `${size}px`,
+ height: `${size}px`,
+ backgroundSize: `${size}px ${size}px`,
+ };
+
+ if (hovering || animate) {
+ style.backgroundImage = `url(${src})`;
+ } else {
+ style.backgroundImage = `url(${staticSrc})`;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/avatar_overlay.js b/app/javascript/themes/glitch/components/avatar_overlay.js
new file mode 100644
index 000000000..2ecf9fa44
--- /dev/null
+++ b/app/javascript/themes/glitch/components/avatar_overlay.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class AvatarOverlay extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map.isRequired,
+ };
+
+ render() {
+ const { account, friend } = this.props;
+
+ const baseStyle = {
+ backgroundImage: `url(${account.get('avatar_static')})`,
+ };
+
+ const overlayStyle = {
+ backgroundImage: `url(${friend.get('avatar_static')})`,
+ };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/button.js b/app/javascript/themes/glitch/components/button.js
new file mode 100644
index 000000000..16868010c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/button.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Button extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.node,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ block: PropTypes.bool,
+ secondary: PropTypes.bool,
+ size: PropTypes.number,
+ className: PropTypes.string,
+ style: PropTypes.object,
+ children: PropTypes.node,
+ title: PropTypes.string,
+ };
+
+ static defaultProps = {
+ size: 36,
+ };
+
+ handleClick = (e) => {
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ focus() {
+ this.node.focus();
+ }
+
+ render () {
+ let attrs = {
+ className: classNames('button', this.props.className, {
+ 'button-secondary': this.props.secondary,
+ 'button--block': this.props.block,
+ }),
+ disabled: this.props.disabled,
+ onClick: this.handleClick,
+ ref: this.setRef,
+ style: {
+ padding: `0 ${this.props.size / 2.25}px`,
+ height: `${this.props.size}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ },
+ };
+
+ if (this.props.title) attrs.title = this.props.title;
+
+ return (
+
+ {this.props.text || this.props.children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/collapsable.js b/app/javascript/themes/glitch/components/collapsable.js
new file mode 100644
index 000000000..8bc0a54f4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/collapsable.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+
+ {({ opacity, height }) =>
+
+ {children}
+
+ }
+
+);
+
+Collapsable.propTypes = {
+ fullHeight: PropTypes.number.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+export default Collapsable;
diff --git a/app/javascript/themes/glitch/components/column.js b/app/javascript/themes/glitch/components/column.js
new file mode 100644
index 000000000..adeba9cc1
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollTop } from 'themes/glitch/util/scroll';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node,
+ extraClasses: PropTypes.string,
+ name: PropTypes.string,
+ };
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidMount () {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+
+ componentWillUnmount () {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+
+ render () {
+ const { children, extraClasses, name } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_back_button.js b/app/javascript/themes/glitch/components/column_back_button.js
new file mode 100644
index 000000000..50c3bf11f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_back_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_back_button_slim.js b/app/javascript/themes/glitch/components/column_back_button_slim.js
new file mode 100644
index 000000000..2cdf1b25b
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_back_button_slim.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButtonSlim extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_header.js b/app/javascript/themes/glitch/components/column_header.js
new file mode 100644
index 000000000..e601082c8
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_header.js
@@ -0,0 +1,214 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+// Glitch imports
+import NotificationPurgeButtonsContainer from 'themes/glitch/containers/notification_purge_buttons_container';
+
+const messages = defineMessages({
+ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+ hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+ moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+ moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
+ enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+});
+
+@injectIntl
+export default class ColumnHeader extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ title: PropTypes.node.isRequired,
+ icon: PropTypes.string.isRequired,
+ active: PropTypes.bool,
+ localSettings : ImmutablePropTypes.map,
+ multiColumn: PropTypes.bool,
+ focusable: PropTypes.bool,
+ showBackButton: PropTypes.bool,
+ notifCleaning: PropTypes.bool, // true only for the notification column
+ notifCleaningActive: PropTypes.bool,
+ onEnterCleaningMode: PropTypes.func,
+ children: PropTypes.node,
+ pinned: PropTypes.bool,
+ onPin: PropTypes.func,
+ onMove: PropTypes.func,
+ onClick: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ focusable: true,
+ }
+
+ state = {
+ collapsed: true,
+ animating: false,
+ animatingNCD: false,
+ };
+
+ handleToggleClick = (e) => {
+ e.stopPropagation();
+ this.setState({ collapsed: !this.state.collapsed, animating: true });
+ }
+
+ handleTitleClick = () => {
+ this.props.onClick();
+ }
+
+ handleMoveLeft = () => {
+ this.props.onMove(-1);
+ }
+
+ handleMoveRight = () => {
+ this.props.onMove(1);
+ }
+
+ handleBackClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ handleTransitionEnd = () => {
+ this.setState({ animating: false });
+ }
+
+ handleTransitionEndNCD = () => {
+ this.setState({ animatingNCD: false });
+ }
+
+ onEnterCleaningMode = () => {
+ this.setState({ animatingNCD: true });
+ this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
+ }
+
+ render () {
+ const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
+ const { collapsed, animating, animatingNCD } = this.state;
+
+ let title = this.props.title;
+
+ const wrapperClassName = classNames('column-header__wrapper', {
+ 'active': active,
+ });
+
+ const buttonClassName = classNames('column-header', {
+ 'active': active,
+ });
+
+ const collapsibleClassName = classNames('column-header__collapsible', {
+ 'collapsed': collapsed,
+ 'animating': animating,
+ });
+
+ const collapsibleButtonClassName = classNames('column-header__button', {
+ 'active': !collapsed,
+ });
+
+ const notifCleaningButtonClassName = classNames('column-header__button', {
+ 'active': notifCleaningActive,
+ });
+
+ const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
+ 'collapsed': !notifCleaningActive,
+ 'animating': animatingNCD,
+ });
+
+ let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+ //*glitch
+ const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
+
+ if (children) {
+ extraContent = (
+
+ {children}
+
+ );
+ }
+
+ if (multiColumn && pinned) {
+ pinButton = ;
+
+ moveButtons = (
+
+
+
+
+ );
+ } else if (multiColumn) {
+ pinButton = ;
+ }
+
+ if (!pinned && (multiColumn || showBackButton)) {
+ backButton = (
+
+
+
+
+ );
+ }
+
+ const collapsedContent = [
+ extraContent,
+ ];
+
+ if (multiColumn) {
+ collapsedContent.push(moveButtons);
+ collapsedContent.push(pinButton);
+ }
+
+ if (children || multiColumn) {
+ collapseButton = ;
+ }
+
+ return (
+
+
+
+
+ {title}
+
+
+ {backButton}
+ { notifCleaning ? (
+
+
+
+ ) : null}
+ {collapseButton}
+
+
+
+ { notifCleaning ? (
+
+
+ {(notifCleaningActive || animatingNCD) ? ( ) : null }
+
+
+ ) : null}
+
+
+
+ {(!collapsed || animating) && collapsedContent}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/display_name.js b/app/javascript/themes/glitch/components/display_name.js
new file mode 100644
index 000000000..2cf84f8f4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/display_name.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class DisplayName extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+
+ return (
+
+ @{this.props.account.get('acct')}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/dropdown_menu.js b/app/javascript/themes/glitch/components/dropdown_menu.js
new file mode 100644
index 000000000..d30dc2aaf
--- /dev/null
+++ b/app/javascript/themes/glitch/components/dropdown_menu.js
@@ -0,0 +1,211 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from './icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class DropdownMenu extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ items: PropTypes.array.isRequired,
+ onClose: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ };
+
+ static defaultProps = {
+ style: {},
+ placement: 'bottom',
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ handleClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.props.onClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ renderItem (option, i) {
+ if (option === null) {
+ return ;
+ }
+
+ const { text, href = '#' } = option;
+
+ return (
+
+
+ {text}
+
+
+ );
+ }
+
+ render () {
+ const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
+
+
+ {items.map((option, i) => this.renderItem(option, i))}
+
+
+ )}
+
+ );
+ }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ icon: PropTypes.string.isRequired,
+ items: PropTypes.array.isRequired,
+ size: PropTypes.number.isRequired,
+ ariaLabel: PropTypes.string,
+ disabled: PropTypes.bool,
+ status: ImmutablePropTypes.map,
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ };
+
+ static defaultProps = {
+ ariaLabel: 'Menu',
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ handleClick = () => {
+ if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
+ const { status, items } = this.props;
+
+ this.props.onModalOpen({
+ status,
+ actions: items,
+ onClick: this.handleItemClick,
+ });
+
+ return;
+ }
+
+ this.setState({ expanded: !this.state.expanded });
+ }
+
+ handleClose = () => {
+ if (this.props.onModalClose) {
+ this.props.onModalClose();
+ }
+
+ this.setState({ expanded: false });
+ }
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Enter':
+ this.handleClick();
+ break;
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ }
+
+ handleItemClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.handleClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { icon, items, size, ariaLabel, disabled } = this.props;
+ const { expanded } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/extended_video_player.js b/app/javascript/themes/glitch/components/extended_video_player.js
new file mode 100644
index 000000000..f8bd067e8
--- /dev/null
+++ b/app/javascript/themes/glitch/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ExtendedVideoPlayer extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ time: PropTypes.number,
+ controls: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+ };
+
+ handleLoadedData = () => {
+ if (this.props.time) {
+ this.video.currentTime = this.props.time;
+ }
+ }
+
+ componentDidMount () {
+ this.video.addEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ componentWillUnmount () {
+ this.video.removeEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ setRef = (c) => {
+ this.video = c;
+ }
+
+ render () {
+ const { src, muted, controls, alt } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/icon_button.js b/app/javascript/themes/glitch/components/icon_button.js
new file mode 100644
index 000000000..31cdf4703
--- /dev/null
+++ b/app/javascript/themes/glitch/components/icon_button.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class IconButton extends React.PureComponent {
+
+ static propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ size: PropTypes.number,
+ active: PropTypes.bool,
+ pressed: PropTypes.bool,
+ expanded: PropTypes.bool,
+ style: PropTypes.object,
+ activeStyle: PropTypes.object,
+ disabled: PropTypes.bool,
+ inverted: PropTypes.bool,
+ animate: PropTypes.bool,
+ flip: PropTypes.bool,
+ overlay: PropTypes.bool,
+ tabIndex: PropTypes.string,
+ label: PropTypes.string,
+ };
+
+ static defaultProps = {
+ size: 18,
+ active: false,
+ disabled: false,
+ animate: false,
+ overlay: false,
+ tabIndex: '0',
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ render () {
+ let style = {
+ fontSize: `${this.props.size}px`,
+ height: `${this.props.size * 1.28571429}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ ...(this.props.active ? this.props.activeStyle : {}),
+ };
+ if (!this.props.label) {
+ style.width = `${this.props.size * 1.28571429}px`;
+ } else {
+ style.textAlign = 'left';
+ }
+
+ const {
+ active,
+ animate,
+ className,
+ disabled,
+ expanded,
+ icon,
+ inverted,
+ flip,
+ overlay,
+ pressed,
+ tabIndex,
+ title,
+ } = this.props;
+
+ const classes = classNames(className, 'icon-button', {
+ active,
+ disabled,
+ inverted,
+ overlayed: overlay,
+ });
+
+ const flipDeg = flip ? -180 : -360;
+ const rotateDeg = active ? flipDeg : 0;
+
+ const motionDefaultStyle = {
+ rotate: rotateDeg,
+ };
+
+ const springOpts = {
+ stiffness: this.props.flip ? 60 : 120,
+ damping: 7,
+ };
+ const motionStyle = {
+ rotate: animate ? spring(rotateDeg, springOpts) : 0,
+ };
+
+ if (!animate) {
+ // Perf optimization: avoid unnecessary components unless
+ // we actually need to animate.
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {({ rotate }) =>
+
+
+ {this.props.label}
+
+ }
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/intersection_observer_article.js b/app/javascript/themes/glitch/components/intersection_observer_article.js
new file mode 100644
index 000000000..f0139ac75
--- /dev/null
+++ b/app/javascript/themes/glitch/components/intersection_observer_article.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from 'themes/glitch/util/schedule_idle_task';
+import getRectFromEntry from 'themes/glitch/util/get_rect_from_entry';
+import { is } from 'immutable';
+
+// Diff these props in the "rendered" state
+const updateOnPropsForRendered = ['id', 'index', 'listLength'];
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+ static propTypes = {
+ intersectionObserverWrapper: PropTypes.object.isRequired,
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ saveHeightKey: PropTypes.string,
+ cachedHeight: PropTypes.number,
+ onHeightChange: PropTypes.func,
+ children: PropTypes.node,
+ };
+
+ state = {
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+ const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+ if (!!isUnrendered !== !!willBeUnrendered) {
+ // If we're going from rendered to unrendered (or vice versa) then update
+ return true;
+ }
+ // Otherwise, diff based on props
+ const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
+ return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+ }
+
+ componentDidMount () {
+ const { intersectionObserverWrapper, id } = this.props;
+
+ intersectionObserverWrapper.observe(
+ id,
+ this.node,
+ this.handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ const { intersectionObserverWrapper, id } = this.props;
+ intersectionObserverWrapper.unobserve(id, this.node);
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ this.entry = entry;
+
+ scheduleIdleTask(this.calculateHeight);
+ this.setState(this.updateStateAfterIntersection);
+ }
+
+ updateStateAfterIntersection = (prevState) => {
+ if (prevState.isIntersecting && !this.entry.isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: this.entry.isIntersecting,
+ isHidden: false,
+ };
+ }
+
+ calculateHeight = () => {
+ const { onHeightChange, saveHeightKey, id } = this.props;
+ // save the height of the fully-rendered element (this is expensive
+ // on Chrome, where we need to fall back to getBoundingClientRect)
+ this.height = getRectFromEntry(this.entry).height;
+
+ if (onHeightChange && saveHeightKey) {
+ onHeightChange(saveHeightKey, id, this.height);
+ }
+ }
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/tootsuite/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ }
+
+ handleRef = (node) => {
+ this.node = node;
+ }
+
+ render () {
+ const { children, id, index, listLength, cachedHeight } = this.props;
+ const { isIntersecting, isHidden } = this.state;
+
+ if (!isIntersecting && (isHidden || cachedHeight)) {
+ return (
+
+ {children && React.cloneElement(children, { hidden: true })}
+
+ );
+ }
+
+ return (
+
+ {children && React.cloneElement(children, { hidden: false })}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/load_more.js b/app/javascript/themes/glitch/components/load_more.js
new file mode 100644
index 000000000..c4c8c94a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/load_more.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadMore extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func,
+ visible: PropTypes.bool,
+ }
+
+ static defaultProps = {
+ visible: true,
+ }
+
+ render() {
+ const { visible } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/loading_indicator.js b/app/javascript/themes/glitch/components/loading_indicator.js
new file mode 100644
index 000000000..d6a5adb6f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/loading_indicator.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/themes/glitch/components/media_gallery.js b/app/javascript/themes/glitch/components/media_gallery.js
new file mode 100644
index 000000000..05390c82f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/media_gallery.js
@@ -0,0 +1,256 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { is } from 'immutable';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from 'themes/glitch/util/is_mobile';
+import classNames from 'classnames';
+import { autoPlayGif } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+});
+
+class Item extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ standalone: PropTypes.bool,
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ letterbox: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ index: 0,
+ size: 1,
+ };
+
+ handleMouseEnter = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ }
+
+ handleMouseLeave = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ }
+
+ hoverToPlay () {
+ const { attachment } = this.props;
+ return !autoPlayGif && attachment.get('type') === 'gifv';
+ }
+
+ handleClick = (e) => {
+ const { index, onClick } = this.props;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ }
+
+ render () {
+ const { attachment, index, size, standalone, letterbox } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'image') {
+ const previewUrl = attachment.get('preview_url');
+ const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+ const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+ const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+ const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ const autoPlay = !isIOS() && autoPlayGif;
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ }
+
+ return (
+
+ {thumbnail}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class MediaGallery extends React.PureComponent {
+
+ static propTypes = {
+ sensitive: PropTypes.bool,
+ standalone: PropTypes.bool,
+ letterbox: PropTypes.bool,
+ fullwidth: PropTypes.bool,
+ media: ImmutablePropTypes.list.isRequired,
+ size: PropTypes.object,
+ onOpenMedia: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ };
+
+ state = {
+ visible: !this.props.sensitive,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.media, this.props.media)) {
+ this.setState({ visible: !nextProps.sensitive });
+ }
+ }
+
+ handleOpen = () => {
+ this.setState({ visible: !this.state.visible });
+ }
+
+ handleClick = (index) => {
+ this.props.onOpenMedia(this.props.media, index);
+ }
+
+ isStandaloneEligible() {
+ const { media, standalone } = this.props;
+ return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+ }
+
+ render () {
+ const { media, intl, sensitive, letterbox, fullwidth } = this.props;
+ const { visible } = this.state;
+
+ let children;
+
+ if (!visible) {
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ children = (
+
+ {warning}
+
+
+ );
+ } else {
+ const size = media.take(4).size;
+
+ if (this.isStandaloneEligible()) {
+ children = ;
+ } else {
+ children = media.take(4).map((attachment, i) => );
+ }
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/missing_indicator.js b/app/javascript/themes/glitch/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/themes/glitch/components/notification_purge_buttons.js b/app/javascript/themes/glitch/components/notification_purge_buttons.js
new file mode 100644
index 000000000..e0c1543b0
--- /dev/null
+++ b/app/javascript/themes/glitch/components/notification_purge_buttons.js
@@ -0,0 +1,58 @@
+/**
+ * Buttons widget for controlling the notification clearing mode.
+ * In idle state, the cleaning mode button is shown. When the mode is active,
+ * a Confirm and Abort buttons are shown in its place.
+ */
+
+
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
+ btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
+ btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
+ btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
+});
+
+@injectIntl
+export default class NotificationPurgeButtons extends ImmutablePureComponent {
+
+ static propTypes = {
+ onDeleteMarked : PropTypes.func.isRequired,
+ onMarkAll : PropTypes.func.isRequired,
+ onMarkNone : PropTypes.func.isRequired,
+ onInvert : PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ markNewForDelete: PropTypes.bool,
+ };
+
+ render () {
+ const { intl, markNewForDelete } = this.props;
+
+ //className='active'
+ return (
+
+
+ ∀ {intl.formatMessage(messages.btnAll)}
+
+
+
+ ∅ {intl.formatMessage(messages.btnNone)}
+
+
+
+ ¬ {intl.formatMessage(messages.btnInvert)}
+
+
+
+ {intl.formatMessage(messages.btnApply)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/permalink.js b/app/javascript/themes/glitch/components/permalink.js
new file mode 100644
index 000000000..d726d37a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/permalink.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Permalink extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ className: PropTypes.string,
+ href: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ };
+
+ handleClick = (e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(this.props.to);
+ }
+ }
+
+ render () {
+ const { href, children, className, ...other } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/relative_timestamp.js b/app/javascript/themes/glitch/components/relative_timestamp.js
new file mode 100644
index 000000000..51588e78c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/relative_timestamp.js
@@ -0,0 +1,147 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+ just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+ seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+ minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+ hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+ days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
+const dateFormatOptions = {
+ hour12: false,
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+ month: 'numeric',
+ day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR = 1000 * 60 * 60;
+const DAY = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+ const absDelta = Math.abs(delta);
+
+ if (absDelta < MINUTE) {
+ return 'second';
+ } else if (absDelta < HOUR) {
+ return 'minute';
+ } else if (absDelta < DAY) {
+ return 'hour';
+ }
+
+ return 'day';
+};
+
+const getUnitDelay = units => {
+ switch (units) {
+ case 'second':
+ return SECOND;
+ case 'minute':
+ return MINUTE;
+ case 'hour':
+ return HOUR;
+ case 'day':
+ return DAY;
+ default:
+ return MAX_DELAY;
+ }
+};
+
+@injectIntl
+export default class RelativeTimestamp extends React.Component {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ timestamp: PropTypes.string.isRequired,
+ };
+
+ state = {
+ now: this.props.intl.now(),
+ };
+
+ shouldComponentUpdate (nextProps, nextState) {
+ // As of right now the locale doesn't change without a new page load,
+ // but we might as well check in case that ever changes.
+ return this.props.timestamp !== nextProps.timestamp ||
+ this.props.intl.locale !== nextProps.intl.locale ||
+ this.state.now !== nextState.now;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.timestamp !== nextProps.timestamp) {
+ this.setState({ now: this.props.intl.now() });
+ }
+ }
+
+ componentDidMount () {
+ this._scheduleNextUpdate(this.props, this.state);
+ }
+
+ componentWillUpdate (nextProps, nextState) {
+ this._scheduleNextUpdate(nextProps, nextState);
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this._timer);
+ }
+
+ _scheduleNextUpdate (props, state) {
+ clearTimeout(this._timer);
+
+ const { timestamp } = props;
+ const delta = (new Date(timestamp)).getTime() - state.now;
+ const unitDelay = getUnitDelay(selectUnits(delta));
+ const unitRemainder = Math.abs(delta % unitDelay);
+ const updateInterval = 1000 * 10;
+ const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+ this._timer = setTimeout(() => {
+ this.setState({ now: this.props.intl.now() });
+ }, delay);
+ }
+
+ render () {
+ const { timestamp, intl } = this.props;
+
+ const date = new Date(timestamp);
+ const delta = this.state.now - date.getTime();
+
+ let relativeTime;
+
+ if (delta < 10 * SECOND) {
+ relativeTime = intl.formatMessage(messages.just_now);
+ } else if (delta < 3 * DAY) {
+ if (delta < MINUTE) {
+ relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+ } else if (delta < HOUR) {
+ relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+ } else if (delta < DAY) {
+ relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+ } else {
+ relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+ }
+ } else {
+ relativeTime = intl.formatDate(date, shortDateFormatOptions);
+ }
+
+ return (
+
+ {relativeTime}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/scrollable_list.js b/app/javascript/themes/glitch/components/scrollable_list.js
new file mode 100644
index 000000000..ccdcd7c85
--- /dev/null
+++ b/app/javascript/themes/glitch/components/scrollable_list.js
@@ -0,0 +1,198 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll-4';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from 'themes/glitch/containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from 'themes/glitch/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen';
+
+export default class ScrollableList extends PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ state = {
+ lastMouseMove: null,
+ };
+
+ intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+ handleScroll = throttle(() => {
+ if (this.node) {
+ const { scrollTop, scrollHeight, clientHeight } = this.node;
+ const offset = scrollHeight - scrollTop - clientHeight;
+ this._oldScrollPosition = scrollHeight - scrollTop;
+
+ if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+ this.props.onScrollToBottom();
+ } else if (scrollTop < 100 && this.props.onScrollToTop) {
+ this.props.onScrollToTop();
+ } else if (this.props.onScroll) {
+ this.props.onScroll();
+ }
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ handleMouseMove = throttle(() => {
+ this._lastMouseMove = new Date();
+ }, 300);
+
+ handleMouseLeave = () => {
+ this._lastMouseMove = null;
+ }
+
+ componentDidMount () {
+ this.attachScrollListener();
+ this.attachIntersectionObserver();
+ attachFullscreenListener(this.onFullScreenChange);
+
+ // Handle initial scroll posiiton
+ this.handleScroll();
+ }
+
+ componentDidUpdate (prevProps) {
+ const someItemInserted = React.Children.count(prevProps.children) > 0 &&
+ React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
+ this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+
+ // Reset the scroll position when a new child comes in in order not to
+ // jerk the scrollbar around if you're already scrolled down the page.
+ if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
+ const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+
+ if (this.node.scrollTop !== newScrollTop) {
+ this.node.scrollTop = newScrollTop;
+ }
+ } else {
+ this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+ }
+ }
+
+ componentWillUnmount () {
+ this.detachScrollListener();
+ this.detachIntersectionObserver();
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ attachIntersectionObserver () {
+ this.intersectionObserverWrapper.connect({
+ root: this.node,
+ rootMargin: '300% 0px',
+ });
+ }
+
+ detachIntersectionObserver () {
+ this.intersectionObserverWrapper.disconnect();
+ }
+
+ attachScrollListener () {
+ this.node.addEventListener('scroll', this.handleScroll);
+ }
+
+ detachScrollListener () {
+ this.node.removeEventListener('scroll', this.handleScroll);
+ }
+
+ getFirstChildKey (props) {
+ const { children } = props;
+ let firstChild = children;
+ if (children instanceof ImmutableList) {
+ firstChild = children.get(0);
+ } else if (Array.isArray(children)) {
+ firstChild = children[0];
+ }
+ return firstChild && firstChild.key;
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.onScrollToBottom();
+ }
+
+ _recentlyMoved () {
+ return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
+ }
+
+ render () {
+ const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+ const { fullscreen } = this.state;
+ const childrenCount = React.Children.count(children);
+
+ const loadMore = (hasMore && childrenCount > 0) ? : null;
+ let scrollableArea = null;
+
+ if (isLoading || childrenCount > 0 || !emptyMessage) {
+ scrollableArea = (
+
+
+ {prepend}
+
+ {React.Children.map(this.props.children, (child, index) => (
+
+ {child}
+
+ ))}
+
+ {loadMore}
+
+
+ );
+ } else {
+ scrollableArea = (
+
+ {emptyMessage}
+
+ );
+ }
+
+ if (trackScroll) {
+ return (
+
+ {scrollableArea}
+
+ );
+ } else {
+ return scrollableArea;
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/setting_text.js b/app/javascript/themes/glitch/components/setting_text.js
new file mode 100644
index 000000000..a6dde4c0f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/setting_text.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class SettingText extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ settingKey: PropTypes.array.isRequired,
+ label: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(this.props.settingKey, e.target.value);
+ }
+
+ render () {
+ const { settings, settingKey, label } = this.props;
+
+ return (
+
+ {label}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status.js b/app/javascript/themes/glitch/components/status.js
new file mode 100644
index 000000000..cf2fbe21e
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status.js
@@ -0,0 +1,436 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusPrepend from './status_prepend';
+import StatusHeader from './status_header';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video } from 'themes/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import NotificationOverlayContainer from 'themes/glitch/features/notifications/containers/overlay_container';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ id: PropTypes.string,
+ status: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.map,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onPin: PropTypes.func,
+ onOpenMedia: PropTypes.func,
+ onOpenVideo: PropTypes.func,
+ onBlock: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onHeightChange: PropTypes.func,
+ muted: PropTypes.bool,
+ collapse: PropTypes.bool,
+ hidden: PropTypes.bool,
+ prepend: PropTypes.string,
+ withDismiss: PropTypes.bool,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+ };
+
+ state = {
+ isExpanded: null,
+ markedForDelete: false,
+ }
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'settings',
+ 'prepend',
+ 'boostModal',
+ 'muted',
+ 'collapse',
+ 'notification',
+ ]
+
+ updateOnStates = [
+ 'isExpanded',
+ 'markedForDelete',
+ ]
+
+ // If our settings have changed to disable collapsed statuses, then we
+ // need to make sure that we uncollapse every one. We do that by watching
+ // for changes to `settings.collapsed.enabled` in
+ // `componentWillReceiveProps()`.
+
+ // We also need to watch for changes on the `collapse` prop---if this
+ // changes to anything other than `undefined`, then we need to collapse or
+ // uncollapse our status accordingly.
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+ if (this.state.isExpanded === false) {
+ this.setExpansion(null);
+ }
+ } else if (
+ nextProps.collapse !== this.props.collapse &&
+ nextProps.collapse !== undefined
+ ) this.setExpansion(nextProps.collapse ? false : null);
+ }
+
+ // When mounting, we just check to see if our status should be collapsed,
+ // and collapse it if so. We don't need to worry about whether collapsing
+ // is enabled here, because `setExpansion()` already takes that into
+ // account.
+
+ // The cases where a status should be collapsed are:
+ //
+ // - The `collapse` prop has been set to `true`
+ // - The user has decided in local settings to collapse all statuses.
+ // - The user has decided to collapse all notifications ('muted'
+ // statuses).
+ // - The user has decided to collapse long statuses and the status is
+ // over 400px (without media, or 650px with).
+ // - The status is a reply and the user has decided to collapse all
+ // replies.
+ // - The status contains media and the user has decided to collapse all
+ // statuses with media.
+ // - The status is a reblog the user has decided to collapse all
+ // statuses which are reblogs.
+ componentDidMount () {
+ const { node } = this;
+ const {
+ status,
+ settings,
+ collapse,
+ muted,
+ prepend,
+ } = this.props;
+ const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+ if (function () {
+ switch (true) {
+ case collapse:
+ case autoCollapseSettings.get('all'):
+ case autoCollapseSettings.get('notifications') && muted:
+ case autoCollapseSettings.get('lengthy') && node.clientHeight > (
+ status.get('media_attachments').size && !muted ? 650 : 400
+ ):
+ case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
+ case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
+ case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size:
+ return true;
+ default:
+ return false;
+ }
+ }()) this.setExpansion(false);
+ }
+
+ // `setExpansion()` sets the value of `isExpanded` in our state. It takes
+ // one argument, `value`, which gives the desired value for `isExpanded`.
+ // The default for this argument is `null`.
+
+ // `setExpansion()` automatically checks for us whether toot collapsing
+ // is enabled, so we don't have to.
+ setExpansion = (value) => {
+ switch (true) {
+ case value === undefined || value === null:
+ this.setState({ isExpanded: null });
+ break;
+ case !value && this.props.settings.getIn(['collapsed', 'enabled']):
+ this.setState({ isExpanded: false });
+ break;
+ case !!value:
+ this.setState({ isExpanded: true });
+ break;
+ }
+ }
+
+ // `parseClick()` takes a click event and responds appropriately.
+ // If our status is collapsed, then clicking on it should uncollapse it.
+ // If `Shift` is held, then clicking on it should collapse it.
+ // Otherwise, we open the url handed to us in `destination`, if
+ // applicable.
+ parseClick = (e, destination) => {
+ const { router } = this.context;
+ const { status } = this.props;
+ const { isExpanded } = this.state;
+ if (!router) return;
+ if (destination === undefined) {
+ destination = `/statuses/${
+ status.getIn(['reblog', 'id'], status.get('id'))
+ }`;
+ }
+ if (e.button === 0) {
+ if (isExpanded === false) this.setExpansion(null);
+ else if (e.shiftKey) {
+ this.setExpansion(false);
+ document.getSelection().removeAllRanges();
+ } else router.history.push(destination);
+ e.preventDefault();
+ }
+ }
+
+ handleAccountClick = (e) => {
+ if (this.context.router && e.button === 0) {
+ const id = e.currentTarget.getAttribute('data-id');
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${id}`);
+ }
+ }
+
+ handleExpandedToggle = () => {
+ this.setExpansion(this.state.isExpanded || !this.props.status.get('spoiler') ? null : true);
+ };
+
+ handleOpenVideo = startTime => {
+ this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+ }
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleHotkeyFavourite = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleHotkeyBoost = e => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleHotkeyOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ handleHotkeyMoveUp = () => {
+ this.props.onMoveUp(this.props.status.get('id'));
+ }
+
+ handleHotkeyMoveDown = () => {
+ this.props.onMoveDown(this.props.status.get('id'));
+ }
+
+ renderLoadingMediaGallery () {
+ return
;
+ }
+
+ renderLoadingVideoPlayer () {
+ return
;
+ }
+
+ render () {
+ const {
+ parseClick,
+ setExpansion,
+ } = this;
+ const { router } = this.context;
+ const {
+ status,
+ account,
+ settings,
+ collapsed,
+ muted,
+ prepend,
+ intersectionObserverWrapper,
+ onOpenVideo,
+ onOpenMedia,
+ notification,
+ hidden,
+ ...other
+ } = this.props;
+ const { isExpanded } = this.state;
+ let background = null;
+ let attachments = null;
+ let media = null;
+ let mediaIcon = null;
+
+ if (status === null) {
+ return null;
+ }
+
+ if (hidden) {
+ return (
+
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {' '}
+ {status.get('content')}
+
+ );
+ }
+
+ // If user backgrounds for collapsed statuses are enabled, then we
+ // initialize our background accordingly. This will only be rendered if
+ // the status is collapsed.
+ if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
+ background = status.getIn(['account', 'header']);
+ }
+
+ // This handles our media attachments. Note that we don't show media on
+ // muted (notification) statuses. If the media type is unknown, then we
+ // simply ignore it.
+
+ // After we have generated our appropriate media element and stored it in
+ // `media`, we snatch the thumbnail to use as our `background` if media
+ // backgrounds for collapsed statuses are enabled.
+ attachments = status.get('media_attachments');
+ if (attachments.size > 0 && !muted) {
+ if (attachments.some(item => item.get('type') === 'unknown')) { // Media type is 'unknown'
+ /* Do nothing */
+ } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
+ const video = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ {Component => }
+
+ );
+ mediaIcon = 'video-camera';
+ } else { // Media type is 'image' or 'gifv'
+ media = (
+
+ {Component => (
+
+ )}
+
+ );
+ mediaIcon = 'picture-o';
+ }
+
+ if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
+ background = attachments.getIn([0, 'preview_url']);
+ }
+ }
+
+ // Here we prepare extra data-* attributes for CSS selectors.
+ // Users can use those for theming, hiding avatars etc via UserStyle
+ const selectorAttribs = {
+ 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+ };
+
+ if (prepend && account) {
+ const notifKind = {
+ favourite: 'favourited',
+ reblog: 'boosted',
+ reblogged_by: 'boosted',
+ }[prepend];
+
+ selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+ }
+
+ const handlers = {
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ open: this.handleHotkeyOpen,
+ openProfile: this.handleHotkeyOpenProfile,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ };
+
+ return (
+
+
+ {prepend && account ? (
+
+ ) : null}
+
+
+ {isExpanded !== false ? (
+
+ ) : null}
+ {notification ? (
+
+ ) : null}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_action_bar.js b/app/javascript/themes/glitch/components/status_action_bar.js
new file mode 100644
index 000000000..9d615ed7c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_action_bar.js
@@ -0,0 +1,188 @@
+// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
+// SEE INSTEAD : glitch/components/status/action_bar
+
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ more: { id: 'status.more', defaultMessage: 'More' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onReport: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ onPin: PropTypes.func,
+ withDismiss: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'withDismiss',
+ ]
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleShareClick = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleMuteClick = () => {
+ this.props.onMute(this.props.status.get('account'));
+ }
+
+ handleBlockClick = () => {
+ this.props.onBlock(this.props.status.get('account'));
+ }
+
+ handleOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ }
+
+ render () {
+ const { status, intl, withDismiss } = this.props;
+
+ const mutingConversation = status.get('muted');
+ const anonymousAccess = !me;
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+ let reblogIcon = 'retweet';
+ let replyIcon;
+ let replyTitle;
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ menu.push(null);
+
+ if (status.getIn(['account', 'id']) === me || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (status.getIn(['account', 'id']) === me) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+
+ );
+
+ return (
+
+
+
+
+ {shareButton}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_content.js b/app/javascript/themes/glitch/components/status_content.js
new file mode 100644
index 000000000..3eba6eaa0
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_content.js
@@ -0,0 +1,245 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'themes/glitch/util/rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+import classnames from 'classnames';
+
+export default class StatusContent extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ expanded: PropTypes.bool,
+ setExpansion: PropTypes.func,
+ media: PropTypes.element,
+ mediaIcon: PropTypes.string,
+ parseClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ };
+
+ state = {
+ hidden: true,
+ };
+
+ _updateStatusLinks () {
+ const node = this.node;
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+ link.classList.add('status-link');
+
+ let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.addEventListener('click', this.onLinkClick.bind(this), false);
+ link.setAttribute('title', link.href);
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener');
+ }
+ }
+
+ componentDidMount () {
+ this._updateStatusLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateStatusLinks();
+ }
+
+ onLinkClick = (e) => {
+ if (this.props.expanded === false) {
+ if (this.props.parseClick) this.props.parseClick(e);
+ }
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.props.parseClick) {
+ this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (this.props.parseClick) {
+ this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleMouseDown = (e) => {
+ this.startXY = [e.clientX, e.clientY];
+ }
+
+ handleMouseUp = (e) => {
+ const { parseClick } = this.props;
+
+ if (!this.startXY) {
+ return;
+ }
+
+ const [ startX, startY ] = this.startXY;
+ const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+ if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+ return;
+ }
+
+ if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
+ parseClick(e);
+ }
+
+ this.startXY = null;
+ }
+
+ handleSpoilerClick = (e) => {
+ e.preventDefault();
+
+ if (this.props.setExpansion) {
+ this.props.setExpansion(this.props.expanded ? null : true);
+ } else {
+ this.setState({ hidden: !this.state.hidden });
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const {
+ status,
+ media,
+ mediaIcon,
+ parseClick,
+ disabled,
+ } = this.props;
+
+ const hidden = this.props.setExpansion ? !this.props.expanded : this.state.hidden;
+
+ const content = { __html: status.get('contentHtml') };
+ const spoilerContent = { __html: status.get('spoilerHtml') };
+ const directionStyle = { direction: 'ltr' };
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': parseClick && !disabled,
+ 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+ });
+
+ if (isRtl(status.get('search_index'))) {
+ directionStyle.direction = 'rtl';
+ }
+
+ if (status.get('spoiler_text').length > 0) {
+ let mentionsPlaceholder = '';
+
+ const mentionLinks = status.get('mentions').map(item => (
+
+ @{item.get('username')}
+
+ )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+ const toggleText = hidden ? [
+ ,
+ mediaIcon ? (
+
+ ) : null,
+ ] : [
+ ,
+ ];
+
+ if (hidden) {
+ mentionsPlaceholder = {mentionLinks}
;
+ }
+
+ return (
+
+
+
+ {' '}
+
+ {toggleText}
+
+
+
+ {mentionsPlaceholder}
+
+
+
+
+ );
+ } else if (parseClick) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_header.js b/app/javascript/themes/glitch/components/status_header.js
new file mode 100644
index 000000000..bfa996cd5
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_header.js
@@ -0,0 +1,120 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Mastodon imports.
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import DisplayName from './display_name';
+import IconButton from './icon_button';
+import VisibilityIcon from './status_visibility_icon';
+
+// Messages for use with internationalization stuff.
+const messages = defineMessages({
+ collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
+ uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
+ public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class StatusHeader extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map,
+ mediaIcon: PropTypes.string,
+ collapsible: PropTypes.bool,
+ collapsed: PropTypes.bool,
+ parseClick: PropTypes.func.isRequired,
+ setExpansion: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Handles clicks on collapsed button
+ handleCollapsedClick = (e) => {
+ const { collapsed, setExpansion } = this.props;
+ if (e.button === 0) {
+ setExpansion(collapsed ? null : false);
+ e.preventDefault();
+ }
+ }
+
+ // Handles clicks on account name/image
+ handleAccountClick = (e) => {
+ const { status, parseClick } = this.props;
+ parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`);
+ }
+
+ // Rendering.
+ render () {
+ const {
+ status,
+ friend,
+ mediaIcon,
+ collapsible,
+ collapsed,
+ intl,
+ } = this.props;
+
+ const account = status.get('account');
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_list.js b/app/javascript/themes/glitch/components/status_list.js
new file mode 100644
index 000000000..ddb1354c6
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_list.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from './scrollable_list';
+
+export default class StatusList extends ImmutablePureComponent {
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ handleMoveUp = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) - 1;
+ this._selectChild(elementIndex);
+ }
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) + 1;
+ this._selectChild(elementIndex);
+ }
+
+ _selectChild (index) {
+ const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { statusIds, ...other } = this.props;
+ const { isLoading } = other;
+
+ const scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map((statusId) => (
+
+ ))
+ ) : null;
+
+ return (
+
+ {scrollableContent}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_prepend.js b/app/javascript/themes/glitch/components/status_prepend.js
new file mode 100644
index 000000000..bd2559e46
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_prepend.js
@@ -0,0 +1,83 @@
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+
+export default class StatusPrepend extends React.PureComponent {
+
+ static propTypes = {
+ type: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ parseClick: PropTypes.func.isRequired,
+ notificationId: PropTypes.number,
+ };
+
+ handleClick = (e) => {
+ const { account, parseClick } = this.props;
+ parseClick(e, `/accounts/${+account.get('id')}`);
+ }
+
+ Message = () => {
+ const { type, account } = this.props;
+ let link = (
+
+
+
+ );
+ switch (type) {
+ case 'reblogged_by':
+ return (
+
+ );
+ case 'favourite':
+ return (
+
+ );
+ case 'reblog':
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ render () {
+ const { Message } = this;
+ const { type } = this.props;
+
+ return !type ? null : (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_visibility_icon.js b/app/javascript/themes/glitch/components/status_visibility_icon.js
new file mode 100644
index 000000000..017b69cbb
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_visibility_icon.js
@@ -0,0 +1,48 @@
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class VisibilityIcon extends ImmutablePureComponent {
+
+ static propTypes = {
+ visibility: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ withLabel: PropTypes.bool,
+ };
+
+ render() {
+ const { withLabel, visibility, intl } = this.props;
+
+ const visibilityClass = {
+ public: 'globe',
+ unlisted: 'unlock-alt',
+ private: 'lock',
+ direct: 'envelope',
+ }[visibility];
+
+ const label = intl.formatMessage(messages[visibility]);
+
+ const icon = ( );
+
+ if (withLabel) {
+ return ({icon} {label} );
+ } else {
+ return icon;
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/account_container.js b/app/javascript/themes/glitch/containers/account_container.js
new file mode 100644
index 000000000..c1ce49987
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/account_container.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import Account from 'themes/glitch/components/account';
+import {
+ followAccount,
+ unfollowAccount,
+ blockAccount,
+ unblockAccount,
+ muteAccount,
+ unmuteAccount,
+} from 'themes/glitch/actions/accounts';
+import { openModal } from 'themes/glitch/actions/modal';
+import { initMuteModal } from 'themes/glitch/actions/mutes';
+import { unfollowModal } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.unfollowConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(blockAccount(account.get('id')));
+ }
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+
+ onMuteNotifications (account, notifications) {
+ dispatch(muteAccount(account.get('id'), notifications));
+ },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/themes/glitch/containers/card_container.js b/app/javascript/themes/glitch/containers/card_container.js
new file mode 100644
index 000000000..8285437bb
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/card_container.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Card from 'themes/glitch/features/status/components/card';
+import { fromJS } from 'immutable';
+
+export default class CardContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string,
+ card: PropTypes.array.isRequired,
+ };
+
+ render () {
+ const { card, ...props } = this.props;
+ return ;
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/compose_container.js b/app/javascript/themes/glitch/containers/compose_container.js
new file mode 100644
index 000000000..82980ee36
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/compose_container.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'themes/glitch/store/configureStore';
+import { hydrateStore } from 'themes/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import Compose from 'themes/glitch/features/standalone/compose';
+import initialState from 'themes/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+ store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/dropdown_menu_container.js b/app/javascript/themes/glitch/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..15e8da2e3
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/dropdown_menu_container.js
@@ -0,0 +1,16 @@
+import { openModal, closeModal } from 'themes/glitch/actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from 'themes/glitch/components/dropdown_menu';
+import { isUserTouching } from 'themes/glitch/util/is_mobile';
+
+const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
+});
+
+const mapDispatchToProps = dispatch => ({
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/themes/glitch/containers/intersection_observer_article_container.js b/app/javascript/themes/glitch/containers/intersection_observer_article_container.js
new file mode 100644
index 000000000..6ede64738
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/intersection_observer_article_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import IntersectionObserverArticle from 'themes/glitch/components/intersection_observer_article';
+import { setHeight } from 'themes/glitch/actions/height_cache';
+
+const makeMapStateToProps = (state, props) => ({
+ cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+ onHeightChange (key, id, height) {
+ dispatch(setHeight(key, id, height));
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
diff --git a/app/javascript/themes/glitch/containers/mastodon.js b/app/javascript/themes/glitch/containers/mastodon.js
new file mode 100644
index 000000000..348470637
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/mastodon.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'themes/glitch/store/configureStore';
+import { showOnboardingOnce } from 'themes/glitch/actions/onboarding';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import UI from 'themes/glitch/features/ui';
+import { hydrateStore } from 'themes/glitch/actions/store';
+import { connectUserStream } from 'themes/glitch/actions/streaming';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import initialState from 'themes/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+store.dispatch(hydrateAction);
+
+export default class Mastodon extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ componentDidMount() {
+ this.disconnect = store.dispatch(connectUserStream());
+
+ // Desktop notifications
+ // Ask after 1 minute
+ if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
+ window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
+ }
+
+ // Protocol handler
+ // Ask after 5 minutes
+ if (typeof navigator.registerProtocolHandler !== 'undefined') {
+ const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
+ window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
+ }
+
+ store.dispatch(showOnboardingOnce());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/media_gallery_container.js b/app/javascript/themes/glitch/containers/media_gallery_container.js
new file mode 100644
index 000000000..86965f73b
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/media_gallery_container.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import MediaGallery from 'themes/glitch/components/media_gallery';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class MediaGalleryContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ media: PropTypes.array.isRequired,
+ };
+
+ handleOpenMedia = () => {}
+
+ render () {
+ const { locale, media, ...props } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js b/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js
new file mode 100644
index 000000000..ee4cb84cd
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/notification_purge_buttons_container.js
@@ -0,0 +1,49 @@
+// Package imports.
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Our imports.
+import NotificationPurgeButtons from 'themes/glitch/components/notification_purge_buttons';
+import {
+ deleteMarkedNotifications,
+ enterNotificationClearingMode,
+ markAllNotifications,
+} from 'themes/glitch/actions/notifications';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+ clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
+ clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+ onEnterCleaningMode(yes) {
+ dispatch(enterNotificationClearingMode(yes));
+ },
+
+ onDeleteMarked() {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.clearMessage),
+ confirm: intl.formatMessage(messages.clearConfirm),
+ onConfirm: () => dispatch(deleteMarkedNotifications()),
+ }));
+ },
+
+ onMarkAll() {
+ dispatch(markAllNotifications(true));
+ },
+
+ onMarkNone() {
+ dispatch(markAllNotifications(false));
+ },
+
+ onInvert() {
+ dispatch(markAllNotifications(null));
+ },
+});
+
+const mapStateToProps = state => ({
+ markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));
diff --git a/app/javascript/themes/glitch/containers/status_container.js b/app/javascript/themes/glitch/containers/status_container.js
new file mode 100644
index 000000000..14906723a
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/status_container.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from 'themes/glitch/components/status';
+import { makeGetStatus } from 'themes/glitch/selectors';
+import {
+ replyCompose,
+ mentionCompose,
+} from 'themes/glitch/actions/compose';
+import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+ pin,
+ unpin,
+} from 'themes/glitch/actions/interactions';
+import { blockAccount } from 'themes/glitch/actions/accounts';
+import { muteStatus, unmuteStatus, deleteStatus } from 'themes/glitch/actions/statuses';
+import { initMuteModal } from 'themes/glitch/actions/mutes';
+import { initReport } from 'themes/glitch/actions/reports';
+import { openModal } from 'themes/glitch/actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => {
+
+ let status = getStatus(state, props.id);
+ let reblogStatus = status ? status.get('reblog', null) : null;
+ let account = undefined;
+ let prepend = undefined;
+
+ if (reblogStatus !== null && typeof reblogStatus === 'object') {
+ account = status.get('account');
+ status = reblogStatus;
+ prepend = 'reblogged_by';
+ }
+
+ return {
+ status : status,
+ account : account || props.account,
+ settings : state.get('local_settings'),
+ prepend : prepend || props.prepend,
+ };
+ };
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch(replyCompose(status, router));
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+ }
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onPin (status) {
+ if (status.get('pinned')) {
+ dispatch(unpin(status));
+ } else {
+ dispatch(pin(status));
+ }
+ },
+
+ onEmbed (status) {
+ dispatch(openModal('EMBED', { url: status.get('url') }));
+ },
+
+ onDelete (status) {
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideo (media, time) {
+ dispatch(openModal('VIDEO', { media, time }));
+ },
+
+ onBlock (account) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onMute (account) {
+ dispatch(initMuteModal(account));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/themes/glitch/containers/timeline_container.js b/app/javascript/themes/glitch/containers/timeline_container.js
new file mode 100644
index 000000000..a75f8808d
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/timeline_container.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'themes/glitch/store/configureStore';
+import { hydrateStore } from 'themes/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import PublicTimeline from 'themes/glitch/features/standalone/public_timeline';
+import HashtagTimeline from 'themes/glitch/features/standalone/hashtag_timeline';
+import initialState from 'themes/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+ store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ hashtag: PropTypes.string,
+ };
+
+ render () {
+ const { locale, hashtag } = this.props;
+
+ let timeline;
+
+ if (hashtag) {
+ timeline = ;
+ } else {
+ timeline = ;
+ }
+
+ return (
+
+
+ {timeline}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/containers/video_container.js b/app/javascript/themes/glitch/containers/video_container.js
new file mode 100644
index 000000000..2b0e98666
--- /dev/null
+++ b/app/javascript/themes/glitch/containers/video_container.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import Video from 'themes/glitch/features/video';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class VideoContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ render () {
+ const { locale, ...props } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account/components/action_bar.js b/app/javascript/themes/glitch/features/account/components/action_bar.js
new file mode 100644
index 000000000..0edd5c848
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account/components/action_bar.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { Link } from 'react-router-dom';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+ share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
+ media: { id: 'account.media', defaultMessage: 'Media' },
+ blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+ hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+ showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onFollow: PropTypes.func,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReblogToggle: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleShare = () => {
+ navigator.share({
+ url: this.props.account.get('url'),
+ });
+ }
+
+ render () {
+ const { account, intl } = this.props;
+
+ let menu = [];
+ let extraInfo = '';
+
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+ if ('share' in navigator) {
+ menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+ }
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
+ menu.push(null);
+
+ if (account.get('id') === me) {
+ menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+ } else {
+ const following = account.getIn(['relationship', 'following']);
+ if (following) {
+ if (following.get('reblogs')) {
+ menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+ }
+ }
+
+ if (account.getIn(['relationship', 'muting'])) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+ }
+
+ if (account.getIn(['relationship', 'blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+ }
+
+ if (account.get('acct') !== account.get('username')) {
+ const domain = account.get('acct').split('@')[1];
+
+ extraInfo = (
+
+ );
+
+ menu.push(null);
+
+ if (account.getIn(['relationship', 'domain_blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
+ }
+ }
+
+ return (
+
+ {extraInfo}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account/components/header.js b/app/javascript/themes/glitch/features/account/components/header.js
new file mode 100644
index 000000000..696bb1991
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account/components/header.js
@@ -0,0 +1,99 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+
+import emojify from 'themes/glitch/util/emoji';
+import { me } from 'themes/glitch/util/initial_state';
+import { processBio } from 'themes/glitch/util/bio_metadata';
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+});
+
+@injectIntl
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { account, intl } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ let displayName = account.get('display_name_html');
+ let info = '';
+ let actionBtn = '';
+
+ if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+ info = ;
+ }
+
+ if (me !== account.get('id')) {
+ if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = (
+
+
+
+ );
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ actionBtn = (
+
+
+
+ );
+ }
+ }
+
+ const { text, metadata } = processBio(account.get('note'));
+
+ return (
+
+
+
+
+
+
+
@{account.get('acct')} {account.get('locked') ? : null}
+
+
+ {info}
+ {actionBtn}
+
+
+
+ {metadata.length && (
+
+
+ {(() => {
+ let data = [];
+ for (let i = 0; i < metadata.length; i++) {
+ data.push(
+
+
+
+
+ );
+ }
+ return data;
+ })()}
+
+
+ ) || null}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_gallery/components/media_item.js b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js
new file mode 100644
index 000000000..88c9156b5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Permalink from 'themes/glitch/components/permalink';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { media } = this.props;
+ const status = media.get('status');
+
+ let content, style;
+
+ if (media.get('type') === 'gifv') {
+ content = GIF ;
+ }
+
+ if (!status.get('sensitive')) {
+ style = { backgroundImage: `url(${media.get('preview_url')})` };
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_gallery/index.js b/app/javascript/themes/glitch/features/account_gallery/index.js
new file mode 100644
index 000000000..a21c089da
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_gallery/index.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'themes/glitch/actions/accounts';
+import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'themes/glitch/actions/timelines';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from 'themes/glitch/selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import { FormattedMessage } from 'react-intl';
+import { ScrollContainer } from 'react-router-scroll-4';
+import LoadMore from 'themes/glitch/components/load_more';
+
+const mapStateToProps = (state, props) => ({
+ medias: getAccountGallery(state, props.params.accountId),
+ isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+ hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountGallery extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ medias: ImmutablePropTypes.list.isRequired,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentDidMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+ }
+ }
+
+ handleScrollToBottom = () => {
+ if (this.props.hasMore) {
+ this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+ const offset = scrollHeight - scrollTop - clientHeight;
+
+ if (150 > offset && !this.props.isLoading) {
+ this.handleScrollToBottom();
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.handleScrollToBottom();
+ }
+
+ render () {
+ const { medias, isLoading, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!medias && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoading && medias.size > 0 && hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {medias.map(media =>
+
+ )}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_timeline/components/header.js b/app/javascript/themes/glitch/features/account_timeline/components/header.js
new file mode 100644
index 000000000..c719a7bcb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/components/header.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from 'themes/glitch/features/account/components/header';
+import ActionBar from 'themes/glitch/features/account/components/action_bar';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReblogToggle: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ };
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMention = () => {
+ this.props.onMention(this.props.account, this.context.router.history);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.account);
+ }
+
+ handleReblogToggle = () => {
+ this.props.onReblogToggle(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ handleBlockDomain = () => {
+ const domain = this.props.account.get('acct').split('@')[1];
+
+ if (!domain) return;
+
+ this.props.onBlockDomain(domain, this.props.account.get('id'));
+ }
+
+ handleUnblockDomain = () => {
+ const domain = this.props.account.get('acct').split('@')[1];
+
+ if (!domain) return;
+
+ this.props.onUnblockDomain(domain, this.props.account.get('id'));
+ }
+
+ render () {
+ const { account } = this.props;
+
+ if (account === null) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..766b57b56
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js
@@ -0,0 +1,104 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import Header from '../components/header';
+import {
+ followAccount,
+ unfollowAccount,
+ blockAccount,
+ unblockAccount,
+ unmuteAccount,
+} from 'themes/glitch/actions/accounts';
+import { mentionCompose } from 'themes/glitch/actions/compose';
+import { initMuteModal } from 'themes/glitch/actions/mutes';
+import { initReport } from 'themes/glitch/actions/reports';
+import { openModal } from 'themes/glitch/actions/modal';
+import { blockDomain, unblockDomain } from 'themes/glitch/actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.unfollowConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onReblogToggle (account) {
+ if (account.getIn(['relationship', 'following', 'reblogs'])) {
+ dispatch(followAccount(account.get('id'), false));
+ } else {
+ dispatch(followAccount(account.get('id'), true));
+ }
+ },
+
+ onReport (account) {
+ dispatch(initReport(account));
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+ onBlockDomain (domain, accountId) {
+ dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => dispatch(blockDomain(domain, accountId)),
+ }));
+ },
+
+ onUnblockDomain (domain, accountId) {
+ dispatch(unblockDomain(domain, accountId));
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/themes/glitch/features/account_timeline/index.js b/app/javascript/themes/glitch/features/account_timeline/index.js
new file mode 100644
index 000000000..81336ef3a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/index.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'themes/glitch/actions/accounts';
+import { refreshAccountTimeline, expandAccountTimeline } from 'themes/glitch/actions/timelines';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+ isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+ hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountTimeline extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
+ }
+ }
+
+ handleScrollToBottom = () => {
+ if (!this.props.isLoading && this.props.hasMore) {
+ this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
+ }
+ }
+
+ render () {
+ const { statusIds, isLoading, hasMore } = this.props;
+
+ if (!statusIds && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ scrollKey='account_timeline'
+ statusIds={statusIds}
+ isLoading={isLoading}
+ hasMore={hasMore}
+ onScrollToBottom={this.handleScrollToBottom}
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/blocks/index.js b/app/javascript/themes/glitch/features/blocks/index.js
new file mode 100644
index 000000000..70630818c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/blocks/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import { fetchBlocks, expandBlocks } from 'themes/glitch/actions/blocks';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'blocks', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Blocks extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchBlocks());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandBlocks());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js
new file mode 100644
index 000000000..988e36308
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from 'themes/glitch/components/setting_text';
+
+const messages = defineMessages({
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+ settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { settings, onChange, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..cd9c34365
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'community']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['community', ...key], checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/community_timeline/index.js b/app/javascript/themes/glitch/features/community_timeline/index.js
new file mode 100644
index 000000000..9d255bd01
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+ refreshCommunityTimeline,
+ expandCommunityTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+ title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('COMMUNITY', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshCommunityTimeline());
+ this.disconnect = dispatch(connectCommunityStream());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandCommunityTimeline());
+ }
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options.js b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
new file mode 100644
index 000000000..045bad2e5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
@@ -0,0 +1,62 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+
+// Our imports.
+import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
+import ComposeDropdown from './dropdown';
+
+const messages = defineMessages({
+ local_only_short :
+ { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
+ local_only_long :
+ { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
+ advanced_options_icon_title :
+ { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
+});
+
+@injectIntl
+export default class ComposeAdvancedOptions extends React.PureComponent {
+
+ static propTypes = {
+ values : ImmutablePropTypes.contains({
+ do_not_federate : PropTypes.bool.isRequired,
+ }).isRequired,
+ onChange : PropTypes.func.isRequired,
+ intl : PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl, values } = this.props;
+ const options = [
+ { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
+ ];
+ const anyEnabled = values.some((enabled) => enabled);
+
+ const optionElems = options.map((option) => {
+ return (
+
+ );
+ });
+
+ return (
+
+ {optionElems}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
new file mode 100644
index 000000000..98b3b6a44
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
@@ -0,0 +1,35 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import Toggle from 'react-toggle';
+
+export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
+
+ static propTypes = {
+ onChange: PropTypes.func.isRequired,
+ active: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+ shortText: PropTypes.string.isRequired,
+ longText: PropTypes.string.isRequired,
+ }
+
+ onToggle = () => {
+ this.props.onChange(this.props.name);
+ }
+
+ render() {
+ const { active, shortText, longText } = this.props;
+ return (
+
+
+
+
+
+ {shortText}
+ {longText}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/attach_options.js b/app/javascript/themes/glitch/features/compose/components/attach_options.js
new file mode 100644
index 000000000..c396714f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/attach_options.js
@@ -0,0 +1,131 @@
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { injectIntl, defineMessages } from 'react-intl';
+
+// Our imports //
+import ComposeDropdown from './dropdown';
+import { uploadCompose } from 'themes/glitch/actions/compose';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+ upload :
+ { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
+ doodle :
+ { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
+ attach :
+ { id: 'compose.attach', defaultMessage: 'Attach...' },
+});
+
+const mapStateToProps = state => ({
+ // This horrible expression is copied from vanilla upload_button_container
+ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+ resetFileKey: state.getIn(['compose', 'resetFileKey']),
+ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onSelectFile (files) {
+ dispatch(uploadCompose(files));
+ },
+ onOpenDoodle () {
+ dispatch(openModal('DOODLE', { noEsc: true }));
+ },
+});
+
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps)
+export default class ComposeAttachOptions extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl : PropTypes.object.isRequired,
+ resetFileKey: PropTypes.number,
+ acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+ disabled: PropTypes.bool,
+ onSelectFile: PropTypes.func.isRequired,
+ onOpenDoodle: PropTypes.func.isRequired,
+ };
+
+ handleItemClick = bt => {
+ if (bt === 'upload') {
+ this.fileElement.click();
+ }
+
+ if (bt === 'doodle') {
+ this.props.onOpenDoodle();
+ }
+
+ this.dropdown.setState({ open: false });
+ };
+
+ handleFileChange = (e) => {
+ if (e.target.files.length > 0) {
+ this.props.onSelectFile(e.target.files);
+ }
+ }
+
+ setFileRef = (c) => {
+ this.fileElement = c;
+ }
+
+ setDropdownRef = (c) => {
+ this.dropdown = c;
+ }
+
+ render () {
+ const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+ const options = [
+ { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
+ { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
+ ];
+
+ const optionElems = options.map((item) => {
+ const hdl = () => this.handleItemClick(item.name);
+ return (
+
+
+
+
+
+
+ {intl.formatMessage(item.text)}
+
+
+ );
+ });
+
+ return (
+
+
+ {optionElems}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..4a98d89fe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/character_counter.js b/app/javascript/themes/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/character_counter.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.string.isRequired,
+ max: PropTypes.number.isRequired,
+ };
+
+ checkRemainingText (diff) {
+ if (diff < 0) {
+ return {diff} ;
+ }
+
+ return {diff} ;
+ }
+
+ render () {
+ const diff = this.props.max - length(this.props.text);
+ return this.checkRemainingText(diff);
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/compose_form.js b/app/javascript/themes/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..54b1944a4
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,286 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from 'themes/glitch/components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from 'themes/glitch/components/autosuggest_textarea';
+import { defineMessages, injectIntl } from 'react-intl';
+import Collapsable from 'themes/glitch/components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { length } from 'stringz';
+import { countableText } from 'themes/glitch/util/counter';
+import ComposeAttachOptions from './attach_options';
+import initialState from 'themes/glitch/util/initial_state';
+
+const maxChars = initialState.max_toot_chars;
+
+const messages = defineMessages({
+ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+ spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+ publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
+ publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+});
+
+@injectIntl
+export default class ComposeForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ text: PropTypes.string.isRequired,
+ suggestion_token: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ spoiler: PropTypes.bool,
+ privacy: PropTypes.string,
+ advanced_options: ImmutablePropTypes.contains({
+ do_not_federate: PropTypes.bool,
+ }),
+ spoiler_text: PropTypes.string,
+ focusDate: PropTypes.instanceOf(Date),
+ preselectDate: PropTypes.instanceOf(Date),
+ is_submitting: PropTypes.bool,
+ is_uploading: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onPrivacyChange: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onChangeSpoilerText: PropTypes.func.isRequired,
+ onPaste: PropTypes.func.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ showSearch: PropTypes.bool,
+ settings : ImmutablePropTypes.map.isRequired,
+ };
+
+ static defaultProps = {
+ showSearch: false,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ }
+
+ handleKeyDown = (e) => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.handleSubmit();
+ }
+ }
+
+ handleSubmit2 = () => {
+ this.props.onPrivacyChange(this.props.settings.get('side_arm'));
+ this.handleSubmit();
+ }
+
+ handleSubmit = () => {
+ if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+ // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+ // Update the state to match the current text
+ this.props.onChange(this.autosuggestTextarea.textarea.value);
+ }
+
+ this.props.onSubmit();
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.props.onClearSuggestions();
+ }
+
+ onSuggestionsFetchRequested = (token) => {
+ this.props.onFetchSuggestions(token);
+ }
+
+ onSuggestionSelected = (tokenStart, token, value) => {
+ this._restoreCaret = null;
+ this.props.onSuggestionSelected(tokenStart, token, value);
+ }
+
+ handleChangeSpoilerText = (e) => {
+ this.props.onChangeSpoilerText(e.target.value);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ // If this is the update where we've finished uploading,
+ // save the last caret position so we can restore it below!
+ if (!nextProps.is_uploading && this.props.is_uploading) {
+ this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ // This statement does several things:
+ // - If we're beginning a reply, and,
+ // - Replying to zero or one users, places the cursor at the end of the textbox.
+ // - Replying to more than one user, selects any usernames past the first;
+ // this provides a convenient shortcut to drop everyone else from the conversation.
+ // - If we've just finished uploading an image, and have a saved caret position,
+ // restores the cursor to that position after the text changes!
+ if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+ let selectionEnd, selectionStart;
+
+ if (this.props.preselectDate !== prevProps.preselectDate) {
+ selectionEnd = this.props.text.length;
+ selectionStart = this.props.text.search(/\s/) + 1;
+ } else if (typeof this._restoreCaret === 'number') {
+ selectionStart = this._restoreCaret;
+ selectionEnd = this._restoreCaret;
+ } else {
+ selectionEnd = this.props.text.length;
+ selectionStart = selectionEnd;
+ }
+
+ this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+ this.autosuggestTextarea.textarea.focus();
+ } else if(prevProps.is_submitting && !this.props.is_submitting) {
+ this.autosuggestTextarea.textarea.focus();
+ }
+ }
+
+ setAutosuggestTextarea = (c) => {
+ this.autosuggestTextarea = c;
+ }
+
+ handleEmojiPick = (data) => {
+ const position = this.autosuggestTextarea.textarea.selectionStart;
+ const emojiChar = data.native;
+ this._restoreCaret = position + emojiChar.length + 1;
+ this.props.onPickEmoji(position, data);
+ }
+
+ render () {
+ const { intl, onPaste, showSearch } = this.props;
+ const disabled = this.props.is_submitting;
+ const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
+ const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
+
+ const secondaryVisibility = this.props.settings.get('side_arm');
+ let showSideArm = secondaryVisibility !== 'none';
+
+ let publishText = '';
+ let publishText2 = '';
+ let title = '';
+ let title2 = '';
+
+ const privacyIcons = {
+ none: '',
+ public: 'globe',
+ unlisted: 'unlock-alt',
+ private: 'lock',
+ direct: 'envelope',
+ };
+
+ title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
+
+ if (showSideArm) {
+ // Enhanced behavior with dual toot buttons
+ publishText = (
+
+ {
+
+ }{intl.formatMessage(messages.publish)}
+
+ );
+
+ title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
+ publishText2 = (
+
+ );
+ } else {
+ // Original vanilla behavior - no icon if public or unlisted
+ if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+ publishText = {intl.formatMessage(messages.publish)} ;
+ } else {
+ publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+ }
+ }
+
+ const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.spoiler_placeholder)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ showSideArm ?
+ : ''
+ }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/dropdown.js b/app/javascript/themes/glitch/features/compose/components/dropdown.js
new file mode 100644
index 000000000..f3d9f094e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/dropdown.js
@@ -0,0 +1,77 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+
+// Our imports.
+import IconButton from 'themes/glitch/components/icon_button';
+
+const iconStyle = {
+ height : null,
+ lineHeight : '27px',
+};
+
+export default class ComposeDropdown extends React.PureComponent {
+
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string,
+ highlight: PropTypes.bool,
+ disabled: PropTypes.bool,
+ children: PropTypes.arrayOf(PropTypes.node).isRequired,
+ };
+
+ state = {
+ open: false,
+ };
+
+ onGlobalClick = (e) => {
+ if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+ this.setState({ open: false });
+ }
+ };
+
+ componentDidMount () {
+ window.addEventListener('click', this.onGlobalClick);
+ window.addEventListener('touchstart', this.onGlobalClick);
+ }
+ componentWillUnmount () {
+ window.removeEventListener('click', this.onGlobalClick);
+ window.removeEventListener('touchstart', this.onGlobalClick);
+ }
+
+ onToggleDropdown = () => {
+ if (this.props.disabled) return;
+ this.setState({ open: !this.state.open });
+ };
+
+ setRef = (c) => {
+ this.node = c;
+ };
+
+ render () {
+ const { open } = this.state;
+ let { highlight, title, icon, disabled } = this.props;
+
+ if (!icon) icon = 'ellipsis-h';
+
+ return (
+
+
+
+
+
+ {this.props.children}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..fd59efb85
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,376 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from 'themes/glitch/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from 'themes/glitch/util/emoji';
+
+const messages = defineMessages({
+ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+ emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+ emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+ custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+ recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+ search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+ people: { id: 'emoji_button.people', defaultMessage: 'People' },
+ nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+ food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+ activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+ travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+ objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+ symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+const categoriesSort = [
+ 'recent',
+ 'custom',
+ 'people',
+ 'nature',
+ 'foods',
+ 'activity',
+ 'places',
+ 'objects',
+ 'symbols',
+ 'flags',
+];
+
+class ModifierPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onSelect: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ handleClick = e => {
+ this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.active) {
+ this.attachListeners();
+ } else {
+ this.removeListeners();
+ }
+ }
+
+ componentWillUnmount () {
+ this.removeListeners();
+ }
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ attachListeners () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ removeListeners () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ modifier: PropTypes.number,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ onOpen: PropTypes.func,
+ };
+
+ handleClick = () => {
+ if (this.props.active) {
+ this.props.onClose();
+ } else {
+ this.props.onOpen();
+ }
+ }
+
+ handleSelect = modifier => {
+ this.props.onChange(modifier);
+ this.props.onClose();
+ }
+
+ render () {
+ const { active, modifier } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ loading: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ onPick: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ style: {},
+ loading: true,
+ placement: 'bottom',
+ frequentlyUsedEmojis: [],
+ };
+
+ state = {
+ modifierOpen: false,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ getI18n = () => {
+ const { intl } = this.props;
+
+ return {
+ search: intl.formatMessage(messages.emoji_search),
+ notfound: intl.formatMessage(messages.emoji_not_found),
+ categories: {
+ search: intl.formatMessage(messages.search_results),
+ recent: intl.formatMessage(messages.recent),
+ people: intl.formatMessage(messages.people),
+ nature: intl.formatMessage(messages.nature),
+ foods: intl.formatMessage(messages.food),
+ activity: intl.formatMessage(messages.activity),
+ places: intl.formatMessage(messages.travel),
+ objects: intl.formatMessage(messages.objects),
+ symbols: intl.formatMessage(messages.symbols),
+ flags: intl.formatMessage(messages.flags),
+ custom: intl.formatMessage(messages.custom),
+ },
+ };
+ }
+
+ handleClick = emoji => {
+ if (!emoji.native) {
+ emoji.native = emoji.colons;
+ }
+
+ this.props.onClose();
+ this.props.onPick(emoji);
+ }
+
+ handleModifierOpen = () => {
+ this.setState({ modifierOpen: true });
+ }
+
+ handleModifierClose = () => {
+ this.setState({ modifierOpen: false });
+ }
+
+ handleModifierChange = modifier => {
+ this.props.onSkinTone(modifier);
+ }
+
+ render () {
+ const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+ if (loading) {
+ return
;
+ }
+
+ const title = intl.formatMessage(messages.emoji);
+ const { modifierOpen } = this.state;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class EmojiPickerDropdown extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ intl: PropTypes.object.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ };
+
+ state = {
+ active: false,
+ loading: false,
+ };
+
+ setRef = (c) => {
+ this.dropdown = c;
+ }
+
+ onShowDropdown = () => {
+ this.setState({ active: true });
+
+ if (!EmojiPicker) {
+ this.setState({ loading: true });
+
+ EmojiPickerAsync().then(EmojiMart => {
+ EmojiPicker = EmojiMart.Picker;
+ Emoji = EmojiMart.Emoji;
+
+ this.setState({ loading: false });
+ }).catch(() => {
+ this.setState({ loading: false });
+ });
+ }
+ }
+
+ onHideDropdown = () => {
+ this.setState({ active: false });
+ }
+
+ onToggle = (e) => {
+ if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+ if (this.state.active) {
+ this.onHideDropdown();
+ } else {
+ this.onShowDropdown();
+ }
+ }
+ }
+
+ handleKeyDown = e => {
+ if (e.key === 'Escape') {
+ this.onHideDropdown();
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+ const title = intl.formatMessage(messages.emoji);
+ const { active, loading } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/navigation_bar.js b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..24a70949b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import Permalink from 'themes/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+
+ {this.props.account.get('acct')}
+
+
+
+
+
+ @{this.props.account.get('acct')}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..0cd92d174
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,200 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from 'themes/glitch/components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+ static propTypes = {
+ style: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ value: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ handleClick = e => {
+ if (e.key === 'Escape') {
+ this.props.onClose();
+ } else if (!e.key || e.key === 'Enter') {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onClose();
+ this.props.onChange(value);
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { style, items, value } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ {items.map(item =>
+
+
+
+
+
+
+ {item.text}
+ {item.meta}
+
+
+ )}
+
+ )}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class PrivacyDropdown extends React.PureComponent {
+
+ static propTypes = {
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: false,
+ };
+
+ handleToggle = () => {
+ if (this.props.isUserTouching()) {
+ if (this.state.open) {
+ this.props.onModalClose();
+ } else {
+ this.props.onModalOpen({
+ actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+ onClick: this.handleModalActionClick,
+ });
+ }
+ } else {
+ this.setState({ open: !this.state.open });
+ }
+ }
+
+ handleModalActionClick = (e) => {
+ e.preventDefault();
+
+ const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
+ this.props.onModalClose();
+ this.props.onChange(value);
+ }
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Enter':
+ this.handleToggle();
+ break;
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ }
+
+ handleClose = () => {
+ this.setState({ open: false });
+ }
+
+ handleChange = value => {
+ this.props.onChange(value);
+ }
+
+ componentWillMount () {
+ const { intl: { formatMessage } } = this.props;
+
+ this.options = [
+ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+ { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+ { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+ ];
+ }
+
+ render () {
+ const { value, intl } = this.props;
+ const { open } = this.state;
+
+ const valueOption = this.options.find(item => item.value === value);
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/reply_indicator.js b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..9a8d10ceb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import DisplayName from 'themes/glitch/components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+@injectIntl
+export default class ReplyIndicator extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ onCancel: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onCancel();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search.js b/app/javascript/themes/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..c3218137f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+ static propTypes = {
+ style: PropTypes.object,
+ };
+
+ render () {
+ const { style } = this.props;
+
+ return (
+
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
+
+
+ #example
+ @username@domain
+ URL
+ URL
+
+
+
+
+ )}
+
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class Search extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ submitted: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ onShow: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ }
+
+ handleClear = (e) => {
+ e.preventDefault();
+
+ if (this.props.value.length > 0 || this.props.submitted) {
+ this.props.onClear();
+ }
+ }
+
+ handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ this.props.onSubmit();
+ } else if (e.key === 'Escape') {
+ document.querySelector('.ui').parentElement.focus();
+ }
+ }
+
+ noop () {
+
+ }
+
+ handleFocus = () => {
+ this.setState({ expanded: true });
+ this.props.onShow();
+ }
+
+ handleBlur = () => {
+ this.setState({ expanded: false });
+ }
+
+ render () {
+ const { intl, value, submitted } = this.props;
+ const { expanded } = this.state;
+ const hasValue = value.length > 0 || submitted;
+
+ return (
+
+
+ {intl.formatMessage(messages.placeholder)}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search_results.js b/app/javascript/themes/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..3fdafa5f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search_results.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class SearchResults extends ImmutablePureComponent {
+
+ static propTypes = {
+ results: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { results } = this.props;
+
+ let accounts, statuses, hashtags;
+ let count = 0;
+
+ if (results.get('accounts') && results.get('accounts').size > 0) {
+ count += results.get('accounts').size;
+ accounts = (
+
+ {results.get('accounts').map(accountId =>
)}
+
+ );
+ }
+
+ if (results.get('statuses') && results.get('statuses').size > 0) {
+ count += results.get('statuses').size;
+ statuses = (
+
+ {results.get('statuses').map(statusId => )}
+
+ );
+ }
+
+ if (results.get('hashtags') && results.get('hashtags').size > 0) {
+ count += results.get('hashtags').size;
+ hashtags = (
+
+ {results.get('hashtags').map(hashtag =>
+
+ #{hashtag}
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accounts}
+ {statuses}
+ {hashtags}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/text_icon_button.js b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TextIconButton extends React.PureComponent {
+
+ static propTypes = {
+ label: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ active: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ ariaControls: PropTypes.string,
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+ this.props.onClick();
+ }
+
+ render () {
+ const { label, title, active, ariaControls } = this.props;
+
+ return (
+
+ {label}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload.js b/app/javascript/themes/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..ded376ada
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'themes/glitch/components/icon_button';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+ description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onUndo: PropTypes.func.isRequired,
+ onDescriptionChange: PropTypes.func.isRequired,
+ };
+
+ state = {
+ hovered: false,
+ focused: false,
+ dirtyDescription: null,
+ };
+
+ handleUndoClick = () => {
+ this.props.onUndo(this.props.media.get('id'));
+ }
+
+ handleInputChange = e => {
+ this.setState({ dirtyDescription: e.target.value });
+ }
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ }
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ }
+
+ handleInputFocus = () => {
+ this.setState({ focused: true });
+ }
+
+ handleInputBlur = () => {
+ const { dirtyDescription } = this.state;
+
+ this.setState({ focused: false, dirtyDescription: null });
+
+ if (dirtyDescription !== null) {
+ this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+ }
+ }
+
+ render () {
+ const { intl, media } = this.props;
+ const active = this.state.hovered || this.state.focused;
+ const description = this.state.dirtyDescription || media.get('description') || '';
+
+ return (
+
+
+ {({ scale }) => (
+
+
+
+
+
+ {intl.formatMessage(messages.description)}
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_button.js b/app/javascript/themes/glitch/features/compose/components/upload_button.js
new file mode 100644
index 000000000..d7742adfe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_button.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import IconButton from 'themes/glitch/components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const messages = defineMessages({
+ upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
+});
+
+const makeMapStateToProps = () => {
+ const mapStateToProps = state => ({
+ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+ });
+
+ return mapStateToProps;
+};
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class UploadButton extends ImmutablePureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ onSelectFile: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ resetFileKey: PropTypes.number,
+ acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleChange = (e) => {
+ if (e.target.files.length > 0) {
+ this.props.onSelectFile(e.target.files);
+ }
+ }
+
+ handleClick = () => {
+ this.fileElement.click();
+ }
+
+ setRef = (c) => {
+ this.fileElement = c;
+ }
+
+ render () {
+
+ const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+ return (
+
+
+
+ {intl.formatMessage(messages.upload)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_form.js b/app/javascript/themes/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..b7f112205
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_form.js
@@ -0,0 +1,29 @@
+import React from 'react';
+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';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ mediaIds: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { mediaIds } = this.props;
+
+ return (
+
+
+
+
+ {mediaIds.map(id => (
+
+ ))}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_progress.js b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..b923d0a22
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ progress: PropTypes.number,
+ };
+
+ render () {
+ const { active, progress } = this.props;
+
+ if (!active) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {({ width }) =>
+
+ }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/warning.js b/app/javascript/themes/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..82df55a31
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+ static propTypes = {
+ message: PropTypes.node.isRequired,
+ };
+
+ render () {
+ const { message } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ {message}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js
new file mode 100644
index 000000000..9f168942a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js
@@ -0,0 +1,20 @@
+// Package imports.
+import { connect } from 'react-redux';
+
+// Our imports.
+import { toggleComposeAdvancedOption } from 'themes/glitch/actions/compose';
+import ComposeAdvancedOptions from '../components/advanced_options';
+
+const mapStateToProps = state => ({
+ values: state.getIn(['compose', 'advanced_options']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (option) {
+ dispatch(toggleComposeAdvancedOption(option));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
diff --git a/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..96eb70c18
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from 'themes/glitch/selectors';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { id }) => ({
+ account: getAccount(state, id),
+ });
+
+ return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..7afa988f1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,71 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import { changeComposeVisibility, uploadCompose } from 'themes/glitch/actions/compose';
+import {
+ changeCompose,
+ submitCompose,
+ clearComposeSuggestions,
+ fetchComposeSuggestions,
+ selectComposeSuggestion,
+ changeComposeSpoilerText,
+ insertEmojiCompose,
+} from 'themes/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+ text: state.getIn(['compose', 'text']),
+ suggestion_token: state.getIn(['compose', 'suggestion_token']),
+ suggestions: state.getIn(['compose', 'suggestions']),
+ advanced_options: state.getIn(['compose', 'advanced_options']),
+ spoiler: state.getIn(['compose', 'spoiler']),
+ spoiler_text: state.getIn(['compose', 'spoiler_text']),
+ privacy: state.getIn(['compose', 'privacy']),
+ focusDate: state.getIn(['compose', 'focusDate']),
+ preselectDate: state.getIn(['compose', 'preselectDate']),
+ is_submitting: state.getIn(['compose', 'is_submitting']),
+ is_uploading: state.getIn(['compose', 'is_uploading']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+ settings: state.get('local_settings'),
+ filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+ onChange (text) {
+ dispatch(changeCompose(text));
+ },
+
+ onPrivacyChange (value) {
+ dispatch(changeComposeVisibility(value));
+ },
+
+ onSubmit () {
+ dispatch(submitCompose());
+ },
+
+ onClearSuggestions () {
+ dispatch(clearComposeSuggestions());
+ },
+
+ onFetchSuggestions (token) {
+ dispatch(fetchComposeSuggestions(token));
+ },
+
+ onSuggestionSelected (position, token, accountId) {
+ dispatch(selectComposeSuggestion(position, token, accountId));
+ },
+
+ onChangeSpoilerText (checked) {
+ dispatch(changeComposeSpoilerText(checked));
+ },
+
+ onPaste (files) {
+ dispatch(uploadCompose(files));
+ },
+
+ onPickEmoji (position, data) {
+ dispatch(insertEmojiCompose(position, data));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..55a13bd65
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,82 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from 'themes/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'themes/glitch/actions/emojis';
+
+const perLine = 8;
+const lines = 2;
+
+const DEFAULTS = [
+ '+1',
+ 'grinning',
+ 'kissing_heart',
+ 'heart_eyes',
+ 'laughing',
+ 'stuck_out_tongue_winking_eye',
+ 'sweat_smile',
+ 'joy',
+ 'yum',
+ 'disappointed',
+ 'thinking_face',
+ 'weary',
+ 'sob',
+ 'sunglasses',
+ 'heart',
+ 'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+ state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+ let emojis = emojiCounters
+ .keySeq()
+ .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+ .reverse()
+ .slice(0, perLine * lines)
+ .toArray();
+
+ if (emojis.length < DEFAULTS.length) {
+ emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+ }
+
+ return emojis;
+});
+
+const getCustomEmojis = createSelector([
+ state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+ const aShort = a.get('shortcode').toLowerCase();
+ const bShort = b.get('shortcode').toLowerCase();
+
+ if (aShort < bShort) {
+ return -1;
+ } else if (aShort > bShort ) {
+ return 1;
+ } else {
+ return 0;
+ }
+}));
+
+const mapStateToProps = state => ({
+ custom_emojis: getCustomEmojis(state),
+ skinTone: state.getIn(['settings', 'skinTone']),
+ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+ onSkinTone: skinTone => {
+ dispatch(changeSetting(['skinTone'], skinTone));
+ },
+
+ onPickEmoji: emoji => {
+ dispatch(useEmoji(emoji));
+
+ if (onPickEmoji) {
+ onPickEmoji(emoji);
+ }
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/themes/glitch/features/compose/containers/navigation_container.js b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..b6d737b46
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+import { me } from 'themes/glitch/util/initial_state';
+
+const mapStateToProps = state => {
+ return {
+ account: state.getIn(['accounts', me]),
+ };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..9636ceab2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from 'themes/glitch/actions/compose';
+import { openModal, closeModal } from 'themes/glitch/actions/modal';
+import { isUserTouching } from 'themes/glitch/util/is_mobile';
+
+const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
+ value: state.getIn(['compose', 'privacy']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (value) {
+ dispatch(changeComposeVisibility(value));
+ },
+
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..6dcabb3cd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'themes/glitch/actions/compose';
+import { makeGetStatus } from 'themes/glitch/selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = state => ({
+ status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+ onCancel () {
+ dispatch(cancelReplyCompose());
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/themes/glitch/features/compose/containers/search_container.js b/app/javascript/themes/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..a450d27e7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+ changeSearch,
+ clearSearch,
+ submitSearch,
+ showSearch,
+} from 'themes/glitch/actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+ value: state.getIn(['search', 'value']),
+ submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (value) {
+ dispatch(changeSearch(value));
+ },
+
+ onClear () {
+ dispatch(clearSearch());
+ },
+
+ onSubmit () {
+ dispatch(submitSearch());
+ },
+
+ onShow () {
+ dispatch(showSearch());
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/themes/glitch/features/compose/containers/search_results_container.js b/app/javascript/themes/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..16d95d417
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/search_results_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+
+const mapStateToProps = state => ({
+ results: state.getIn(['search', 'results']),
+});
+
+export default connect(mapStateToProps)(SearchResults);
diff --git a/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..a710dd104
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import IconButton from 'themes/glitch/components/icon_button';
+import { changeComposeSensitivity } from 'themes/glitch/actions/compose';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
+});
+
+const mapStateToProps = state => ({
+ visible: state.getIn(['compose', 'media_attachments']).size > 0,
+ active: state.getIn(['compose', 'sensitive']),
+ disabled: state.getIn(['compose', 'spoiler']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick () {
+ dispatch(changeComposeSensitivity());
+ },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+ static propTypes = {
+ visible: PropTypes.bool,
+ active: PropTypes.bool,
+ disabled: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { visible, active, disabled, onClick, intl } = this.props;
+
+ return (
+
+ {({ scale }) => {
+ const icon = active ? 'eye-slash' : 'eye';
+ const className = classNames('compose-form__sensitive-button', {
+ 'compose-form__sensitive-button--visible': visible,
+ });
+ return (
+
+
+
+ );
+ }}
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js
new file mode 100644
index 000000000..160e71ba9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from 'themes/glitch/actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
+});
+
+const mapStateToProps = (state, { intl }) => ({
+ label: 'CW',
+ title: intl.formatMessage(messages.title),
+ active: state.getIn(['compose', 'spoiler']),
+ ariaControls: 'cw-spoiler-input',
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick () {
+ dispatch(changeComposeSpoilerness());
+ },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js
new file mode 100644
index 000000000..f332eae1a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import UploadButton from '../components/upload_button';
+import { uploadCompose } from 'themes/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+ resetFileKey: state.getIn(['compose', 'resetFileKey']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onSelectFile (files) {
+ dispatch(uploadCompose(files));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..eea514bf5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from 'themes/glitch/actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onUndo: id => {
+ dispatch(undoUploadCompose(id));
+ },
+
+ onDescriptionChange: (id, description) => {
+ dispatch(changeUploadCompose(id, description));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+ mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..0cfee96da
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = state => ({
+ active: state.getIn(['compose', 'is_uploading']),
+ progress: state.getIn(['compose', 'progress']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/themes/glitch/features/compose/containers/warning_container.js b/app/javascript/themes/glitch/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..225d6a1dd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/warning_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+const mapStateToProps = state => ({
+ needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+});
+
+const WarningWrapper = ({ needsLockWarning }) => {
+ if (needsLockWarning) {
+ return }} />} />;
+ }
+
+ return null;
+};
+
+WarningWrapper.propTypes = {
+ needsLockWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/themes/glitch/features/compose/index.js b/app/javascript/themes/glitch/features/compose/index.js
new file mode 100644
index 000000000..3fcaf416f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/index.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { mountCompose, unmountCompose } from 'themes/glitch/actions/compose';
+import { openModal } from 'themes/glitch/actions/modal';
+import { changeLocalSetting } from 'themes/glitch/actions/local_settings';
+import { Link } from 'react-router-dom';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { changeComposing } from 'themes/glitch/actions/compose';
+
+const messages = defineMessages({
+ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+ community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+});
+
+const mapStateToProps = state => ({
+ columns: state.getIn(['settings', 'columns']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Compose extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columns: ImmutablePropTypes.list.isRequired,
+ multiColumn: PropTypes.bool,
+ showSearch: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount () {
+ this.props.dispatch(mountCompose());
+ }
+
+ componentWillUnmount () {
+ this.props.dispatch(unmountCompose());
+ }
+
+ onLayoutClick = (e) => {
+ const layout = e.currentTarget.getAttribute('data-mastodon-layout');
+ this.props.dispatch(changeLocalSetting(['layout'], layout));
+ e.preventDefault();
+ }
+
+ openSettings = () => {
+ this.props.dispatch(openModal('SETTINGS', {}));
+ }
+
+ onFocus = () => {
+ this.props.dispatch(changeComposing(true));
+ }
+
+ onBlur = () => {
+ this.props.dispatch(changeComposing(false));
+ }
+
+ render () {
+ const { multiColumn, showSearch, intl } = this.props;
+
+ let header = '';
+
+ if (multiColumn) {
+ const { columns } = this.props;
+ header = (
+
+
+ {!columns.some(column => column.get('id') === 'HOME') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'COMMUNITY') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'PUBLIC') && (
+
+ )}
+
+
+
+ );
+ }
+
+
+
+ return (
+
+ {header}
+
+
+
+
+
+
+
+
+
+
+ {({ x }) =>
+
+
+
+ }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..2a40c65a5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'direct']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['direct', ...key], checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/direct_timeline/index.js b/app/javascript/themes/glitch/features/direct_timeline/index.js
new file mode 100644
index 000000000..6b29cf94d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/direct_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+ refreshDirectTimeline,
+ expandDirectTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectDirectStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+ title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class DirectTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('DIRECT', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshDirectTimeline());
+ this.disconnect = dispatch(connectDirectStream());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandDirectTimeline());
+ }
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/favourited_statuses/index.js b/app/javascript/themes/glitch/features/favourited_statuses/index.js
new file mode 100644
index 000000000..80345e0e2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/favourited_statuses/index.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'themes/glitch/actions/favourites';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import StatusList from 'themes/glitch/components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Favourites extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFavouritedStatuses());
+ }
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('FAVOURITES', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleScrollToBottom = () => {
+ this.props.dispatch(expandFavouritedStatuses());
+ }
+
+ render () {
+ const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/favourites/index.js b/app/javascript/themes/glitch/features/favourites/index.js
new file mode 100644
index 000000000..d7b8ac3b1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/favourites/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { fetchFavourites } from 'themes/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Favourites extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFavourites(this.props.params.statusId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this.props.dispatch(fetchFavourites(nextProps.params.statusId));
+ }
+ }
+
+ render () {
+ const { accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..ce386d888
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from 'themes/glitch/components/permalink';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import IconButton from 'themes/glitch/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+@injectIntl
+export default class AccountAuthorize extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onAuthorize: PropTypes.func.isRequired,
+ onReject: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl, account, onAuthorize, onReject } = this.props;
+ const content = { __html: account.get('note_emojified') };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..78ae77eee
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from 'themes/glitch/actions/accounts';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+ onAuthorize () {
+ dispatch(authorizeFollowRequest(id));
+ },
+
+ onReject () {
+ dispatch(rejectFollowRequest(id));
+ },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/themes/glitch/features/follow_requests/index.js b/app/javascript/themes/glitch/features/follow_requests/index.js
new file mode 100644
index 000000000..3f44f518a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from 'themes/glitch/actions/accounts';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class FollowRequests extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFollowRequests());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandFollowRequests());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/followers/index.js b/app/javascript/themes/glitch/features/followers/index.js
new file mode 100644
index 000000000..d586bf41d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/followers/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import {
+ fetchAccount,
+ fetchFollowers,
+ expandFollowers,
+} from 'themes/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'themes/glitch/components/load_more';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Followers extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowers(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+ this.props.dispatch(expandFollowers(this.props.params.accountId));
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.dispatch(expandFollowers(this.props.params.accountId));
+ }
+
+ render () {
+ const { accountIds, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {accountIds.map(id =>
)}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/following/index.js b/app/javascript/themes/glitch/features/following/index.js
new file mode 100644
index 000000000..c306faf21
--- /dev/null
+++ b/app/javascript/themes/glitch/features/following/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import {
+ fetchAccount,
+ fetchFollowing,
+ expandFollowing,
+} from 'themes/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'themes/glitch/components/load_more';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Following extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowing(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+ this.props.dispatch(expandFollowing(this.props.params.accountId));
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.dispatch(expandFollowing(this.props.params.accountId));
+ }
+
+ render () {
+ const { accountIds, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {accountIds.map(id =>
)}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/generic_not_found/index.js b/app/javascript/themes/glitch/features/generic_not_found/index.js
new file mode 100644
index 000000000..ccd2b87b2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from 'themes/glitch/features/ui/components/column';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+
+const GenericNotFound = () => (
+
+
+
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/themes/glitch/features/getting_started/index.js b/app/javascript/themes/glitch/features/getting_started/index.js
new file mode 100644
index 000000000..74b019cf1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/getting_started/index.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnLink from 'themes/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'themes/glitch/features/ui/components/column_subheading';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { openModal } from 'themes/glitch/actions/modal';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+ navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
+ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
+ community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+ sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
+ show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
+ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
+});
+
+const mapStateToProps = state => ({
+ myAccount: state.getIn(['accounts', me]),
+ columns: state.getIn(['settings', 'columns']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class GettingStarted extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ myAccount: ImmutablePropTypes.map.isRequired,
+ columns: ImmutablePropTypes.list,
+ multiColumn: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ openSettings = () => {
+ this.props.dispatch(openModal('SETTINGS', {}));
+ }
+
+ openOnboardingModal = (e) => {
+ e.preventDefault();
+ this.props.dispatch(openModal('ONBOARDING'));
+ }
+
+ render () {
+ const { intl, myAccount, columns, multiColumn } = this.props;
+
+ let navItems = [];
+
+ if (multiColumn) {
+ if (!columns.find(item => item.get('id') === 'HOME')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'PUBLIC')) {
+ navItems.push( );
+ }
+ }
+
+ if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+ navItems.push( );
+ }
+
+ navItems = navItems.concat([
+ ,
+ ,
+ ]);
+
+ if (myAccount.get('locked')) {
+ navItems.push( );
+ }
+
+ navItems = navItems.concat([
+ ,
+ ,
+ ]);
+
+ return (
+
+
+
+
+ {navItems}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ •
+
+
+ •
+
+
+
+
+
+ glitch-soc/mastodon,
+ Mastodon: Mastodon ,
+ }}
+ />
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..a878931b3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/hashtag_timeline/index.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+ refreshHashtagTimeline,
+ expandHashtagTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { FormattedMessage } from 'react-intl';
+import { connectHashtagStream } from 'themes/glitch/actions/streaming';
+
+const mapStateToProps = (state, props) => ({
+ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+export default class HashtagTimeline extends React.PureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ _subscribe (dispatch, id) {
+ this.disconnect = dispatch(connectHashtagStream(id));
+ }
+
+ _unsubscribe () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ const { id } = this.props.params;
+
+ dispatch(refreshHashtagTimeline(id));
+ this._subscribe(dispatch, id);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.id !== this.props.params.id) {
+ this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+ this._unsubscribe();
+ this._subscribe(this.props.dispatch, nextProps.params.id);
+ }
+ }
+
+ componentWillUnmount () {
+ this._unsubscribe();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+ }
+
+ render () {
+ const { hasUnread, columnId, multiColumn } = this.props;
+ const { id } = this.props.params;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..533da6c36
--- /dev/null
+++ b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from 'themes/glitch/features/notifications/components/setting_toggle';
+import SettingText from 'themes/glitch/components/setting_text';
+
+const messages = defineMessages({
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+ settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { settings, onChange, intl } = this.props;
+
+ return (
+
+
+
+
+ } />
+
+
+
+ } />
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..a0062f564
--- /dev/null
+++ b/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'home']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['home', ...key], checked));
+ },
+
+ onSave () {
+ dispatch(saveSettings());
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/home_timeline/index.js b/app/javascript/themes/glitch/features/home_timeline/index.js
new file mode 100644
index 000000000..8a65891cd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/home_timeline/index.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from 'themes/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+ title: { id: 'column.home', defaultMessage: 'Home' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class HomeTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('HOME', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHomeTimeline());
+ }
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }} />}
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/index.js b/app/javascript/themes/glitch/features/local_settings/index.js
new file mode 100644
index 000000000..6c5d51413
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/index.js
@@ -0,0 +1,68 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+
+// Our imports
+import LocalSettingsPage from './page';
+import LocalSettingsNavigation from './navigation';
+import { closeModal } from 'themes/glitch/actions/modal';
+import { changeLocalSetting } from 'themes/glitch/actions/local_settings';
+
+// Stylesheet imports
+import './style.scss';
+
+const mapStateToProps = state => ({
+ settings: state.get('local_settings'),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onChange (setting, value) {
+ dispatch(changeLocalSetting(setting, value));
+ },
+ onClose () {
+ dispatch(closeModal());
+ },
+});
+
+class LocalSettings extends React.PureComponent {
+
+ static propTypes = {
+ onChange: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ currentIndex: 0,
+ };
+
+ navigateTo = (index) =>
+ this.setState({ currentIndex: +index });
+
+ render () {
+
+ const { navigateTo } = this;
+ const { onChange, onClose, settings } = this.props;
+ const { currentIndex } = this.state;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/index.js
new file mode 100644
index 000000000..fa35e83c7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/index.js
@@ -0,0 +1,74 @@
+// Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+// Our imports
+import LocalSettingsNavigationItem from './item';
+
+// Stylesheet imports
+import './style.scss';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+ general: { id: 'settings.general', defaultMessage: 'General' },
+ collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
+ media: { id: 'settings.media', defaultMessage: 'Media' },
+ preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
+ close: { id: 'settings.close', defaultMessage: 'Close' },
+});
+
+@injectIntl
+export default class LocalSettingsNavigation extends React.PureComponent {
+
+ static propTypes = {
+ index : PropTypes.number,
+ intl : PropTypes.object.isRequired,
+ onClose : PropTypes.func.isRequired,
+ onNavigate : PropTypes.func.isRequired,
+ };
+
+ render () {
+
+ const { index, intl, onClose, onNavigate } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js
new file mode 100644
index 000000000..a352d5fb2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js
@@ -0,0 +1,69 @@
+// Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+// Stylesheet imports
+import './style.scss';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPage extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ className: PropTypes.string,
+ href: PropTypes.string,
+ icon: PropTypes.string,
+ index: PropTypes.number.isRequired,
+ onNavigate: PropTypes.func,
+ title: PropTypes.string,
+ };
+
+ handleClick = (e) => {
+ const { index, onNavigate } = this.props;
+ if (onNavigate) {
+ onNavigate(index);
+ e.preventDefault();
+ }
+ }
+
+ render () {
+ const { handleClick } = this;
+ const {
+ active,
+ className,
+ href,
+ icon,
+ onNavigate,
+ title,
+ } = this.props;
+
+ const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
+ active,
+ }, className);
+
+ const iconElem = icon ? : null;
+
+ if (href) return (
+
+ {iconElem} {title}
+
+ );
+ else if (onNavigate) return (
+
+ {iconElem} {title}
+
+ );
+ else return null;
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss
new file mode 100644
index 000000000..7f7371993
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss
@@ -0,0 +1,27 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__navigation__item {
+ display: block;
+ padding: 15px 20px;
+ color: inherit;
+ background: $primary-text-color;
+ border-bottom: 1px $ui-primary-color solid;
+ cursor: pointer;
+ text-decoration: none;
+ outline: none;
+ transition: background .3s;
+
+ &:hover {
+ background: $ui-secondary-color;
+ }
+
+ &.active {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ }
+
+ &.close, &.close:hover {
+ background: $error-value-color;
+ color: $primary-text-color;
+ }
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss
new file mode 100644
index 000000000..0336f943b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss
@@ -0,0 +1,10 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__navigation {
+ background: $primary-text-color;
+ color: $ui-base-color;
+ width: 200px;
+ font-size: 15px;
+ line-height: 20px;
+ overflow-y: auto;
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/index.js b/app/javascript/themes/glitch/features/local_settings/page/index.js
new file mode 100644
index 000000000..498230f7b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/index.js
@@ -0,0 +1,212 @@
+// Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+
+// Our imports
+import LocalSettingsPageItem from './item';
+
+// Stylesheet imports
+import './style.scss';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+ layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
+ layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+ layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+ side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
+});
+
+@injectIntl
+export default class LocalSettingsPage extends React.PureComponent {
+
+ static propTypes = {
+ index : PropTypes.number,
+ intl : PropTypes.object.isRequired,
+ onChange : PropTypes.func.isRequired,
+ settings : ImmutablePropTypes.map.isRequired,
+ };
+
+ pages = [
+ ({ intl, onChange, settings }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ ({ onChange, settings }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ ({ onChange, settings }) => (
+
+
+
+
+
+
+
+
+
+ ),
+ ];
+
+ render () {
+ const { pages } = this;
+ const { index, intl, onChange, settings } = this.props;
+ const CurrentPage = pages[index] || pages[0];
+
+ return ;
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/index.js b/app/javascript/themes/glitch/features/local_settings/page/item/index.js
new file mode 100644
index 000000000..37e28c084
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/item/index.js
@@ -0,0 +1,90 @@
+// Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+// Stylesheet imports
+import './style.scss';
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPageItem extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.element.isRequired,
+ dependsOn: PropTypes.array,
+ dependsOnNot: PropTypes.array,
+ id: PropTypes.string.isRequired,
+ item: PropTypes.array.isRequired,
+ onChange: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ })),
+ settings: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleChange = e => {
+ const { target } = e;
+ const { item, onChange, options } = this.props;
+ if (options && options.length > 0) onChange(item, target.value);
+ else onChange(item, target.checked);
+ }
+
+ render () {
+ const { handleChange } = this;
+ const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
+ let enabled = true;
+
+ if (dependsOn) {
+ for (let i = 0; i < dependsOn.length; i++) {
+ enabled = enabled && settings.getIn(dependsOn[i]);
+ }
+ }
+ if (dependsOnNot) {
+ for (let i = 0; i < dependsOnNot.length; i++) {
+ enabled = enabled && !settings.getIn(dependsOnNot[i]);
+ }
+ }
+
+ if (options && options.length > 0) {
+ const currentValue = settings.getIn(item);
+ const optionElems = options && options.length > 0 && options.map((opt) => (
+
+ {opt.message}
+
+ ));
+ return (
+
+ {children}
+
+
+ {optionElems}
+
+
+
+ );
+ } else return (
+
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/style.scss b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss
new file mode 100644
index 000000000..b2d8f7185
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss
@@ -0,0 +1,7 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__page__item {
+ select {
+ margin-bottom: 5px;
+ }
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/style.scss b/app/javascript/themes/glitch/features/local_settings/page/style.scss
new file mode 100644
index 000000000..e9eedcad0
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/style.scss
@@ -0,0 +1,9 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__page {
+ display: block;
+ flex: auto;
+ padding: 15px 20px 15px 20px;
+ width: 360px;
+ overflow-y: auto;
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/style.scss b/app/javascript/themes/glitch/features/local_settings/style.scss
new file mode 100644
index 000000000..765294607
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/style.scss
@@ -0,0 +1,34 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ border-radius: 8px;
+ height: 80vh;
+ width: 80vw;
+ max-width: 740px;
+ max-height: 450px;
+ overflow: hidden;
+
+ label {
+ display: block;
+ }
+
+ h1 {
+ font-size: 18px;
+ font-weight: 500;
+ line-height: 24px;
+ margin-bottom: 20px;
+ }
+
+ h2 {
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 20px;
+ margin-top: 20px;
+ margin-bottom: 10px;
+ }
+}
diff --git a/app/javascript/themes/glitch/features/mutes/index.js b/app/javascript/themes/glitch/features/mutes/index.js
new file mode 100644
index 000000000..1158b8262
--- /dev/null
+++ b/app/javascript/themes/glitch/features/mutes/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import { fetchMutes, expandMutes } from 'themes/glitch/actions/mutes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Mutes extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchMutes());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandMutes());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..22a10753f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class ClearColumnButton extends React.Component {
+
+ static propTypes = {
+ onClick: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/column_settings.js b/app/javascript/themes/glitch/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..88a29d4d3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/column_settings.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ClearColumnButton from './clear_column_button';
+import SettingToggle from './setting_toggle';
+
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ pushSettings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ };
+
+ onPushChange = (key, checked) => {
+ this.props.onChange(['push', ...key], checked);
+ }
+
+ render () {
+ const { settings, pushSettings, onChange, onClear } = this.props;
+
+ const alertStr = ;
+ const showStr = ;
+ const soundStr = ;
+
+ const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+ const pushStr = showPushSettings && ;
+ const pushMeta = showPushSettings && ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/follow.js b/app/javascript/themes/glitch/features/notifications/components/follow.js
new file mode 100644
index 000000000..8a0f01736
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/follow.js
@@ -0,0 +1,97 @@
+// Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+
+// Our imports.
+import Permalink from 'themes/glitch/components/permalink';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+
+export default class NotificationFollow extends ImmutablePureComponent {
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ notification: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleMoveUp = () => {
+ const { notification, onMoveUp } = this.props;
+ onMoveUp(notification.get('id'));
+ }
+
+ handleMoveDown = () => {
+ const { notification, onMoveDown } = this.props;
+ onMoveDown(notification.get('id'));
+ }
+
+ handleOpen = () => {
+ this.handleOpenProfile();
+ }
+
+ handleOpenProfile = () => {
+ const { notification } = this.props;
+ this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+ }
+
+ handleMention = e => {
+ e.preventDefault();
+
+ const { notification, onMention } = this.props;
+ onMention(notification.get('account'), this.context.router.history);
+ }
+
+ getHandlers () {
+ return {
+ moveUp: this.handleMoveUp,
+ moveDown: this.handleMoveDown,
+ open: this.handleOpen,
+ openProfile: this.handleOpenProfile,
+ mention: this.handleMention,
+ reply: this.handleMention,
+ };
+ }
+
+ render () {
+ const { account, notification } = this.props;
+
+ // Links to the display name.
+ const displayName = account.get('display_name_html') || account.get('username');
+ const link = (
+
+ );
+
+ // Renders.
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/notification.js b/app/javascript/themes/glitch/features/notifications/components/notification.js
new file mode 100644
index 000000000..a309d3a42
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/notification.js
@@ -0,0 +1,88 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+// Our imports,
+import StatusContainer from 'themes/glitch/containers/status_container';
+import NotificationFollow from './follow';
+
+export default class Notification extends ImmutablePureComponent {
+
+ static propTypes = {
+ notification: ImmutablePropTypes.map.isRequired,
+ hidden: PropTypes.bool,
+ onMoveUp: PropTypes.func.isRequired,
+ onMoveDown: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
+ };
+
+ renderFollow () {
+ const { notification } = this.props;
+ return (
+
+ );
+ }
+
+ renderMention () {
+ const { notification } = this.props;
+ return (
+
+ );
+ }
+
+ renderFavourite () {
+ const { notification } = this.props;
+ return (
+
+ );
+ }
+
+ renderReblog () {
+ const { notification } = this.props;
+ return (
+
+ );
+ }
+
+ render () {
+ const { notification } = this.props;
+ switch(notification.get('type')) {
+ case 'follow':
+ return this.renderFollow();
+ case 'mention':
+ return this.renderMention();
+ case 'favourite':
+ return this.renderFavourite();
+ case 'reblog':
+ return this.renderReblog();
+ default:
+ return null;
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/overlay.js b/app/javascript/themes/glitch/features/notifications/components/overlay.js
new file mode 100644
index 000000000..e56f9c628
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/overlay.js
@@ -0,0 +1,57 @@
+/**
+ * Notification overlay
+ */
+
+
+// Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
+});
+
+@injectIntl
+export default class NotificationOverlay extends ImmutablePureComponent {
+
+ static propTypes = {
+ notification : ImmutablePropTypes.map.isRequired,
+ onMarkForDelete : PropTypes.func.isRequired,
+ show : PropTypes.bool.isRequired,
+ intl : PropTypes.object.isRequired,
+ };
+
+ onToggleMark = () => {
+ const mark = !this.props.notification.get('markedForDelete');
+ const id = this.props.notification.get('id');
+ this.props.onMarkForDelete(id, mark);
+ }
+
+ render () {
+ const { notification, show, intl } = this.props;
+
+ const active = notification.get('markedForDelete');
+ const label = intl.formatMessage(messages.markForDeletion);
+
+ return show ? (
+
+
+
+ {active ? ( ) : ''}
+
+
+
+ ) : null;
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..281359d2a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class SettingToggle extends React.PureComponent {
+
+ static propTypes = {
+ prefix: PropTypes.string,
+ settings: ImmutablePropTypes.map.isRequired,
+ settingKey: PropTypes.array.isRequired,
+ label: PropTypes.node.isRequired,
+ meta: PropTypes.node,
+ onChange: PropTypes.func.isRequired,
+ }
+
+ onChange = ({ target }) => {
+ this.props.onChange(this.props.settingKey, target.checked);
+ }
+
+ render () {
+ const { prefix, settings, settingKey, label, meta } = this.props;
+ const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
+
+ return (
+
+
+ {label}
+ {meta && {meta} }
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..ddc8495f4
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,44 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from 'themes/glitch/actions/settings';
+import { clearNotifications } from 'themes/glitch/actions/notifications';
+import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from 'themes/glitch/actions/push_notifications';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
+ clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+});
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'notifications']),
+ pushSettings: state.get('push_notifications'),
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onChange (key, checked) {
+ if (key[0] === 'push') {
+ dispatch(changePushNotifications(key.slice(1), checked));
+ } else {
+ dispatch(changeSetting(['notifications', ...key], checked));
+ }
+ },
+
+ onSave () {
+ dispatch(saveSettings());
+ dispatch(savePushNotificationSettings());
+ },
+
+ onClear () {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.clearMessage),
+ confirm: intl.formatMessage(messages.clearConfirm),
+ onConfirm: () => dispatch(clearNotifications()),
+ }));
+ },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/themes/glitch/features/notifications/containers/notification_container.js b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..b61aaa21c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js
@@ -0,0 +1,27 @@
+// Package imports.
+import { connect } from 'react-redux';
+
+// Our imports.
+import { makeGetNotification } from 'themes/glitch/selectors';
+import Notification from '../components/notification';
+import { mentionCompose } from 'themes/glitch/actions/compose';
+
+const makeMapStateToProps = () => {
+ const getNotification = makeGetNotification();
+
+ const mapStateToProps = (state, props) => ({
+ notification: getNotification(state, props.notification, props.accountId),
+ settings: state.get('local_settings'),
+ notifCleaning: state.getIn(['notifications', 'cleaningMode']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+ onMention: (account, router) => {
+ dispatch(mentionCompose(account, router));
+ },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js
new file mode 100644
index 000000000..52649cdd7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js
@@ -0,0 +1,18 @@
+// Package imports.
+import { connect } from 'react-redux';
+
+// Our imports.
+import NotificationOverlay from '../components/overlay';
+import { markNotificationForDelete } from 'themes/glitch/actions/notifications';
+
+const mapDispatchToProps = dispatch => ({
+ onMarkForDelete(id, yes) {
+ dispatch(markNotificationForDelete(id, yes));
+ },
+});
+
+const mapStateToProps = state => ({
+ show: state.getIn(['notifications', 'cleaningMode']),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);
diff --git a/app/javascript/themes/glitch/features/notifications/index.js b/app/javascript/themes/glitch/features/notifications/index.js
new file mode 100644
index 000000000..1ecde660a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/index.js
@@ -0,0 +1,193 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+ enterNotificationClearingMode,
+ expandNotifications,
+ scrollTopNotifications,
+} from 'themes/glitch/actions/notifications';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from 'themes/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+ title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+});
+
+const getNotifications = createSelector([
+ state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+ state => state.getIn(['notifications', 'items']),
+], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+
+const mapStateToProps = state => ({
+ notifications: getNotifications(state),
+ localSettings: state.get('local_settings'),
+ isLoading: state.getIn(['notifications', 'isLoading'], true),
+ isUnread: state.getIn(['notifications', 'unread']) > 0,
+ hasMore: !!state.getIn(['notifications', 'next']),
+ notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+});
+
+/* glitch */
+const mapDispatchToProps = dispatch => ({
+ onEnterCleaningMode(yes) {
+ dispatch(enterNotificationClearingMode(yes));
+ },
+ dispatch,
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class Notifications extends React.PureComponent {
+
+ static propTypes = {
+ columnId: PropTypes.string,
+ notifications: ImmutablePropTypes.list.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ shouldUpdateScroll: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ isLoading: PropTypes.bool,
+ isUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ localSettings: ImmutablePropTypes.map,
+ notifCleaningActive: PropTypes.bool,
+ onEnterCleaningMode: PropTypes.func,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ handleScrollToBottom = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
+ this.props.dispatch(expandNotifications());
+ }, 300, { leading: true });
+
+ handleScrollToTop = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(true));
+ }, 100);
+
+ handleScroll = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
+ }, 100);
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('NOTIFICATIONS', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setColumnRef = c => {
+ this.column = c;
+ }
+
+ handleMoveUp = id => {
+ const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+ this._selectChild(elementIndex);
+ }
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+ this._selectChild(elementIndex);
+ }
+
+ _selectChild (index) {
+ const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ render () {
+ const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
+ const pinned = !!columnId;
+ const emptyMessage = ;
+
+ let scrollableContent = null;
+
+ if (isLoading && this.scrollableContent) {
+ scrollableContent = this.scrollableContent;
+ } else if (notifications.size > 0 || hasMore) {
+ scrollableContent = notifications.map((item) => (
+
+ ));
+ } else {
+ scrollableContent = null;
+ }
+
+ this.scrollableContent = scrollableContent;
+
+ const scrollContainer = (
+
+ {scrollableContent}
+
+ );
+
+ return (
+
+
+
+
+
+ {scrollContainer}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/pinned_statuses/index.js b/app/javascript/themes/glitch/features/pinned_statuses/index.js
new file mode 100644
index 000000000..0a3997850
--- /dev/null
+++ b/app/javascript/themes/glitch/features/pinned_statuses/index.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from 'themes/glitch/actions/pin_statuses';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import StatusList from 'themes/glitch/components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'pins', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PinnedStatuses extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasMore: PropTypes.bool.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchPinnedStatuses());
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ render () {
+ const { intl, statusIds, hasMore } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..0185a7724
--- /dev/null
+++ b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'public']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['public', ...key], checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/public_timeline/index.js b/app/javascript/themes/glitch/features/public_timeline/index.js
new file mode 100644
index 000000000..f5b3865af
--- /dev/null
+++ b/app/javascript/themes/glitch/features/public_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+ refreshPublicTimeline,
+ expandPublicTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+ title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasUnread: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('PUBLIC', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshPublicTimeline());
+ this.disconnect = dispatch(connectPublicStream());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandPublicTimeline());
+ }
+
+ render () {
+ const { intl, columnId, hasUnread, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/reblogs/index.js b/app/javascript/themes/glitch/features/reblogs/index.js
new file mode 100644
index 000000000..8723f7c7c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/reblogs/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { fetchReblogs } from 'themes/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Reblogs extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchReblogs(this.props.params.statusId));
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this.props.dispatch(fetchReblogs(nextProps.params.statusId));
+ }
+ }
+
+ render () {
+ const { accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/report/components/status_check_box.js b/app/javascript/themes/glitch/features/report/components/status_check_box.js
new file mode 100644
index 000000000..cc9232201
--- /dev/null
+++ b/app/javascript/themes/glitch/features/report/components/status_check_box.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class StatusCheckBox extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ checked: PropTypes.bool,
+ onToggle: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ };
+
+ render () {
+ const { status, checked, onToggle, disabled } = this.props;
+ const content = { __html: status.get('contentHtml') };
+
+ if (status.get('reblog')) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js b/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..40d55fb3c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { toggleStatusReport } from 'themes/glitch/actions/reports';
+import { Set as ImmutableSet } from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+ status: state.getIn(['statuses', id]),
+ checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+ onToggle (e) {
+ dispatch(toggleStatusReport(id, e.target.checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/javascript/themes/glitch/features/standalone/compose/index.js b/app/javascript/themes/glitch/features/standalone/compose/index.js
new file mode 100644
index 000000000..8a8118178
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from 'themes/glitch/features/compose/containers/compose_form_container';
+import NotificationsContainer from 'themes/glitch/features/ui/containers/notifications_container';
+import LoadingBarContainer from 'themes/glitch/features/ui/containers/loading_bar_container';
+import ModalContainer from 'themes/glitch/features/ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+ render () {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..7c56f264f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import {
+ refreshHashtagTimeline,
+ expandHashtagTimeline,
+} from 'themes/glitch/actions/timelines';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ hashtag: PropTypes.string.isRequired,
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ componentDidMount () {
+ const { dispatch, hashtag } = this.props;
+
+ dispatch(refreshHashtagTimeline(hashtag));
+
+ this.polling = setInterval(() => {
+ dispatch(refreshHashtagTimeline(hashtag));
+ }, 10000);
+ }
+
+ componentWillUnmount () {
+ if (typeof this.polling !== 'undefined') {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+ }
+
+ render () {
+ const { hashtag } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/standalone/public_timeline/index.js b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js
new file mode 100644
index 000000000..b3fb55288
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import {
+ refreshPublicTimeline,
+ expandPublicTimeline,
+} from 'themes/glitch/actions/timelines';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshPublicTimeline());
+
+ this.polling = setInterval(() => {
+ dispatch(refreshPublicTimeline());
+ }, 3000);
+ }
+
+ componentWillUnmount () {
+ if (typeof this.polling !== 'undefined') {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandPublicTimeline());
+ }
+
+ render () {
+ const { intl } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/action_bar.js b/app/javascript/themes/glitch/features/status/components/action_bar.js
new file mode 100644
index 000000000..6cda988d1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/action_bar.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'themes/glitch/components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onFavourite: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReport: PropTypes.func,
+ onPin: PropTypes.func,
+ onEmbed: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleShare = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ if (me === status.getIn(['account', 'id'])) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+
+ );
+
+ let reblogIcon = 'retweet';
+ //if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+ // else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+
+ let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+
+ return (
+
+
+
+
+ {shareButton}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/card.js b/app/javascript/themes/glitch/features/status/components/card.js
new file mode 100644
index 000000000..bb83374b9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/card.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import punycode from 'punycode';
+import classnames from 'classnames';
+
+const IDNA_PREFIX = 'xn--';
+
+const decodeIDNA = domain => {
+ return domain
+ .split('.')
+ .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+ .join('.');
+};
+
+const getHostname = url => {
+ const parser = document.createElement('a');
+ parser.href = url;
+ return parser.hostname;
+};
+
+export default class Card extends React.PureComponent {
+
+ static propTypes = {
+ card: ImmutablePropTypes.map,
+ maxDescription: PropTypes.number,
+ };
+
+ static defaultProps = {
+ maxDescription: 50,
+ };
+
+ state = {
+ width: 0,
+ };
+
+ renderLink () {
+ const { card, maxDescription } = this.props;
+
+ let image = '';
+ let provider = card.get('provider_name');
+
+ if (card.get('image')) {
+ image = (
+
+
+
+ );
+ }
+
+ if (provider.length < 1) {
+ provider = decodeIDNA(getHostname(card.get('url')));
+ }
+
+ const className = classnames('status-card', {
+ 'horizontal': card.get('width') > card.get('height'),
+ });
+
+ return (
+
+ {image}
+
+
+
{card.get('title')}
+
{(card.get('description') || '').substring(0, maxDescription)}
+
{provider}
+
+
+ );
+ }
+
+ renderPhoto () {
+ const { card } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ setRef = c => {
+ if (c) {
+ this.setState({ width: c.offsetWidth });
+ }
+ }
+
+ renderVideo () {
+ const { card } = this.props;
+ const content = { __html: card.get('html') };
+ const { width } = this.state;
+ const ratio = card.get('width') / card.get('height');
+ const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
+
+ return (
+
+ );
+ }
+
+ render () {
+ const { card } = this.props;
+
+ if (card === null) {
+ return null;
+ }
+
+ switch(card.get('type')) {
+ case 'link':
+ return this.renderLink();
+ case 'photo':
+ return this.renderPhoto();
+ case 'video':
+ return this.renderVideo();
+ case 'rich':
+ default:
+ return null;
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/detailed_status.js b/app/javascript/themes/glitch/features/status/components/detailed_status.js
new file mode 100644
index 000000000..7606bfbf3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/detailed_status.js
@@ -0,0 +1,128 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import StatusContent from 'themes/glitch/components/status_content';
+import StatusGallery from 'themes/glitch/components/media_gallery';
+import AttachmentList from 'themes/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'themes/glitch/features/video';
+import VisibilityIcon from 'themes/glitch/components/status_visibility_icon';
+
+export default class DetailedStatus extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ onOpenVideo: PropTypes.func.isRequired,
+ };
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ e.stopPropagation();
+ }
+
+ // handleOpenVideo = startTime => {
+ // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+ // }
+
+ render () {
+ const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+ const { settings } = this.props;
+
+ let media = '';
+ let mediaIcon = null;
+ let applicationLink = '';
+ let reblogLink = '';
+ let reblogIcon = 'retweet';
+
+ if (status.get('media_attachments').size > 0) {
+ if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+ media = ;
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ media = (
+
+ );
+ mediaIcon = 'video-camera';
+ } else {
+ media = (
+
+ );
+ mediaIcon = 'picture-o';
+ }
+ } else media = ;
+
+ if (status.get('application')) {
+ applicationLink = · {status.getIn(['application', 'name'])} ;
+ }
+
+ if (status.get('visibility') === 'direct') {
+ reblogIcon = 'envelope';
+ } else if (status.get('visibility') === 'private') {
+ reblogIcon = 'lock';
+ }
+
+ if (status.get('visibility') === 'private') {
+ reblogLink = ;
+ } else {
+ reblogLink = (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {applicationLink} · {reblogLink} ·
+
+
+
+
+ ·
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/containers/card_container.js b/app/javascript/themes/glitch/features/status/containers/card_container.js
new file mode 100644
index 000000000..a97404de1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/containers/card_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+ card: state.getIn(['cards', statusId], null),
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/themes/glitch/features/status/index.js b/app/javascript/themes/glitch/features/status/index.js
new file mode 100644
index 000000000..57af94a9a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/index.js
@@ -0,0 +1,343 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from 'themes/glitch/actions/statuses';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from 'themes/glitch/features/ui/components/column';
+import {
+ favourite,
+ unfavourite,
+ reblog,
+ unreblog,
+ pin,
+ unpin,
+} from 'themes/glitch/actions/interactions';
+import {
+ replyCompose,
+ mentionCompose,
+} from 'themes/glitch/actions/compose';
+import { deleteStatus } from 'themes/glitch/actions/statuses';
+import { initReport } from 'themes/glitch/actions/reports';
+import { makeGetStatus } from 'themes/glitch/selectors';
+import { ScrollContainer } from 'react-router-scroll-4';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import { openModal } from 'themes/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, deleteModal } from 'themes/glitch/util/initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props.params.statusId),
+ settings: state.get('local_settings'),
+ ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+ descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
+ });
+
+ return mapStateToProps;
+};
+
+@injectIntl
+@connect(makeMapStateToProps)
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ status: ImmutablePropTypes.map,
+ settings: ImmutablePropTypes.map.isRequired,
+ ancestorsIds: ImmutablePropTypes.list,
+ descendantsIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ fullscreen: false,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchStatus(this.props.params.statusId));
+ }
+
+ componentDidMount () {
+ attachFullscreenListener(this.onFullScreenChange);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this._scrolledIntoView = false;
+ this.props.dispatch(fetchStatus(nextProps.params.statusId));
+ }
+ }
+
+ handleFavouriteClick = (status) => {
+ if (status.get('favourited')) {
+ this.props.dispatch(unfavourite(status));
+ } else {
+ this.props.dispatch(favourite(status));
+ }
+ }
+
+ handlePin = (status) => {
+ if (status.get('pinned')) {
+ this.props.dispatch(unpin(status));
+ } else {
+ this.props.dispatch(pin(status));
+ }
+ }
+
+ handleReplyClick = (status) => {
+ this.props.dispatch(replyCompose(status, this.context.router.history));
+ }
+
+ handleModalReblog = (status) => {
+ this.props.dispatch(reblog(status));
+ }
+
+ handleReblogClick = (status, e) => {
+ if (status.get('reblogged')) {
+ this.props.dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.handleModalReblog(status);
+ } else {
+ this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+ }
+ }
+ }
+
+ handleDeleteClick = (status) => {
+ const { dispatch, intl } = this.props;
+
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ }
+
+ handleMentionClick = (account, router) => {
+ this.props.dispatch(mentionCompose(account, router));
+ }
+
+ handleOpenMedia = (media, index) => {
+ this.props.dispatch(openModal('MEDIA', { media, index }));
+ }
+
+ handleOpenVideo = (media, time) => {
+ this.props.dispatch(openModal('VIDEO', { media, time }));
+ }
+
+ handleReport = (status) => {
+ this.props.dispatch(initReport(status.get('account'), status));
+ }
+
+ handleEmbed = (status) => {
+ this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+ }
+
+ handleHotkeyMoveUp = () => {
+ this.handleMoveUp(this.props.status.get('id'));
+ }
+
+ handleHotkeyMoveDown = () => {
+ this.handleMoveDown(this.props.status.get('id'));
+ }
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.handleReplyClick(this.props.status);
+ }
+
+ handleHotkeyFavourite = () => {
+ this.handleFavouriteClick(this.props.status);
+ }
+
+ handleHotkeyBoost = () => {
+ this.handleReblogClick(this.props.status);
+ }
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.handleMentionClick(this.props.status);
+ }
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ handleMoveUp = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size - 1);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index);
+ } else {
+ this._selectChild(index - 1);
+ }
+ }
+ }
+
+ handleMoveDown = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size + 1);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index + 2);
+ } else {
+ this._selectChild(index + 1);
+ }
+ }
+ }
+
+ _selectChild (index) {
+ const element = this.node.querySelectorAll('.focusable')[index];
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ renderChildren (list) {
+ return list.map(id => (
+
+ ));
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidUpdate () {
+ if (this._scrolledIntoView) {
+ return;
+ }
+
+ const { status, ancestorsIds } = this.props;
+
+ if (status && ancestorsIds && ancestorsIds.size > 0) {
+ const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+ if (element) {
+ element.scrollIntoView(true);
+ this._scrolledIntoView = true;
+ }
+ }
+ }
+
+ componentWillUnmount () {
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ render () {
+ let ancestors, descendants;
+ const { status, settings, ancestorsIds, descendantsIds } = this.props;
+ const { fullscreen } = this.state;
+
+ if (status === null) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (ancestorsIds && ancestorsIds.size > 0) {
+ ancestors = {this.renderChildren(ancestorsIds)}
;
+ }
+
+ if (descendantsIds && descendantsIds.size > 0) {
+ descendants = {this.renderChildren(descendantsIds)}
;
+ }
+
+ const handlers = {
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ openProfile: this.handleHotkeyOpenProfile,
+ };
+
+ return (
+
+
+
+
+
+ {ancestors}
+
+
+
+
+
+ {descendants}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js b/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js
new file mode 100644
index 000000000..1e5e1d8dc
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Column from '../column';
+import ColumnHeader from '../column_header';
+
+describe(' ', () => {
+ describe(' click handler', () => {
+ const originalRaf = global.requestAnimationFrame;
+
+ beforeEach(() => {
+ global.requestAnimationFrame = jest.fn();
+ });
+
+ afterAll(() => {
+ global.requestAnimationFrame = originalRaf;
+ });
+
+ it('runs the scroll animation if the column contains scrollable content', () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper.find(ColumnHeader).simulate('click');
+ expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
+ });
+
+ it('does not try to scroll if there is no scrollable content', () => {
+ const wrapper = mount( );
+ wrapper.find(ColumnHeader).simulate('click');
+ expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
+ });
+ });
+});
diff --git a/app/javascript/themes/glitch/features/ui/components/actions_modal.js b/app/javascript/themes/glitch/features/ui/components/actions_modal.js
new file mode 100644
index 000000000..7a2b78b63
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/actions_modal.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from 'themes/glitch/components/status_content';
+import Avatar from 'themes/glitch/components/avatar';
+import RelativeTimestamp from 'themes/glitch/components/relative_timestamp';
+import DisplayName from 'themes/glitch/components/display_name';
+import IconButton from 'themes/glitch/components/icon_button';
+import classNames from 'classnames';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ actions: PropTypes.array,
+ onClick: PropTypes.func,
+ };
+
+ renderAction = (action, i) => {
+ if (action === null) {
+ return ;
+ }
+
+ const { icon = null, text, meta = null, active = false, href = '#' } = action;
+
+ return (
+
+
+ {icon && }
+
+
+
+ );
+ }
+
+ render () {
+ const status = this.props.status && (
+
+ );
+
+ return (
+
+ {status}
+
+
+ {this.props.actions.map(this.renderAction)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/boost_modal.js b/app/javascript/themes/glitch/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..49781db10
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'themes/glitch/components/button';
+import StatusContent from 'themes/glitch/components/status_content';
+import Avatar from 'themes/glitch/components/avatar';
+import RelativeTimestamp from 'themes/glitch/components/relative_timestamp';
+import DisplayName from 'themes/glitch/components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+@injectIntl
+export default class BoostModal extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleReblog = () => {
+ this.props.onReblog(this.props.status);
+ this.props.onClose();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.props.onClose();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle.js b/app/javascript/themes/glitch/features/ui/components/bundle.js
new file mode 100644
index 000000000..fc88e0c70
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.Component {
+
+ static propTypes = {
+ fetchComponent: PropTypes.func.isRequired,
+ loading: PropTypes.func,
+ error: PropTypes.func,
+ children: PropTypes.func.isRequired,
+ renderDelay: PropTypes.number,
+ onFetch: PropTypes.func,
+ onFetchSuccess: PropTypes.func,
+ onFetchFail: PropTypes.func,
+ }
+
+ static defaultProps = {
+ loading: emptyComponent,
+ error: emptyComponent,
+ renderDelay: 0,
+ onFetch: noop,
+ onFetchSuccess: noop,
+ onFetchFail: noop,
+ }
+
+ static cache = {}
+
+ state = {
+ mod: undefined,
+ forceRender: false,
+ }
+
+ componentWillMount() {
+ this.load(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.fetchComponent !== this.props.fetchComponent) {
+ this.load(nextProps);
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+
+ load = (props) => {
+ const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+
+ onFetch();
+
+ if (Bundle.cache[fetchComponent.name]) {
+ const mod = Bundle.cache[fetchComponent.name];
+
+ this.setState({ mod: mod.default });
+ onFetchSuccess();
+ return Promise.resolve();
+ }
+
+ this.setState({ mod: undefined });
+
+ if (renderDelay !== 0) {
+ this.timestamp = new Date();
+ this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+ }
+
+ return fetchComponent()
+ .then((mod) => {
+ Bundle.cache[fetchComponent.name] = mod;
+ this.setState({ mod: mod.default });
+ onFetchSuccess();
+ })
+ .catch((error) => {
+ this.setState({ mod: null });
+ onFetchFail(error);
+ });
+ }
+
+ render() {
+ const { loading: Loading, error: Error, children, renderDelay } = this.props;
+ const { mod, forceRender } = this.state;
+ const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+ if (mod === undefined) {
+ return (elapsed >= renderDelay || forceRender) ? : null;
+ }
+
+ if (mod === null) {
+ return ;
+ }
+
+ return children(mod);
+ }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js
new file mode 100644
index 000000000..daedc6299
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import Column from './column';
+import ColumnHeader from './column_header';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import IconButton from 'themes/glitch/components/icon_button';
+
+const messages = defineMessages({
+ title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
+ body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
+ retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
+});
+
+class BundleColumnError extends React.Component {
+
+ static propTypes = {
+ onRetry: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ handleRetry = () => {
+ this.props.onRetry();
+ }
+
+ render () {
+ const { intl: { formatMessage } } = this.props;
+
+ return (
+
+
+
+
+
+ {formatMessage(messages.body)}
+
+
+ );
+ }
+
+}
+
+export default injectIntl(BundleColumnError);
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js
new file mode 100644
index 000000000..8cca32ae9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import IconButton from 'themes/glitch/components/icon_button';
+
+const messages = defineMessages({
+ error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
+ retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
+ close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
+});
+
+class BundleModalError extends React.Component {
+
+ static propTypes = {
+ onRetry: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ handleRetry = () => {
+ this.props.onRetry();
+ }
+
+ render () {
+ const { onClose, intl: { formatMessage } } = this.props;
+
+ // Keep the markup in sync with
+ // (make sure they have the same dimensions)
+ return (
+
+
+
+ {formatMessage(messages.error)}
+
+
+
+
+
+ {formatMessage(messages.close)}
+
+
+
+
+ );
+ }
+
+}
+
+export default injectIntl(BundleModalError);
diff --git a/app/javascript/themes/glitch/features/ui/components/column.js b/app/javascript/themes/glitch/features/ui/components/column.js
new file mode 100644
index 000000000..73a5bc15e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+import { debounce } from 'lodash';
+import { scrollTop } from 'themes/glitch/util/scroll';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ heading: PropTypes.string,
+ icon: PropTypes.string,
+ children: PropTypes.node,
+ active: PropTypes.bool,
+ hideHeadingOnMobile: PropTypes.bool,
+ name: PropTypes.string,
+ };
+
+ handleHeaderClick = () => {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+
+ handleScroll = debounce(() => {
+ if (typeof this._interruptScrollAnimation !== 'undefined') {
+ this._interruptScrollAnimation();
+ }
+ }, 200)
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props;
+
+ const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
+
+ const columnHeaderId = showHeading && heading.replace(/ /g, '-');
+ const header = showHeading && (
+
+ );
+ return (
+
+ {header}
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_header.js b/app/javascript/themes/glitch/features/ui/components/column_header.js
new file mode 100644
index 000000000..af195ea9c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_header.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ColumnHeader extends React.PureComponent {
+
+ static propTypes = {
+ icon: PropTypes.string,
+ type: PropTypes.string,
+ active: PropTypes.bool,
+ onClick: PropTypes.func,
+ columnHeaderId: PropTypes.string,
+ };
+
+ handleClick = () => {
+ this.props.onClick();
+ }
+
+ render () {
+ const { type, active, columnHeaderId } = this.props;
+
+ let icon = '';
+
+ if (this.props.icon) {
+ icon = ;
+ }
+
+ return (
+
+ {icon}
+ {type}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_link.js b/app/javascript/themes/glitch/features/ui/components/column_link.js
new file mode 100644
index 000000000..b845d1895
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_link.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
+const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
+ if (href) {
+ return (
+
+
+ {text}
+
+ );
+ } else if (to) {
+ return (
+
+
+ {text}
+
+ );
+ } else {
+ return (
+
+
+ {text}
+
+ );
+ }
+};
+
+ColumnLink.propTypes = {
+ icon: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ to: PropTypes.string,
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ method: PropTypes.string,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/themes/glitch/features/ui/components/column_loading.js b/app/javascript/themes/glitch/features/ui/components/column_loading.js
new file mode 100644
index 000000000..75f26218a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_loading.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class ColumnLoading extends ImmutablePureComponent {
+
+ static propTypes = {
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ icon: PropTypes.string,
+ };
+
+ static defaultProps = {
+ title: '',
+ icon: '',
+ };
+
+ render() {
+ let { title, icon } = this.props;
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_subheading.js b/app/javascript/themes/glitch/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_subheading.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+ return (
+
+ {text}
+
+ );
+};
+
+ColumnSubheading.propTypes = {
+ text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/themes/glitch/features/ui/components/columns_area.js b/app/javascript/themes/glitch/features/ui/components/columns_area.js
new file mode 100644
index 000000000..452950363
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/columns_area.js
@@ -0,0 +1,174 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import ReactSwipeableViews from 'react-swipeable-views';
+import { links, getIndex, getLink } from './tabs_bar';
+
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from 'themes/glitch/util/async-components';
+
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from 'themes/glitch/util/scroll';
+
+const componentMap = {
+ 'COMPOSE': Compose,
+ 'HOME': HomeTimeline,
+ 'NOTIFICATIONS': Notifications,
+ 'PUBLIC': PublicTimeline,
+ 'COMMUNITY': CommunityTimeline,
+ 'HASHTAG': HashtagTimeline,
+ 'DIRECT': DirectTimeline,
+ 'FAVOURITES': FavouritedStatuses,
+};
+
+@component => injectIntl(component, { withRef: true })
+export default class ColumnsArea extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ columns: ImmutablePropTypes.list.isRequired,
+ singleColumn: PropTypes.bool,
+ children: PropTypes.node,
+ };
+
+ state = {
+ shouldAnimate: false,
+ }
+
+ componentWillReceiveProps() {
+ this.setState({ shouldAnimate: false });
+ }
+
+ componentDidMount() {
+ if (!this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+ this.lastIndex = getIndex(this.context.router.history.location.pathname);
+ this.setState({ shouldAnimate: true });
+ }
+
+ componentWillUpdate(nextProps) {
+ if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+ this.lastIndex = getIndex(this.context.router.history.location.pathname);
+ this.setState({ shouldAnimate: true });
+ }
+
+ componentWillUnmount () {
+ if (!this.props.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ handleChildrenContentChange() {
+ if (!this.props.singleColumn) {
+ this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
+ }
+ }
+
+ handleSwipe = (index) => {
+ this.pendingIndex = index;
+
+ const nextLinkTranslationId = links[index].props['data-preview-title-id'];
+ const currentLinkSelector = '.tabs-bar__link.active';
+ const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
+
+ // HACK: Remove the active class from the current link and set it to the next one
+ // React-router does this for us, but too late, feeling laggy.
+ document.querySelector(currentLinkSelector).classList.remove('active');
+ document.querySelector(nextLinkSelector).classList.add('active');
+ }
+
+ handleAnimationEnd = () => {
+ if (typeof this.pendingIndex === 'number') {
+ this.context.router.history.push(getLink(this.pendingIndex));
+ this.pendingIndex = null;
+ }
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ }
+
+ setRef = (node) => {
+ this.node = node;
+ }
+
+ renderView = (link, index) => {
+ const columnIndex = getIndex(this.context.router.history.location.pathname);
+ const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
+ const icon = link.props['data-preview-icon'];
+
+ const view = (index === columnIndex) ?
+ React.cloneElement(this.props.children) :
+ ;
+
+ return (
+
+ {view}
+
+ );
+ }
+
+ renderLoading = columnId => () => {
+ return columnId === 'COMPOSE' ? : ;
+ }
+
+ renderError = (props) => {
+ return ;
+ }
+
+ render () {
+ const { columns, children, singleColumn } = this.props;
+ const { shouldAnimate } = this.state;
+
+ const columnIndex = getIndex(this.context.router.history.location.pathname);
+ this.pendingIndex = null;
+
+ if (singleColumn) {
+ return columnIndex !== -1 ? (
+
+ {links.map(this.renderView)}
+
+ ) : {children}
;
+ }
+
+ return (
+
+ {columns.map(column => {
+ const params = column.get('params', null) === null ? null : column.get('params').toJS();
+
+ return (
+
+ {SpecificComponent => }
+
+ );
+ })}
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..3d568aec3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'themes/glitch/components/button';
+
+@injectIntl
+export default class ConfirmationModal extends React.PureComponent {
+
+ static propTypes = {
+ message: PropTypes.node.isRequired,
+ confirm: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm();
+ }
+
+ handleCancel = () => {
+ this.props.onClose();
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ render () {
+ const { message, confirm } = this.props;
+
+ return (
+
+
+ {message}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/doodle_modal.js b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js
new file mode 100644
index 000000000..819656dbf
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js
@@ -0,0 +1,614 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'themes/glitch/components/button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Atrament from 'atrament'; // the doodling library
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { doodleSet, uploadCompose } from 'themes/glitch/actions/compose';
+import IconButton from 'themes/glitch/components/icon_button';
+import { debounce, mapValues } from 'lodash';
+import classNames from 'classnames';
+
+// palette nicked from MyPaint, CC0
+const palette = [
+ ['rgb( 0, 0, 0)', 'Black'],
+ ['rgb( 38, 38, 38)', 'Gray 15'],
+ ['rgb( 77, 77, 77)', 'Grey 30'],
+ ['rgb(128, 128, 128)', 'Grey 50'],
+ ['rgb(171, 171, 171)', 'Grey 67'],
+ ['rgb(217, 217, 217)', 'Grey 85'],
+ ['rgb(255, 255, 255)', 'White'],
+ ['rgb(128, 0, 0)', 'Maroon'],
+ ['rgb(209, 0, 0)', 'English-red'],
+ ['rgb(255, 54, 34)', 'Tomato'],
+ ['rgb(252, 60, 3)', 'Orange-red'],
+ ['rgb(255, 140, 105)', 'Salmon'],
+ ['rgb(252, 232, 32)', 'Cadium-yellow'],
+ ['rgb(243, 253, 37)', 'Lemon yellow'],
+ ['rgb(121, 5, 35)', 'Dark crimson'],
+ ['rgb(169, 32, 62)', 'Deep carmine'],
+ ['rgb(255, 140, 0)', 'Orange'],
+ ['rgb(255, 168, 18)', 'Dark tangerine'],
+ ['rgb(217, 144, 88)', 'Persian orange'],
+ ['rgb(194, 178, 128)', 'Sand'],
+ ['rgb(255, 229, 180)', 'Peach'],
+ ['rgb(100, 54, 46)', 'Bole'],
+ ['rgb(108, 41, 52)', 'Dark cordovan'],
+ ['rgb(163, 65, 44)', 'Chestnut'],
+ ['rgb(228, 136, 100)', 'Dark salmon'],
+ ['rgb(255, 195, 143)', 'Apricot'],
+ ['rgb(255, 219, 188)', 'Unbleached silk'],
+ ['rgb(242, 227, 198)', 'Straw'],
+ ['rgb( 53, 19, 13)', 'Bistre'],
+ ['rgb( 84, 42, 14)', 'Dark chocolate'],
+ ['rgb(102, 51, 43)', 'Burnt sienna'],
+ ['rgb(184, 66, 0)', 'Sienna'],
+ ['rgb(216, 153, 12)', 'Yellow ochre'],
+ ['rgb(210, 180, 140)', 'Tan'],
+ ['rgb(232, 204, 144)', 'Dark wheat'],
+ ['rgb( 0, 49, 83)', 'Prussian blue'],
+ ['rgb( 48, 69, 119)', 'Dark grey blue'],
+ ['rgb( 0, 71, 171)', 'Cobalt blue'],
+ ['rgb( 31, 117, 254)', 'Blue'],
+ ['rgb(120, 180, 255)', 'Bright french blue'],
+ ['rgb(171, 200, 255)', 'Bright steel blue'],
+ ['rgb(208, 231, 255)', 'Ice blue'],
+ ['rgb( 30, 51, 58)', 'Medium jungle green'],
+ ['rgb( 47, 79, 79)', 'Dark slate grey'],
+ ['rgb( 74, 104, 93)', 'Dark grullo green'],
+ ['rgb( 0, 128, 128)', 'Teal'],
+ ['rgb( 67, 170, 176)', 'Turquoise'],
+ ['rgb(109, 174, 199)', 'Cerulean frost'],
+ ['rgb(173, 217, 186)', 'Tiffany green'],
+ ['rgb( 22, 34, 29)', 'Gray-asparagus'],
+ ['rgb( 36, 48, 45)', 'Medium dark teal'],
+ ['rgb( 74, 104, 93)', 'Xanadu'],
+ ['rgb(119, 198, 121)', 'Mint'],
+ ['rgb(175, 205, 182)', 'Timberwolf'],
+ ['rgb(185, 245, 246)', 'Celeste'],
+ ['rgb(193, 255, 234)', 'Aquamarine'],
+ ['rgb( 29, 52, 35)', 'Cal Poly Pomona'],
+ ['rgb( 1, 68, 33)', 'Forest green'],
+ ['rgb( 42, 128, 0)', 'Napier green'],
+ ['rgb(128, 128, 0)', 'Olive'],
+ ['rgb( 65, 156, 105)', 'Sea green'],
+ ['rgb(189, 246, 29)', 'Green-yellow'],
+ ['rgb(231, 244, 134)', 'Bright chartreuse'],
+ ['rgb(138, 23, 137)', 'Purple'],
+ ['rgb( 78, 39, 138)', 'Violet'],
+ ['rgb(193, 75, 110)', 'Dark thulian pink'],
+ ['rgb(222, 49, 99)', 'Cerise'],
+ ['rgb(255, 20, 147)', 'Deep pink'],
+ ['rgb(255, 102, 204)', 'Rose pink'],
+ ['rgb(255, 203, 219)', 'Pink'],
+ ['rgb(255, 255, 255)', 'White'],
+ ['rgb(229, 17, 1)', 'RGB Red'],
+ ['rgb( 0, 255, 0)', 'RGB Green'],
+ ['rgb( 0, 0, 255)', 'RGB Blue'],
+ ['rgb( 0, 255, 255)', 'CMYK Cyan'],
+ ['rgb(255, 0, 255)', 'CMYK Magenta'],
+ ['rgb(255, 255, 0)', 'CMYK Yellow'],
+];
+
+// re-arrange to the right order for display
+let palReordered = [];
+for (let row = 0; row < 7; row++) {
+ for (let col = 0; col < 11; col++) {
+ palReordered.push(palette[col * 7 + row]);
+ }
+ palReordered.push(null); // null indicates a
+}
+
+// Utility for converting base64 image to binary for upload
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
+function dataURLtoFile(dataurl, filename) {
+ let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+ bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+ while(n--){
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ return new File([u8arr], filename, { type: mime });
+}
+
+const DOODLE_SIZES = {
+ normal: [500, 500, 'Square 500'],
+ tootbanner: [702, 330, 'Tootbanner'],
+ s640x480: [640, 480, '640×480 - 480p'],
+ s800x600: [800, 600, '800×600 - SVGA'],
+ s720x480: [720, 405, '720x405 - 16:9'],
+};
+
+
+const mapStateToProps = state => ({
+ options: state.getIn(['compose', 'doodle']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ /** Set options in the redux store */
+ setOpt: (opts) => dispatch(doodleSet(opts)),
+ /** Submit doodle for upload */
+ submit: (file) => dispatch(uploadCompose([file])),
+});
+
+/**
+ * Doodling dialog with drawing canvas
+ *
+ * Keyboard shortcuts:
+ * - Delete: Clear screen, fill with background color
+ * - Backspace, Ctrl+Z: Undo one step
+ * - Ctrl held while drawing: Use background color
+ * - Shift held while clicking screen: Use fill tool
+ *
+ * Palette:
+ * - Left mouse button: pick foreground
+ * - Ctrl + left mouse button: pick background
+ * - Right mouse button: pick background
+ */
+@connect(mapStateToProps, mapDispatchToProps)
+export default class DoodleModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ options: ImmutablePropTypes.map,
+ onClose: PropTypes.func.isRequired,
+ setOpt: PropTypes.func.isRequired,
+ submit: PropTypes.func.isRequired,
+ };
+
+ //region Option getters/setters
+
+ /** Foreground color */
+ get fg () {
+ return this.props.options.get('fg');
+ }
+ set fg (value) {
+ this.props.setOpt({ fg: value });
+ }
+
+ /** Background color */
+ get bg () {
+ return this.props.options.get('bg');
+ }
+ set bg (value) {
+ this.props.setOpt({ bg: value });
+ }
+
+ /** Swap Fg and Bg for drawing */
+ get swapped () {
+ return this.props.options.get('swapped');
+ }
+ set swapped (value) {
+ this.props.setOpt({ swapped: value });
+ }
+
+ /** Mode - 'draw' or 'fill' */
+ get mode () {
+ return this.props.options.get('mode');
+ }
+ set mode (value) {
+ this.props.setOpt({ mode: value });
+ }
+
+ /** Base line weight */
+ get weight () {
+ return this.props.options.get('weight');
+ }
+ set weight (value) {
+ this.props.setOpt({ weight: value });
+ }
+
+ /** Drawing opacity */
+ get opacity () {
+ return this.props.options.get('opacity');
+ }
+ set opacity (value) {
+ this.props.setOpt({ opacity: value });
+ }
+
+ /** Adaptive stroke - change width with speed */
+ get adaptiveStroke () {
+ return this.props.options.get('adaptiveStroke');
+ }
+ set adaptiveStroke (value) {
+ this.props.setOpt({ adaptiveStroke: value });
+ }
+
+ /** Smoothing (for mouse drawing) */
+ get smoothing () {
+ return this.props.options.get('smoothing');
+ }
+ set smoothing (value) {
+ this.props.setOpt({ smoothing: value });
+ }
+
+ /** Size preset */
+ get size () {
+ return this.props.options.get('size');
+ }
+ set size (value) {
+ this.props.setOpt({ size: value });
+ }
+
+ //endregion
+
+ /** Key up handler */
+ handleKeyUp = (e) => {
+ if (e.target.nodeName === 'INPUT') return;
+
+ if (e.key === 'Delete') {
+ e.preventDefault();
+ this.handleClearBtn();
+ return;
+ }
+
+ if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
+ e.preventDefault();
+ this.undo();
+ }
+
+ if (e.key === 'Control' || e.key === 'Meta') {
+ this.controlHeld = false;
+ this.swapped = false;
+ }
+
+ if (e.key === 'Shift') {
+ this.shiftHeld = false;
+ this.mode = 'draw';
+ }
+ };
+
+ /** Key down handler */
+ handleKeyDown = (e) => {
+ if (e.key === 'Control' || e.key === 'Meta') {
+ this.controlHeld = true;
+ this.swapped = true;
+ }
+
+ if (e.key === 'Shift') {
+ this.shiftHeld = true;
+ this.mode = 'fill';
+ }
+ };
+
+ /**
+ * Component installed in the DOM, do some initial set-up
+ */
+ componentDidMount () {
+ this.controlHeld = false;
+ this.shiftHeld = false;
+ this.swapped = false;
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ window.addEventListener('keydown', this.handleKeyDown, false);
+ };
+
+ /**
+ * Tear component down
+ */
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp, false);
+ window.removeEventListener('keydown', this.handleKeyDown, false);
+ if (this.sketcher) this.sketcher.destroy();
+ }
+
+ /**
+ * Set reference to the canvas element.
+ * This is called during component init
+ *
+ * @param elem - canvas element
+ */
+ setCanvasRef = (elem) => {
+ this.canvas = elem;
+ if (elem) {
+ elem.addEventListener('dirty', () => {
+ this.saveUndo();
+ this.sketcher._dirty = false;
+ });
+
+ elem.addEventListener('click', () => {
+ // sketcher bug - does not fire dirty on fill
+ if (this.mode === 'fill') {
+ this.saveUndo();
+ }
+ });
+
+ // prevent context menu
+ elem.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ });
+
+ elem.addEventListener('mousedown', (e) => {
+ if (e.button === 2) {
+ this.swapped = true;
+ }
+ });
+
+ elem.addEventListener('mouseup', (e) => {
+ if (e.button === 2) {
+ this.swapped = this.controlHeld;
+ }
+ });
+
+ this.initSketcher(elem);
+ this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+ }
+ };
+
+ /**
+ * Set up the sketcher instance
+ *
+ * @param canvas - canvas element. Null if we're just resizing
+ */
+ initSketcher (canvas = null) {
+ const sizepreset = DOODLE_SIZES[this.size];
+
+ if (this.sketcher) this.sketcher.destroy();
+ this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
+
+ if (canvas) {
+ this.ctx = this.sketcher.context;
+ this.updateSketcherSettings();
+ }
+
+ this.clearScreen();
+ }
+
+ /**
+ * Done button handler
+ */
+ onDoneButton = () => {
+ const dataUrl = this.sketcher.toImage();
+ const file = dataURLtoFile(dataUrl, 'doodle.png');
+ this.props.submit(file);
+ this.props.onClose(); // close dialog
+ };
+
+ /**
+ * Cancel button handler
+ */
+ onCancelButton = () => {
+ if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
+ return;
+ }
+
+ this.props.onClose(); // close dialog
+ };
+
+ /**
+ * Update sketcher options based on state
+ */
+ updateSketcherSettings () {
+ if (!this.sketcher) return;
+
+ if (this.oldSize !== this.size) this.initSketcher();
+
+ this.sketcher.color = (this.swapped ? this.bg : this.fg);
+ this.sketcher.opacity = this.opacity;
+ this.sketcher.weight = this.weight;
+ this.sketcher.mode = this.mode;
+ this.sketcher.smoothing = this.smoothing;
+ this.sketcher.adaptiveStroke = this.adaptiveStroke;
+
+ this.oldSize = this.size;
+ }
+
+ /**
+ * Fill screen with background color
+ */
+ clearScreen = () => {
+ this.ctx.fillStyle = this.bg;
+ this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
+ this.undos = [];
+
+ this.doSaveUndo();
+ };
+
+ /**
+ * Undo one step
+ */
+ undo = () => {
+ if (this.undos.length > 1) {
+ this.undos.pop();
+ const buf = this.undos.pop();
+
+ this.sketcher.clear();
+ this.ctx.putImageData(buf, 0, 0);
+ this.doSaveUndo();
+ }
+ };
+
+ /**
+ * Save canvas content into the undo buffer immediately
+ */
+ doSaveUndo = () => {
+ this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
+ };
+
+ /**
+ * Called on each canvas change.
+ * Saves canvas content to the undo buffer after some period of inactivity.
+ */
+ saveUndo = debounce(() => {
+ this.doSaveUndo();
+ }, 100);
+
+ /**
+ * Palette left click.
+ * Selects Fg color (or Bg, if Control/Meta is held)
+ *
+ * @param e - event
+ */
+ onPaletteClick = (e) => {
+ const c = e.target.dataset.color;
+
+ if (this.controlHeld) {
+ this.bg = c;
+ } else {
+ this.fg = c;
+ }
+
+ e.target.blur();
+ e.preventDefault();
+ };
+
+ /**
+ * Palette right click.
+ * Selects Bg color
+ *
+ * @param e - event
+ */
+ onPaletteRClick = (e) => {
+ this.bg = e.target.dataset.color;
+ e.target.blur();
+ e.preventDefault();
+ };
+
+ /**
+ * Handle click on the Draw mode button
+ *
+ * @param e - event
+ */
+ setModeDraw = (e) => {
+ this.mode = 'draw';
+ e.target.blur();
+ };
+
+ /**
+ * Handle click on the Fill mode button
+ *
+ * @param e - event
+ */
+ setModeFill = (e) => {
+ this.mode = 'fill';
+ e.target.blur();
+ };
+
+ /**
+ * Handle click on Smooth checkbox
+ *
+ * @param e - event
+ */
+ tglSmooth = (e) => {
+ this.smoothing = !this.smoothing;
+ e.target.blur();
+ };
+
+ /**
+ * Handle click on Adaptive checkbox
+ *
+ * @param e - event
+ */
+ tglAdaptive = (e) => {
+ this.adaptiveStroke = !this.adaptiveStroke;
+ e.target.blur();
+ };
+
+ /**
+ * Handle change of the Weight input field
+ *
+ * @param e - event
+ */
+ setWeight = (e) => {
+ this.weight = +e.target.value || 1;
+ };
+
+ /**
+ * Set size - clalback from the select box
+ *
+ * @param e - event
+ */
+ changeSize = (e) => {
+ let newSize = e.target.value;
+ if (newSize === this.oldSize) return;
+
+ if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
+ return;
+ }
+
+ this.size = newSize;
+ };
+
+ handleClearBtn = () => {
+ if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
+ return;
+ }
+
+ this.clearScreen();
+ };
+
+ /**
+ * Render the component
+ */
+ render () {
+ this.updateSketcherSettings();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Smoothing
+
+
+
+
+
+ Adaptive
+
+
+
+
+
+ Weight
+
+
+
+
+
+
+ { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
+ {val[2]}
+ )) }
+
+
+
+
+
+
+
+
+
+
+ {
+ palReordered.map((c, i) =>
+ c === null ?
+ :
+
+ )
+ }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/drawer_loading.js b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DrawerLoading = () => (
+
+);
+
+export default DrawerLoading;
diff --git a/app/javascript/themes/glitch/features/ui/components/embed_modal.js b/app/javascript/themes/glitch/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..1afffb51b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import axios from 'axios';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ url: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ state = {
+ loading: false,
+ oembed: null,
+ };
+
+ componentDidMount () {
+ const { url } = this.props;
+
+ this.setState({ loading: true });
+
+ axios.post('/api/web/embed', { url }).then(res => {
+ this.setState({ loading: false, oembed: res.data });
+
+ const iframeDocument = this.iframe.contentWindow.document;
+
+ iframeDocument.open();
+ iframeDocument.write(res.data.html);
+ iframeDocument.close();
+
+ iframeDocument.body.style.margin = 0;
+ this.iframe.width = iframeDocument.body.scrollWidth;
+ this.iframe.height = iframeDocument.body.scrollHeight;
+ });
+ }
+
+ setIframeRef = c => {
+ this.iframe = c;
+ }
+
+ handleTextareaClick = (e) => {
+ e.target.select();
+ }
+
+ render () {
+ const { oembed } = this.state;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/image_loader.js b/app/javascript/themes/glitch/features/ui/components/image_loader.js
new file mode 100644
index 000000000..aad594380
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/image_loader.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class ImageLoader extends React.PureComponent {
+
+ static propTypes = {
+ alt: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ previewSrc: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ }
+
+ static defaultProps = {
+ alt: '',
+ width: null,
+ height: null,
+ };
+
+ state = {
+ loading: true,
+ error: false,
+ }
+
+ removers = [];
+
+ get canvasContext() {
+ if (!this.canvas) {
+ return null;
+ }
+ this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+ return this._canvasContext;
+ }
+
+ componentDidMount () {
+ this.loadImage(this.props);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.src !== nextProps.src) {
+ this.loadImage(nextProps);
+ }
+ }
+
+ loadImage (props) {
+ this.removeEventListeners();
+ this.setState({ loading: true, error: false });
+ Promise.all([
+ this.loadPreviewCanvas(props),
+ this.hasSize() && this.loadOriginalImage(props),
+ ].filter(Boolean))
+ .then(() => {
+ this.setState({ loading: false, error: false });
+ this.clearPreviewCanvas();
+ })
+ .catch(() => this.setState({ loading: false, error: true }));
+ }
+
+ loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ this.canvasContext.drawImage(image, 0, 0, width, height);
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = previewSrc;
+ this.removers.push(removeEventListeners);
+ })
+
+ clearPreviewCanvas () {
+ const { width, height } = this.canvas;
+ this.canvasContext.clearRect(0, 0, width, height);
+ }
+
+ loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = src;
+ this.removers.push(removeEventListeners);
+ });
+
+ removeEventListeners () {
+ this.removers.forEach(listeners => listeners());
+ this.removers = [];
+ }
+
+ hasSize () {
+ const { width, height } = this.props;
+ return typeof width === 'number' && typeof height === 'number';
+ }
+
+ setCanvasRef = c => {
+ this.canvas = c;
+ }
+
+ render () {
+ const { alt, src, width, height } = this.props;
+ const { loading } = this.state;
+
+ const className = classNames('image-loader', {
+ 'image-loader--loading': loading,
+ 'image-loader--amorphous': !this.hasSize(),
+ });
+
+ return (
+
+
+
+ {!loading && (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/media_modal.js b/app/javascript/themes/glitch/features/ui/components/media_modal.js
new file mode 100644
index 000000000..1dad972b2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/media_modal.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from 'themes/glitch/components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'themes/glitch/components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+@injectIntl
+export default class MediaModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ index: PropTypes.number.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ index: null,
+ };
+
+ handleSwipe = (index) => {
+ this.setState({ index: index % this.props.media.size });
+ }
+
+ handleNextClick = () => {
+ this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
+ }
+
+ handlePrevClick = () => {
+ this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
+ }
+
+ handleChangeIndex = (e) => {
+ const index = Number(e.currentTarget.getAttribute('data-index'));
+ this.setState({ index: index % this.props.media.size });
+ }
+
+ handleKeyUp = (e) => {
+ switch(e.key) {
+ case 'ArrowLeft':
+ this.handlePrevClick();
+ break;
+ case 'ArrowRight':
+ this.handleNextClick();
+ break;
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ getIndex () {
+ return this.state.index !== null ? this.state.index : this.props.index;
+ }
+
+ render () {
+ const { media, intl, onClose } = this.props;
+
+ const index = this.getIndex();
+ let pagination = [];
+
+ const leftNav = media.size > 1 && ;
+ const rightNav = media.size > 1 && ;
+
+ if (media.size > 1) {
+ pagination = media.map((item, i) => {
+ const classes = ['media-modal__button'];
+ if (i === index) {
+ classes.push('media-modal__button--active');
+ }
+ return ({i + 1} );
+ });
+ }
+
+ const content = media.map((image) => {
+ const width = image.getIn(['meta', 'original', 'width']) || null;
+ const height = image.getIn(['meta', 'original', 'height']) || null;
+
+ if (image.get('type') === 'image') {
+ return ;
+ } else if (image.get('type') === 'gifv') {
+ return ;
+ }
+
+ return null;
+ }).toArray();
+
+ const containerStyle = {
+ alignItems: 'center', // center vertically
+ };
+
+ return (
+
+ {leftNav}
+
+
+
+
+ {content}
+
+
+
+
+ {rightNav}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/modal_loading.js b/app/javascript/themes/glitch/features/ui/components/modal_loading.js
new file mode 100644
index 000000000..e14d20fbb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/modal_loading.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+
+// Keep the markup in sync with
+// (make sure they have the same dimensions)
+const ModalLoading = () => (
+
+);
+
+export default ModalLoading;
diff --git a/app/javascript/themes/glitch/features/ui/components/modal_root.js b/app/javascript/themes/glitch/features/ui/components/modal_root.js
new file mode 100644
index 000000000..fbe794170
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/modal_root.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import DoodleModal from './doodle_modal';
+import ConfirmationModal from './confirmation_modal';
+import {
+ OnboardingModal,
+ MuteModal,
+ ReportModal,
+ SettingsModal,
+ EmbedModal,
+} from 'themes/glitch/util/async-components';
+
+const MODAL_COMPONENTS = {
+ 'MEDIA': () => Promise.resolve({ default: MediaModal }),
+ 'ONBOARDING': OnboardingModal,
+ 'VIDEO': () => Promise.resolve({ default: VideoModal }),
+ 'BOOST': () => Promise.resolve({ default: BoostModal }),
+ 'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+ 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+ 'MUTE': MuteModal,
+ 'REPORT': ReportModal,
+ 'SETTINGS': SettingsModal,
+ 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+ 'EMBED': EmbedModal,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+ static propTypes = {
+ type: PropTypes.string,
+ props: PropTypes.object,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ state = {
+ revealed: false,
+ };
+
+ handleKeyUp = (e) => {
+ if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+ && !!this.props.type && !this.props.props.noEsc) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!!nextProps.type && !this.props.type) {
+ this.activeElement = document.activeElement;
+
+ this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+ } else if (!nextProps.type) {
+ this.setState({ revealed: false });
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (!this.props.type && !!prevProps.type) {
+ this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+ this.activeElement.focus();
+ this.activeElement = null;
+ }
+ if (this.props.type) {
+ requestAnimationFrame(() => {
+ this.setState({ revealed: true });
+ });
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ getSiblings = () => {
+ return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+ }
+
+ setRef = ref => {
+ this.node = ref;
+ }
+
+ renderLoading = modalId => () => {
+ return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
+ }
+
+ renderError = (props) => {
+ const { onClose } = this.props;
+
+ return ;
+ }
+
+ render () {
+ const { type, props, onClose } = this.props;
+ const { revealed } = this.state;
+ const visible = !!type;
+
+ if (!visible) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {
+ visible ?
+ (
+ {(SpecificComponent) => }
+ ) :
+ null
+ }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/mute_modal.js b/app/javascript/themes/glitch/features/ui/components/mute_modal.js
new file mode 100644
index 000000000..ffccdc84d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/mute_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Button from 'themes/glitch/components/button';
+import { closeModal } from 'themes/glitch/actions/modal';
+import { muteAccount } from 'themes/glitch/actions/accounts';
+import { toggleHideNotifications } from 'themes/glitch/actions/mutes';
+
+
+const mapStateToProps = state => {
+ return {
+ isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+ account: state.getIn(['mutes', 'new', 'account']),
+ notifications: state.getIn(['mutes', 'new', 'notifications']),
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onConfirm(account, notifications) {
+ dispatch(muteAccount(account.get('id'), notifications));
+ },
+
+ onClose() {
+ dispatch(closeModal());
+ },
+
+ onToggleNotifications() {
+ dispatch(toggleHideNotifications());
+ },
+ };
+};
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class MuteModal extends React.PureComponent {
+
+ static propTypes = {
+ isSubmitting: PropTypes.bool.isRequired,
+ account: PropTypes.object.isRequired,
+ notifications: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onToggleNotifications: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm(this.props.account, this.props.notifications);
+ }
+
+ handleCancel = () => {
+ this.props.onClose();
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ toggleNotifications = () => {
+ this.props.onToggleNotifications();
+ }
+
+ render () {
+ const { account, notifications } = this.props;
+
+ return (
+
+
+
+ @{account.get('acct')} }}
+ />
+
+
+
+
+ {' '}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..58875262e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js
@@ -0,0 +1,323 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ReactSwipeableViews from 'react-swipeable-views';
+import classNames from 'classnames';
+import Permalink from 'themes/glitch/components/permalink';
+import ComposeForm from 'themes/glitch/features/compose/components/compose_form';
+import Search from 'themes/glitch/features/compose/components/search';
+import NavigationBar from 'themes/glitch/features/compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import {
+ List as ImmutableList,
+ Map as ImmutableMap,
+} from 'immutable';
+import { me } from 'themes/glitch/util/initial_state';
+
+const noop = () => { };
+
+const messages = defineMessages({
+ home_title: { id: 'column.home', defaultMessage: 'Home' },
+ notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+ local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+ federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const PageOne = ({ acct, domain }) => (
+
+
+
+
+
+
+
@{acct}@{domain} }} />
+
+
+);
+
+PageOne.propTypes = {
+ acct: PropTypes.string.isRequired,
+ domain: PropTypes.string.isRequired,
+};
+
+const PageTwo = ({ myAccount }) => (
+
+);
+
+PageTwo.propTypes = {
+ myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ myAccount }) => (
+
+
+
+
#illustration, introductions: #introductions }} />
+
+
+);
+
+PageThree.propTypes = {
+ myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageFour = ({ domain, intl }) => (
+
+);
+
+PageFour.propTypes = {
+ domain: PropTypes.string.isRequired,
+ intl: PropTypes.object.isRequired,
+};
+
+const PageSix = ({ admin, domain }) => {
+ let adminSection = '';
+
+ if (admin) {
+ adminSection = (
+
+ @{admin.get('acct')} }} />
+
+ }} />
+
+ );
+ }
+
+ return (
+
+
+ {adminSection}
+
fork, Mastodon: Mastodon , github: GitHub }} />
+
}} />
+
+
+ );
+};
+
+PageSix.propTypes = {
+ admin: ImmutablePropTypes.map,
+ domain: PropTypes.string.isRequired,
+};
+
+const mapStateToProps = state => ({
+ myAccount: state.getIn(['accounts', me]),
+ admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+ domain: state.getIn(['meta', 'domain']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class OnboardingModal extends React.PureComponent {
+
+ static propTypes = {
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ myAccount: ImmutablePropTypes.map.isRequired,
+ domain: PropTypes.string.isRequired,
+ admin: ImmutablePropTypes.map,
+ };
+
+ state = {
+ currentIndex: 0,
+ };
+
+ componentWillMount() {
+ const { myAccount, admin, domain, intl } = this.props;
+ this.pages = [
+ ,
+ ,
+ ,
+ ,
+ ,
+ ];
+ };
+
+ componentDidMount() {
+ window.addEventListener('keyup', this.handleKeyUp);
+ }
+
+ componentWillUnmount() {
+ window.addEventListener('keyup', this.handleKeyUp);
+ }
+
+ handleSkip = (e) => {
+ e.preventDefault();
+ this.props.onClose();
+ }
+
+ handleDot = (e) => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.setState({ currentIndex: i });
+ }
+
+ handlePrev = () => {
+ this.setState(({ currentIndex }) => ({
+ currentIndex: Math.max(0, currentIndex - 1),
+ }));
+ }
+
+ handleNext = () => {
+ const { pages } = this;
+ this.setState(({ currentIndex }) => ({
+ currentIndex: Math.min(currentIndex + 1, pages.length - 1),
+ }));
+ }
+
+ handleSwipe = (index) => {
+ this.setState({ currentIndex: index });
+ }
+
+ handleKeyUp = ({ key }) => {
+ switch (key) {
+ case 'ArrowLeft':
+ this.handlePrev();
+ break;
+ case 'ArrowRight':
+ this.handleNext();
+ break;
+ }
+ }
+
+ handleClose = () => {
+ this.props.onClose();
+ }
+
+ render () {
+ const { pages } = this;
+ const { currentIndex } = this.state;
+ const hasMore = currentIndex < pages.length - 1;
+
+ const nextOrDoneBtn = hasMore ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+
+ {pages.map((page, i) => {
+ const className = classNames('onboarding-modal__page__wrapper', {
+ 'onboarding-modal__page__wrapper--active': i === currentIndex,
+ });
+ return (
+ {page}
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ {pages.map((_, i) => {
+ const className = classNames('onboarding-modal__dot', {
+ active: i === currentIndex,
+ });
+ return (
+
+ );
+ })}
+
+
+
+ {nextOrDoneBtn}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/report_modal.js b/app/javascript/themes/glitch/features/ui/components/report_modal.js
new file mode 100644
index 000000000..e6153948e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/report_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { changeReportComment, submitReport } from 'themes/glitch/actions/reports';
+import { refreshAccountTimeline } from 'themes/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from 'themes/glitch/features/report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Button from 'themes/glitch/components/button';
+
+const messages = defineMessages({
+ placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+ submit: { id: 'report.submit', defaultMessage: 'Submit' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = state => {
+ const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+ return {
+ isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+ account: getAccount(state, accountId),
+ comment: state.getIn(['reports', 'new', 'comment']),
+ statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+ };
+ };
+
+ return mapStateToProps;
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class ReportModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ isSubmitting: PropTypes.bool,
+ account: ImmutablePropTypes.map,
+ statusIds: ImmutablePropTypes.orderedSet.isRequired,
+ comment: PropTypes.string.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleCommentChange = (e) => {
+ this.props.dispatch(changeReportComment(e.target.value));
+ }
+
+ handleSubmit = () => {
+ this.props.dispatch(submitReport());
+ }
+
+ componentDidMount () {
+ this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.account !== nextProps.account && nextProps.account) {
+ this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
+ }
+ }
+
+ render () {
+ const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ return (
+
+
+ {account.get('acct')} }} />
+
+
+
+
+
+ {statusIds.map(statusId => )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/tabs_bar.js b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..ef5deae99
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from 'themes/glitch/util/is_mobile';
+
+export const links = [
+ ,
+ ,
+ ,
+
+ ,
+ ,
+
+ ,
+];
+
+export function getIndex (path) {
+ return links.findIndex(link => link.props.to === path);
+}
+
+export function getLink (index) {
+ return links[index].props.to;
+}
+
+@injectIntl
+export default class TabsBar extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ }
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ }
+
+ setRef = ref => {
+ this.node = ref;
+ }
+
+ handleClick = (e) => {
+ // Only apply optimization for touch devices, which we assume are slower
+ // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
+ if (isUserTouching()) {
+ e.preventDefault();
+ e.persist();
+
+ requestAnimationFrame(() => {
+ const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
+ const currentTab = tabs.find(tab => tab.classList.contains('active'));
+ const nextTab = tabs.find(tab => tab.contains(e.target));
+ const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
+
+
+ if (currentTab !== nextTab) {
+ if (currentTab) {
+ currentTab.classList.remove('active');
+ }
+
+ const listener = debounce(() => {
+ nextTab.removeEventListener('transitionend', listener);
+ this.context.router.history.push(to);
+ }, 50);
+
+ nextTab.addEventListener('transitionend', listener);
+ nextTab.classList.add('active');
+ }
+ });
+ }
+
+ }
+
+ render () {
+ const { intl: { formatMessage } } = this.props;
+
+ return (
+
+ {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/upload_area.js b/app/javascript/themes/glitch/features/ui/components/upload_area.js
new file mode 100644
index 000000000..72a450215
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/upload_area.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadArea extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onClose: PropTypes.func,
+ };
+
+ handleKeyUp = (e) => {
+ const keyCode = e.keyCode;
+ if (this.props.active) {
+ switch(keyCode) {
+ case 27:
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onClose();
+ break;
+ }
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+ {({ backgroundOpacity, backgroundScale }) =>
+
+ }
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/video_modal.js b/app/javascript/themes/glitch/features/ui/components/video_modal.js
new file mode 100644
index 000000000..91168c790
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/video_modal.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'themes/glitch/features/video';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ time: PropTypes.number,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ const { media, time, onClose } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/containers/bundle_container.js b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js
new file mode 100644
index 000000000..e6f9afcf7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+
+import Bundle from '../components/bundle';
+
+import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from 'themes/glitch/actions/bundles';
+
+const mapDispatchToProps = dispatch => ({
+ onFetch () {
+ dispatch(fetchBundleRequest());
+ },
+ onFetchSuccess () {
+ dispatch(fetchBundleSuccess());
+ },
+ onFetchFail (error) {
+ dispatch(fetchBundleFail(error));
+ },
+});
+
+export default connect(null, mapDispatchToProps)(Bundle);
diff --git a/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js
new file mode 100644
index 000000000..95f95618b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+
+const mapStateToProps = state => ({
+ columns: state.getIn(['settings', 'columns']),
+});
+
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..4bb90fb68
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state) => ({
+ loading: state.get('loadingBar'),
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/themes/glitch/features/ui/containers/modal_container.js b/app/javascript/themes/glitch/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..c26f19886
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from 'themes/glitch/actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+ type: state.get('modal').modalType,
+ props: state.get('modal').modalProps,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onClose () {
+ dispatch(closeModal());
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/themes/glitch/features/ui/containers/notifications_container.js b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..5bd4017f5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import { dismissAlert } from 'themes/glitch/actions/alerts';
+import { getAlerts } from 'themes/glitch/selectors';
+
+const mapStateToProps = state => ({
+ notifications: getAlerts(state),
+});
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onDismiss: alert => {
+ dispatch(dismissAlert(alert));
+ },
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/themes/glitch/features/ui/containers/status_list_container.js b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..807c82e16
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js
@@ -0,0 +1,73 @@
+import { connect } from 'react-redux';
+import StatusList from 'themes/glitch/components/status_list';
+import { scrollTopTimeline } from 'themes/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'lodash';
+import { me } from 'themes/glitch/util/initial_state';
+
+const makeGetStatusIds = () => createSelector([
+ (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+ (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+ (state) => state.get('statuses'),
+], (columnSettings, statusIds, statuses) => {
+ const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
+ let regex = null;
+
+ try {
+ regex = rawRegex && new RegExp(rawRegex, 'i');
+ } catch (e) {
+ // Bad regex, don't affect filters
+ }
+
+ return statusIds.filter(id => {
+ const statusForId = statuses.get(id);
+ let showStatus = true;
+
+ if (columnSettings.getIn(['shows', 'reblog']) === false) {
+ showStatus = showStatus && statusForId.get('reblog') === null;
+ }
+
+ if (columnSettings.getIn(['shows', 'reply']) === false) {
+ showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+ }
+
+ if (showStatus && regex && statusForId.get('account') !== me) {
+ const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
+ showStatus = !regex.test(searchIndex);
+ }
+
+ return showStatus;
+ });
+});
+
+const makeMapStateToProps = () => {
+ const getStatusIds = makeGetStatusIds();
+
+ const mapStateToProps = (state, { timelineId }) => ({
+ statusIds: getStatusIds(state, { type: timelineId }),
+ isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+ hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
+
+ onScrollToBottom: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, false));
+ loadMore();
+ }, 300, { leading: true }),
+
+ onScrollToTop: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, true));
+ }, 100),
+
+ onScroll: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, false));
+ }, 100),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/themes/glitch/features/ui/index.js b/app/javascript/themes/glitch/features/ui/index.js
new file mode 100644
index 000000000..b59a2e637
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/index.js
@@ -0,0 +1,442 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose } from 'themes/glitch/actions/compose';
+import { refreshHomeTimeline } from 'themes/glitch/actions/timelines';
+import { refreshNotifications } from 'themes/glitch/actions/notifications';
+import { clearHeight } from 'themes/glitch/actions/height_cache';
+import { WrappedSwitch, WrappedRoute } from 'themes/glitch/util/react_router_helpers';
+import UploadArea from './components/upload_area';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
+import {
+ Compose,
+ Status,
+ GettingStarted,
+ PublicTimeline,
+ CommunityTimeline,
+ AccountTimeline,
+ AccountGallery,
+ HomeTimeline,
+ Followers,
+ Following,
+ Reblogs,
+ Favourites,
+ DirectTimeline,
+ HashtagTimeline,
+ Notifications,
+ FollowRequests,
+ GenericNotFound,
+ FavouritedStatuses,
+ Blocks,
+ Mutes,
+ PinnedStatuses,
+} from 'themes/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import { me } from 'themes/glitch/util/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Dummy import, to make sure that ends up in the application bundle.
+// Without this it ends up in ~8 very commonly used bundles.
+import '../../../glitch/components/status';
+
+const messages = defineMessages({
+ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
+const mapStateToProps = state => ({
+ isComposing: state.getIn(['compose', 'is_composing']),
+ hasComposingText: state.getIn(['compose', 'text']) !== '',
+ layout: state.getIn(['local_settings', 'layout']),
+ isWide: state.getIn(['local_settings', 'stretch']),
+ navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+});
+
+const keyMap = {
+ new: 'n',
+ search: 's',
+ forceNew: 'option+n',
+ focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+ reply: 'r',
+ favourite: 'f',
+ boost: 'b',
+ mention: 'm',
+ open: ['enter', 'o'],
+ openProfile: 'p',
+ moveDown: ['down', 'j'],
+ moveUp: ['up', 'k'],
+ back: 'backspace',
+ goToHome: 'g h',
+ goToNotifications: 'g n',
+ goToLocal: 'g l',
+ goToFederated: 'g t',
+ goToDirect: 'g d',
+ goToStart: 'g s',
+ goToFavourites: 'g f',
+ goToPinned: 'g p',
+ goToProfile: 'g u',
+ goToBlocked: 'g b',
+ goToMuted: 'g m',
+};
+
+@connect(mapStateToProps)
+@injectIntl
+@withRouter
+export default class UI extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ children: PropTypes.node,
+ layout: PropTypes.string,
+ isWide: PropTypes.bool,
+ systemFontUi: PropTypes.bool,
+ navbarUnder: PropTypes.bool,
+ isComposing: PropTypes.bool,
+ hasComposingText: PropTypes.bool,
+ location: PropTypes.object,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ width: window.innerWidth,
+ draggingOver: false,
+ };
+
+ handleBeforeUnload = (e) => {
+ const { intl, isComposing, hasComposingText } = this.props;
+
+ if (isComposing && hasComposingText) {
+ // Setting returnValue to any string causes confirmation dialog.
+ // Many browsers no longer display this text to users,
+ // but we set user-friendly message for other browsers, e.g. Edge.
+ e.returnValue = intl.formatMessage(messages.beforeUnload);
+ }
+ }
+
+ handleResize = debounce(() => {
+ // The cached heights are no longer accurate, invalidate
+ this.props.dispatch(clearHeight());
+
+ this.setState({ width: window.innerWidth });
+ }, 500, {
+ trailing: true,
+ });
+
+ handleDragEnter = (e) => {
+ e.preventDefault();
+
+ if (!this.dragTargets) {
+ this.dragTargets = [];
+ }
+
+ if (this.dragTargets.indexOf(e.target) === -1) {
+ this.dragTargets.push(e.target);
+ }
+
+ if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+ this.setState({ draggingOver: true });
+ }
+ }
+
+ handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ try {
+ e.dataTransfer.dropEffect = 'copy';
+ } catch (err) {
+
+ }
+
+ return false;
+ }
+
+ handleDrop = (e) => {
+ e.preventDefault();
+
+ this.setState({ draggingOver: false });
+
+ if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+ this.props.dispatch(uploadCompose(e.dataTransfer.files));
+ }
+ }
+
+ handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+ if (this.dragTargets.length > 0) {
+ return;
+ }
+
+ this.setState({ draggingOver: false });
+ }
+
+ closeUploadModal = () => {
+ this.setState({ draggingOver: false });
+ }
+
+ handleServiceWorkerPostMessage = ({ data }) => {
+ if (data.type === 'navigate') {
+ this.context.router.history.push(data.path);
+ } else {
+ console.warn('Unknown message type:', data.type);
+ }
+ }
+
+ componentWillMount () {
+ window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ document.addEventListener('dragenter', this.handleDragEnter, false);
+ document.addEventListener('dragover', this.handleDragOver, false);
+ document.addEventListener('drop', this.handleDrop, false);
+ document.addEventListener('dragleave', this.handleDragLeave, false);
+ document.addEventListener('dragend', this.handleDragEnd, false);
+
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+ }
+
+ this.props.dispatch(refreshHomeTimeline());
+ this.props.dispatch(refreshNotifications());
+ }
+
+ componentDidMount () {
+ this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+ return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+ };
+ }
+
+ shouldComponentUpdate (nextProps) {
+ if (nextProps.isComposing !== this.props.isComposing) {
+ // Avoid expensive update just to toggle a class
+ this.node.classList.toggle('is-composing', nextProps.isComposing);
+ this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
+
+ return false;
+ }
+
+ // Why isn't this working?!?
+ // return super.shouldComponentUpdate(nextProps, nextState);
+ return true;
+ }
+
+ componentDidUpdate (prevProps) {
+ if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+ this.columnsAreaNode.handleChildrenContentChange();
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
+ window.removeEventListener('resize', this.handleResize);
+ document.removeEventListener('dragenter', this.handleDragEnter);
+ document.removeEventListener('dragover', this.handleDragOver);
+ document.removeEventListener('drop', this.handleDrop);
+ document.removeEventListener('dragleave', this.handleDragLeave);
+ document.removeEventListener('dragend', this.handleDragEnd);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ setColumnsAreaRef = c => {
+ this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+ }
+
+ handleHotkeyNew = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ handleHotkeySearch = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.search__input');
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ handleHotkeyForceNew = e => {
+ this.handleHotkeyNew(e);
+ this.props.dispatch(resetCompose());
+ }
+
+ 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) {
+ const status = column.querySelector('.focusable');
+
+ if (status) {
+ status.focus();
+ }
+ }
+ }
+
+ handleHotkeyBack = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ setHotkeysRef = c => {
+ this.hotkeys = c;
+ }
+
+ handleHotkeyGoToHome = () => {
+ this.context.router.history.push('/timelines/home');
+ }
+
+ handleHotkeyGoToNotifications = () => {
+ this.context.router.history.push('/notifications');
+ }
+
+ handleHotkeyGoToLocal = () => {
+ this.context.router.history.push('/timelines/public/local');
+ }
+
+ handleHotkeyGoToFederated = () => {
+ this.context.router.history.push('/timelines/public');
+ }
+
+ handleHotkeyGoToDirect = () => {
+ this.context.router.history.push('/timelines/direct');
+ }
+
+ handleHotkeyGoToStart = () => {
+ this.context.router.history.push('/getting-started');
+ }
+
+ handleHotkeyGoToFavourites = () => {
+ this.context.router.history.push('/favourites');
+ }
+
+ handleHotkeyGoToPinned = () => {
+ this.context.router.history.push('/pinned');
+ }
+
+ handleHotkeyGoToProfile = () => {
+ this.context.router.history.push(`/accounts/${me}`);
+ }
+
+ handleHotkeyGoToBlocked = () => {
+ this.context.router.history.push('/blocks');
+ }
+
+ handleHotkeyGoToMuted = () => {
+ this.context.router.history.push('/mutes');
+ }
+
+ render () {
+ const { width, draggingOver } = this.state;
+ const { children, layout, isWide, navbarUnder } = this.props;
+
+ const columnsClass = layout => {
+ switch (layout) {
+ case 'single':
+ return 'single-column';
+ case 'multiple':
+ return 'multi-columns';
+ default:
+ return 'auto-columns';
+ }
+ };
+
+ const className = classNames('ui', columnsClass(layout), {
+ 'wide': isWide,
+ 'system-font': this.props.systemFontUi,
+ 'navbar-under': navbarUnder,
+ });
+
+ const handlers = {
+ new: this.handleHotkeyNew,
+ search: this.handleHotkeySearch,
+ forceNew: this.handleHotkeyForceNew,
+ focusColumn: this.handleHotkeyFocusColumn,
+ back: this.handleHotkeyBack,
+ goToHome: this.handleHotkeyGoToHome,
+ goToNotifications: this.handleHotkeyGoToNotifications,
+ goToLocal: this.handleHotkeyGoToLocal,
+ goToFederated: this.handleHotkeyGoToFederated,
+ goToDirect: this.handleHotkeyGoToDirect,
+ goToStart: this.handleHotkeyGoToStart,
+ goToFavourites: this.handleHotkeyGoToFavourites,
+ goToPinned: this.handleHotkeyGoToPinned,
+ goToProfile: this.handleHotkeyGoToProfile,
+ goToBlocked: this.handleHotkeyGoToBlocked,
+ goToMuted: this.handleHotkeyGoToMuted,
+ };
+
+ return (
+
+
+ {navbarUnder ? null : ( )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {navbarUnder ? ( ) : null}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/features/video/index.js b/app/javascript/themes/glitch/features/video/index.js
new file mode 100644
index 000000000..0ecbe37c9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/video/index.js
@@ -0,0 +1,288 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from 'themes/glitch/util/fullscreen';
+
+const messages = defineMessages({
+ play: { id: 'video.play', defaultMessage: 'Play' },
+ pause: { id: 'video.pause', defaultMessage: 'Pause' },
+ mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+ unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+ hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+ expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+ close: { id: 'video.close', defaultMessage: 'Close video' },
+ fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+ exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const findElementPosition = el => {
+ let box;
+
+ if (el.getBoundingClientRect && el.parentNode) {
+ box = el.getBoundingClientRect();
+ }
+
+ if (!box) {
+ return {
+ left: 0,
+ top: 0,
+ };
+ }
+
+ const docEl = document.documentElement;
+ const body = document.body;
+
+ const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+ const scrollLeft = window.pageXOffset || body.scrollLeft;
+ const left = (box.left + scrollLeft) - clientLeft;
+
+ const clientTop = docEl.clientTop || body.clientTop || 0;
+ const scrollTop = window.pageYOffset || body.scrollTop;
+ const top = (box.top + scrollTop) - clientTop;
+
+ return {
+ left: Math.round(left),
+ top: Math.round(top),
+ };
+};
+
+const getPointerPosition = (el, event) => {
+ const position = {};
+ const box = findElementPosition(el);
+ const boxW = el.offsetWidth;
+ const boxH = el.offsetHeight;
+ const boxY = box.top;
+ const boxX = box.left;
+
+ let pageY = event.pageY;
+ let pageX = event.pageX;
+
+ if (event.changedTouches) {
+ pageX = event.changedTouches[0].pageX;
+ pageY = event.changedTouches[0].pageY;
+ }
+
+ position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+ position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+ return position;
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+ static propTypes = {
+ preview: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ startTime: PropTypes.number,
+ onOpenVideo: PropTypes.func,
+ onCloseVideo: PropTypes.func,
+ letterbox: PropTypes.bool,
+ fullwidth: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ progress: 0,
+ paused: true,
+ dragging: false,
+ fullscreen: false,
+ hovered: false,
+ muted: false,
+ revealed: !this.props.sensitive,
+ };
+
+ setPlayerRef = c => {
+ this.player = c;
+ }
+
+ setVideoRef = c => {
+ this.video = c;
+ }
+
+ setSeekRef = c => {
+ this.seek = c;
+ }
+
+ handlePlay = () => {
+ this.setState({ paused: false });
+ }
+
+ handlePause = () => {
+ this.setState({ paused: true });
+ }
+
+ handleTimeUpdate = () => {
+ this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
+ }
+
+ handleMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseMove, true);
+ document.addEventListener('mouseup', this.handleMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseMove, true);
+ document.addEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: true });
+ this.video.pause();
+ this.handleMouseMove(e);
+ }
+
+ handleMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseMove, true);
+ document.removeEventListener('mouseup', this.handleMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseMove, true);
+ document.removeEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: false });
+ this.video.play();
+ }
+
+ handleMouseMove = throttle(e => {
+ const { x } = getPointerPosition(this.seek, e);
+ this.video.currentTime = this.video.duration * x;
+ this.setState({ progress: x * 100 });
+ }, 60);
+
+ togglePlay = () => {
+ if (this.state.paused) {
+ this.video.play();
+ } else {
+ this.video.pause();
+ }
+ }
+
+ toggleFullscreen = () => {
+ if (isFullscreen()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(this.player);
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+ }
+
+ handleFullscreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ }
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ }
+
+ toggleMute = () => {
+ this.video.muted = !this.video.muted;
+ this.setState({ muted: this.video.muted });
+ }
+
+ toggleReveal = () => {
+ if (this.state.revealed) {
+ this.video.pause();
+ }
+
+ this.setState({ revealed: !this.state.revealed });
+ }
+
+ handleLoadedData = () => {
+ if (this.props.startTime) {
+ this.video.currentTime = this.props.startTime;
+ this.video.play();
+ }
+ }
+
+ handleProgress = () => {
+ if (this.video.buffered.length > 0) {
+ this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+ }
+ }
+
+ handleOpenVideo = () => {
+ this.video.pause();
+ this.props.onOpenVideo(this.video.currentTime);
+ }
+
+ handleCloseVideo = () => {
+ this.video.pause();
+ this.props.onCloseVideo();
+ }
+
+ render () {
+ const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth } = this.props;
+ const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!onCloseVideo && }
+
+
+
+ {(!fullscreen && onOpenVideo) && }
+ {onCloseVideo && }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/index.js b/app/javascript/themes/glitch/index.js
new file mode 100644
index 000000000..407e1f767
--- /dev/null
+++ b/app/javascript/themes/glitch/index.js
@@ -0,0 +1,14 @@
+import loadPolyfills from './util/load_polyfills';
+
+// import default stylesheet with variables
+require('font-awesome/css/font-awesome.css');
+
+import './styles/index.scss';
+
+require.context('../../images/', true);
+
+loadPolyfills().then(() => {
+ require('./util/main').default();
+}).catch(e => {
+ console.error(e);
+});
diff --git a/app/javascript/themes/glitch/middleware/errors.js b/app/javascript/themes/glitch/middleware/errors.js
new file mode 100644
index 000000000..c54e7b0a2
--- /dev/null
+++ b/app/javascript/themes/glitch/middleware/errors.js
@@ -0,0 +1,31 @@
+import { showAlert } from 'themes/glitch/actions/alerts';
+
+const defaultFailSuffix = 'FAIL';
+
+export default function errorsMiddleware() {
+ return ({ dispatch }) => next => action => {
+ if (action.type && !action.skipAlert) {
+ const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
+
+ if (action.type.match(isFail)) {
+ if (action.error.response) {
+ const { data, status, statusText } = action.error.response;
+
+ let message = statusText;
+ let title = `${status}`;
+
+ if (data.error) {
+ message = data.error;
+ }
+
+ dispatch(showAlert(title, message));
+ } else {
+ console.error(action.error);
+ dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
+ }
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/themes/glitch/middleware/loading_bar.js b/app/javascript/themes/glitch/middleware/loading_bar.js
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/javascript/themes/glitch/middleware/loading_bar.js
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+ const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+ return ({ dispatch }) => next => (action) => {
+ if (action.type && !action.skipLoading) {
+ const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+ const isPending = new RegExp(`${PENDING}$`, 'g');
+ const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+ const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+ if (action.type.match(isPending)) {
+ dispatch(showLoading());
+ } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+ dispatch(hideLoading());
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/themes/glitch/middleware/sounds.js b/app/javascript/themes/glitch/middleware/sounds.js
new file mode 100644
index 000000000..3d1e3eaba
--- /dev/null
+++ b/app/javascript/themes/glitch/middleware/sounds.js
@@ -0,0 +1,46 @@
+const createAudio = sources => {
+ const audio = new Audio();
+ sources.forEach(({ type, src }) => {
+ const source = document.createElement('source');
+ source.type = type;
+ source.src = src;
+ audio.appendChild(source);
+ });
+ return audio;
+};
+
+const play = audio => {
+ if (!audio.paused) {
+ audio.pause();
+ if (typeof audio.fastSeek === 'function') {
+ audio.fastSeek(0);
+ } else {
+ audio.seek(0);
+ }
+ }
+
+ audio.play();
+};
+
+export default function soundsMiddleware() {
+ const soundCache = {
+ boop: createAudio([
+ {
+ src: '/sounds/boop.ogg',
+ type: 'audio/ogg',
+ },
+ {
+ src: '/sounds/boop.mp3',
+ type: 'audio/mpeg',
+ },
+ ]),
+ };
+
+ return () => next => action => {
+ if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+ play(soundCache[action.meta.sound]);
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/themes/glitch/reducers/accounts.js b/app/javascript/themes/glitch/reducers/accounts.js
new file mode 100644
index 000000000..0a65d3723
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/accounts.js
@@ -0,0 +1,135 @@
+import {
+ ACCOUNT_FETCH_SUCCESS,
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'themes/glitch/actions/compose';
+import {
+ REBLOG_SUCCESS,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from 'themes/glitch/actions/interactions';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/timelines';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+} from 'themes/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/favourites';
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import emojify from 'themes/glitch/util/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const normalizeAccount = (state, account) => {
+ account = { ...account };
+
+ delete account.followers_count;
+ delete account.following_count;
+ delete account.statuses_count;
+
+ const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+ account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+ account.note_emojified = emojify(account.note);
+
+ return state.set(account.id, fromJS(account));
+};
+
+const normalizeAccounts = (state, accounts) => {
+ accounts.forEach(account => {
+ state = normalizeAccount(state, account);
+ });
+
+ return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+ state = normalizeAccount(state, status.account);
+
+ if (status.reblog && status.reblog.account) {
+ state = normalizeAccount(state, status.reblog.account);
+ }
+
+ return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeAccountFromStatus(state, status);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accounts(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('accounts'));
+ case ACCOUNT_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeAccount(state, action.account);
+ case FOLLOWERS_FETCH_SUCCESS:
+ case FOLLOWERS_EXPAND_SUCCESS:
+ case FOLLOWING_FETCH_SUCCESS:
+ case FOLLOWING_EXPAND_SUCCESS:
+ case REBLOGS_FETCH_SUCCESS:
+ case FAVOURITES_FETCH_SUCCESS:
+ case COMPOSE_SUGGESTIONS_READY:
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ case BLOCKS_FETCH_SUCCESS:
+ case BLOCKS_EXPAND_SUCCESS:
+ case MUTES_FETCH_SUCCESS:
+ case MUTES_EXPAND_SUCCESS:
+ return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return normalizeAccountsFromStatuses(state, action.statuses);
+ case REBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ return normalizeAccountFromStatus(state, action.response);
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ return normalizeAccountFromStatus(state, action.status);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/accounts_counters.js b/app/javascript/themes/glitch/reducers/accounts_counters.js
new file mode 100644
index 000000000..e3728ecd7
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/accounts_counters.js
@@ -0,0 +1,138 @@
+import {
+ ACCOUNT_FETCH_SUCCESS,
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ ACCOUNT_FOLLOW_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'themes/glitch/actions/compose';
+import {
+ REBLOG_SUCCESS,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from 'themes/glitch/actions/interactions';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/timelines';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+} from 'themes/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/favourites';
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, fromJS({
+ followers_count: account.followers_count,
+ following_count: account.following_count,
+ statuses_count: account.statuses_count,
+}));
+
+const normalizeAccounts = (state, accounts) => {
+ accounts.forEach(account => {
+ state = normalizeAccount(state, account);
+ });
+
+ return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+ state = normalizeAccount(state, status.account);
+
+ if (status.reblog && status.reblog.account) {
+ state = normalizeAccount(state, status.reblog.account);
+ }
+
+ return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeAccountFromStatus(state, status);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accountsCounters(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('accounts').map(item => fromJS({
+ followers_count: item.get('followers_count'),
+ following_count: item.get('following_count'),
+ statuses_count: item.get('statuses_count'),
+ })));
+ case ACCOUNT_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeAccount(state, action.account);
+ case FOLLOWERS_FETCH_SUCCESS:
+ case FOLLOWERS_EXPAND_SUCCESS:
+ case FOLLOWING_FETCH_SUCCESS:
+ case FOLLOWING_EXPAND_SUCCESS:
+ case REBLOGS_FETCH_SUCCESS:
+ case FAVOURITES_FETCH_SUCCESS:
+ case COMPOSE_SUGGESTIONS_READY:
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ case BLOCKS_FETCH_SUCCESS:
+ case BLOCKS_EXPAND_SUCCESS:
+ case MUTES_FETCH_SUCCESS:
+ case MUTES_EXPAND_SUCCESS:
+ return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return normalizeAccountsFromStatuses(state, action.statuses);
+ case REBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ return normalizeAccountFromStatus(state, action.response);
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ return normalizeAccountFromStatus(state, action.status);
+ case ACCOUNT_FOLLOW_SUCCESS:
+ if (action.alreadyFollowing) {
+ return state;
+ }
+ return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/alerts.js b/app/javascript/themes/glitch/reducers/alerts.js
new file mode 100644
index 000000000..ad66b63f6
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+ ALERT_SHOW,
+ ALERT_DISMISS,
+ ALERT_CLEAR,
+} from 'themes/glitch/actions/alerts';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableList([]);
+
+export default function alerts(state = initialState, action) {
+ switch(action.type) {
+ case ALERT_SHOW:
+ return state.push(ImmutableMap({
+ key: state.size > 0 ? state.last().get('key') + 1 : 0,
+ title: action.title,
+ message: action.message,
+ }));
+ case ALERT_DISMISS:
+ return state.filterNot(item => item.get('key') === action.alert.key);
+ case ALERT_CLEAR:
+ return state.clear();
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/cards.js b/app/javascript/themes/glitch/reducers/cards.js
new file mode 100644
index 000000000..35be30444
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from 'themes/glitch/actions/cards';
+
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function cards(state = initialState, action) {
+ switch(action.type) {
+ case STATUS_CARD_FETCH_SUCCESS:
+ return state.set(action.id, fromJS(action.card));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/compose.js b/app/javascript/themes/glitch/reducers/compose.js
new file mode 100644
index 000000000..be359fcb4
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/compose.js
@@ -0,0 +1,307 @@
+import {
+ COMPOSE_MOUNT,
+ COMPOSE_UNMOUNT,
+ COMPOSE_CHANGE,
+ COMPOSE_REPLY,
+ COMPOSE_REPLY_CANCEL,
+ COMPOSE_MENTION,
+ COMPOSE_SUBMIT_REQUEST,
+ COMPOSE_SUBMIT_SUCCESS,
+ COMPOSE_SUBMIT_FAIL,
+ COMPOSE_UPLOAD_REQUEST,
+ COMPOSE_UPLOAD_SUCCESS,
+ COMPOSE_UPLOAD_FAIL,
+ COMPOSE_UPLOAD_UNDO,
+ COMPOSE_UPLOAD_PROGRESS,
+ COMPOSE_SUGGESTIONS_CLEAR,
+ COMPOSE_SUGGESTIONS_READY,
+ COMPOSE_SUGGESTION_SELECT,
+ COMPOSE_ADVANCED_OPTIONS_CHANGE,
+ COMPOSE_SENSITIVITY_CHANGE,
+ COMPOSE_SPOILERNESS_CHANGE,
+ COMPOSE_SPOILER_TEXT_CHANGE,
+ COMPOSE_VISIBILITY_CHANGE,
+ COMPOSE_COMPOSING_CHANGE,
+ COMPOSE_EMOJI_INSERT,
+ COMPOSE_UPLOAD_CHANGE_REQUEST,
+ COMPOSE_UPLOAD_CHANGE_SUCCESS,
+ COMPOSE_UPLOAD_CHANGE_FAIL,
+ COMPOSE_DOODLE_SET,
+ COMPOSE_RESET,
+} from 'themes/glitch/actions/compose';
+import { TIMELINE_DELETE } from 'themes/glitch/actions/timelines';
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import uuid from 'themes/glitch/util/uuid';
+import { me } from 'themes/glitch/util/initial_state';
+
+const initialState = ImmutableMap({
+ mounted: false,
+ advanced_options: ImmutableMap({
+ do_not_federate: false,
+ }),
+ sensitive: false,
+ spoiler: false,
+ spoiler_text: '',
+ privacy: null,
+ text: '',
+ focusDate: null,
+ preselectDate: null,
+ in_reply_to: null,
+ is_composing: false,
+ is_submitting: false,
+ is_uploading: false,
+ progress: 0,
+ media_attachments: ImmutableList(),
+ suggestion_token: null,
+ suggestions: ImmutableList(),
+ default_advanced_options: ImmutableMap({
+ do_not_federate: false,
+ }),
+ default_privacy: 'public',
+ default_sensitive: false,
+ resetFileKey: Math.floor((Math.random() * 0x10000)),
+ idempotencyKey: null,
+ doodle: ImmutableMap({
+ fg: 'rgb( 0, 0, 0)',
+ bg: 'rgb(255, 255, 255)',
+ swapped: false,
+ mode: 'draw',
+ size: 'normal',
+ weight: 2,
+ opacity: 1,
+ adaptiveStroke: true,
+ smoothing: false,
+ }),
+});
+
+function statusToTextMentions(state, status) {
+ let set = ImmutableOrderedSet([]);
+
+ if (status.getIn(['account', 'id']) !== me) {
+ set = set.add(`@${status.getIn(['account', 'acct'])} `);
+ }
+
+ return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
+};
+
+function clearAll(state) {
+ return state.withMutations(map => {
+ map.set('text', '');
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ map.set('is_submitting', false);
+ map.set('in_reply_to', null);
+ map.set('advanced_options', state.get('default_advanced_options'));
+ map.set('privacy', state.get('default_privacy'));
+ map.set('sensitive', false);
+ map.update('media_attachments', list => list.clear());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+function appendMedia(state, media) {
+ const prevSize = state.get('media_attachments').size;
+
+ return state.withMutations(map => {
+ map.update('media_attachments', list => list.push(media));
+ map.set('is_uploading', false);
+ map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+ map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
+ map.set('sensitive', true);
+ }
+ });
+};
+
+function removeMedia(state, mediaId) {
+ const media = state.get('media_attachments').find(item => item.get('id') === mediaId);
+ const prevSize = state.get('media_attachments').size;
+
+ return state.withMutations(map => {
+ map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
+ map.update('text', text => text.replace(media.get('text_url'), '').trim());
+ map.set('idempotencyKey', uuid());
+
+ if (prevSize === 1) {
+ map.set('sensitive', false);
+ }
+ });
+};
+
+const insertSuggestion = (state, position, token, completion) => {
+ return state.withMutations(map => {
+ map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
+ map.set('suggestion_token', null);
+ map.update('suggestions', ImmutableList(), list => list.clear());
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+ const emoji = emojiData.native;
+
+ return state.withMutations(map => {
+ map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+const privacyPreference = (a, b) => {
+ if (a === 'direct' || b === 'direct') {
+ return 'direct';
+ } else if (a === 'private' || b === 'private') {
+ return 'private';
+ } else if (a === 'unlisted' || b === 'unlisted') {
+ return 'unlisted';
+ } else {
+ return 'public';
+ }
+};
+
+const hydrate = (state, hydratedState) => {
+ state = clearAll(state.merge(hydratedState));
+
+ if (hydratedState.has('text')) {
+ state = state.set('text', hydratedState.get('text'));
+ }
+
+ return state;
+};
+
+export default function compose(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return hydrate(state, action.state.get('compose'));
+ case COMPOSE_MOUNT:
+ return state.set('mounted', true);
+ case COMPOSE_UNMOUNT:
+ return state
+ .set('mounted', false)
+ .set('is_composing', false);
+ case COMPOSE_ADVANCED_OPTIONS_CHANGE:
+ return state
+ .set('advanced_options',
+ state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
+ .set('idempotencyKey', uuid());
+ case COMPOSE_SENSITIVITY_CHANGE:
+ return state.withMutations(map => {
+ if (!state.get('spoiler')) {
+ map.set('sensitive', !state.get('sensitive'));
+ }
+
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_SPOILERNESS_CHANGE:
+ return state.withMutations(map => {
+ map.set('spoiler_text', '');
+ map.set('spoiler', !state.get('spoiler'));
+ map.set('idempotencyKey', uuid());
+
+ if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
+ map.set('sensitive', true);
+ }
+ });
+ case COMPOSE_SPOILER_TEXT_CHANGE:
+ return state
+ .set('spoiler_text', action.text)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_VISIBILITY_CHANGE:
+ return state
+ .set('privacy', action.value)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_CHANGE:
+ return state
+ .set('text', action.text)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_COMPOSING_CHANGE:
+ return state.set('is_composing', action.value);
+ case COMPOSE_REPLY:
+ return state.withMutations(map => {
+ map.set('in_reply_to', action.status.get('id'));
+ map.set('text', statusToTextMentions(state, action.status));
+ map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+ map.set('advanced_options', new ImmutableMap({
+ do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')),
+ }));
+ map.set('focusDate', new Date());
+ map.set('preselectDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.status.get('spoiler_text'));
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+ });
+ case COMPOSE_REPLY_CANCEL:
+ case COMPOSE_RESET:
+ return state.withMutations(map => {
+ map.set('in_reply_to', null);
+ map.set('text', '');
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ map.set('privacy', state.get('default_privacy'));
+ map.set('advanced_options', state.get('default_advanced_options'));
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_SUBMIT_REQUEST:
+ case COMPOSE_UPLOAD_CHANGE_REQUEST:
+ return state.set('is_submitting', true);
+ case COMPOSE_SUBMIT_SUCCESS:
+ return clearAll(state);
+ case COMPOSE_SUBMIT_FAIL:
+ case COMPOSE_UPLOAD_CHANGE_FAIL:
+ return state.set('is_submitting', false);
+ case COMPOSE_UPLOAD_REQUEST:
+ return state.set('is_uploading', true);
+ case COMPOSE_UPLOAD_SUCCESS:
+ return appendMedia(state, fromJS(action.media));
+ case COMPOSE_UPLOAD_FAIL:
+ return state.set('is_uploading', false);
+ case COMPOSE_UPLOAD_UNDO:
+ return removeMedia(state, action.media_id);
+ case COMPOSE_UPLOAD_PROGRESS:
+ return state.set('progress', Math.round((action.loaded / action.total) * 100));
+ case COMPOSE_MENTION:
+ return state
+ .update('text', text => `${text}@${action.account.get('acct')} `)
+ .set('focusDate', new Date())
+ .set('idempotencyKey', uuid());
+ case COMPOSE_SUGGESTIONS_CLEAR:
+ return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
+ case COMPOSE_SUGGESTIONS_READY:
+ return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
+ case COMPOSE_SUGGESTION_SELECT:
+ return insertSuggestion(state, action.position, action.token, action.completion);
+ case TIMELINE_DELETE:
+ if (action.id === state.get('in_reply_to')) {
+ return state.set('in_reply_to', null);
+ } else {
+ return state;
+ }
+ case COMPOSE_EMOJI_INSERT:
+ return insertEmoji(state, action.position, action.emoji);
+ case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+ return state
+ .set('is_submitting', false)
+ .update('media_attachments', list => list.map(item => {
+ if (item.get('id') === action.media.id) {
+ return item.set('description', action.media.description);
+ }
+
+ return item;
+ }));
+ case COMPOSE_DOODLE_SET:
+ return state.mergeIn(['doodle'], action.options);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/contexts.js b/app/javascript/themes/glitch/reducers/contexts.js
new file mode 100644
index 000000000..56c930bd5
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/contexts.js
@@ -0,0 +1,61 @@
+import { CONTEXT_FETCH_SUCCESS } from 'themes/glitch/actions/statuses';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'themes/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ ancestors: ImmutableMap(),
+ descendants: ImmutableMap(),
+});
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+ const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id));
+ const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+
+ return state.withMutations(map => {
+ map.setIn(['ancestors', id], ancestorsIds);
+ map.setIn(['descendants', id], descendantsIds);
+ });
+};
+
+const deleteFromContexts = (state, id) => {
+ state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
+ state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+ });
+
+ state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
+ state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+ });
+
+ state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+
+ return state;
+};
+
+const updateContext = (state, status, references) => {
+ return state.update('descendants', map => {
+ references.forEach(parentId => {
+ map = map.update(parentId, ImmutableList(), list => {
+ if (list.includes(status.id)) {
+ return list;
+ }
+
+ return list.push(status.id);
+ });
+ });
+
+ return map;
+ });
+};
+
+export default function contexts(state = initialState, action) {
+ switch(action.type) {
+ case CONTEXT_FETCH_SUCCESS:
+ return normalizeContext(state, action.id, action.ancestors, action.descendants);
+ case TIMELINE_DELETE:
+ return deleteFromContexts(state, action.id);
+ case TIMELINE_CONTEXT_UPDATE:
+ return updateContext(state, action.status, action.references);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/custom_emojis.js b/app/javascript/themes/glitch/reducers/custom_emojis.js
new file mode 100644
index 000000000..e3f1e0018
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/custom_emojis.js
@@ -0,0 +1,16 @@
+import { List as ImmutableList } from 'immutable';
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { search as emojiSearch } from 'themes/glitch/util/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from 'themes/glitch/util/emoji';
+
+const initialState = ImmutableList();
+
+export default function custom_emojis(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+ return action.state.get('custom_emojis');
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/height_cache.js b/app/javascript/themes/glitch/reducers/height_cache.js
new file mode 100644
index 000000000..93c31b42c
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/height_cache.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'themes/glitch/actions/height_cache';
+
+const initialState = ImmutableMap();
+
+const setHeight = (state, key, id, height) => {
+ return state.update(key, ImmutableMap(), map => map.set(id, height));
+};
+
+const clearHeights = () => {
+ return ImmutableMap();
+};
+
+export default function statuses(state = initialState, action) {
+ switch(action.type) {
+ case HEIGHT_CACHE_SET:
+ return setHeight(state, action.key, action.id, action.height);
+ case HEIGHT_CACHE_CLEAR:
+ return clearHeights();
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/index.js b/app/javascript/themes/glitch/reducers/index.js
new file mode 100644
index 000000000..aa748421a
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/index.js
@@ -0,0 +1,54 @@
+import { combineReducers } from 'redux-immutable';
+import timelines from './timelines';
+import meta from './meta';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import settings from './settings';
+import local_settings from './local_settings';
+import push_notifications from './push_notifications';
+import status_lists from './status_lists';
+import cards from './cards';
+import mutes from './mutes';
+import reports from './reports';
+import contexts from './contexts';
+import compose from './compose';
+import search from './search';
+import media_attachments from './media_attachments';
+import notifications from './notifications';
+import height_cache from './height_cache';
+import custom_emojis from './custom_emojis';
+
+const reducers = {
+ timelines,
+ meta,
+ alerts,
+ loadingBar: loadingBarReducer,
+ modal,
+ user_lists,
+ status_lists,
+ accounts,
+ accounts_counters,
+ statuses,
+ relationships,
+ settings,
+ local_settings,
+ push_notifications,
+ cards,
+ mutes,
+ reports,
+ contexts,
+ compose,
+ search,
+ media_attachments,
+ notifications,
+ height_cache,
+ custom_emojis,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/themes/glitch/reducers/local_settings.js b/app/javascript/themes/glitch/reducers/local_settings.js
new file mode 100644
index 000000000..b1ffa047e
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/local_settings.js
@@ -0,0 +1,45 @@
+// Package imports.
+import { Map as ImmutableMap } from 'immutable';
+
+// Our imports.
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { LOCAL_SETTING_CHANGE } from 'themes/glitch/actions/local_settings';
+
+const initialState = ImmutableMap({
+ layout : 'auto',
+ stretch : true,
+ navbar_under : false,
+ side_arm : 'none',
+ collapsed : ImmutableMap({
+ enabled : true,
+ auto : ImmutableMap({
+ all : false,
+ notifications : true,
+ lengthy : true,
+ reblogs : false,
+ replies : false,
+ media : false,
+ }),
+ backgrounds : ImmutableMap({
+ user_backgrounds : false,
+ preview_images : false,
+ }),
+ }),
+ media : ImmutableMap({
+ letterbox : true,
+ fullwidth : true,
+ }),
+});
+
+const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
+
+export default function localSettings(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return hydrate(state, action.state.get('local_settings'));
+ case LOCAL_SETTING_CHANGE:
+ return state.setIn(action.key, action.value);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/media_attachments.js b/app/javascript/themes/glitch/reducers/media_attachments.js
new file mode 100644
index 000000000..69a44639c
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/media_attachments.js
@@ -0,0 +1,15 @@
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+ accept_content_types: [],
+});
+
+export default function meta(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('media_attachments'));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/meta.js b/app/javascript/themes/glitch/reducers/meta.js
new file mode 100644
index 000000000..2249f1d78
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/meta.js
@@ -0,0 +1,16 @@
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+ streaming_api_base_url: null,
+ access_token: null,
+});
+
+export default function meta(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('meta'));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/modal.js b/app/javascript/themes/glitch/reducers/modal.js
new file mode 100644
index 000000000..97fb31203
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/modal.js
@@ -0,0 +1,17 @@
+import { MODAL_OPEN, MODAL_CLOSE } from 'themes/glitch/actions/modal';
+
+const initialState = {
+ modalType: null,
+ modalProps: {},
+};
+
+export default function modal(state = initialState, action) {
+ switch(action.type) {
+ case MODAL_OPEN:
+ return { modalType: action.modalType, modalProps: action.modalProps };
+ case MODAL_CLOSE:
+ return initialState;
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/mutes.js b/app/javascript/themes/glitch/reducers/mutes.js
new file mode 100644
index 000000000..8fe4ae0c3
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/mutes.js
@@ -0,0 +1,29 @@
+import Immutable from 'immutable';
+
+import {
+ MUTES_INIT_MODAL,
+ MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+} from 'themes/glitch/actions/mutes';
+
+const initialState = Immutable.Map({
+ new: Immutable.Map({
+ isSubmitting: false,
+ account: null,
+ notifications: true,
+ }),
+});
+
+export default function mutes(state = initialState, action) {
+ switch (action.type) {
+ case MUTES_INIT_MODAL:
+ return state.withMutations((state) => {
+ state.setIn(['new', 'isSubmitting'], false);
+ state.setIn(['new', 'account'], action.account);
+ state.setIn(['new', 'notifications'], true);
+ });
+ case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+ return state.updateIn(['new', 'notifications'], (old) => !old);
+ default:
+ return state;
+ }
+}
diff --git a/app/javascript/themes/glitch/reducers/notifications.js b/app/javascript/themes/glitch/reducers/notifications.js
new file mode 100644
index 000000000..c4f505053
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/notifications.js
@@ -0,0 +1,191 @@
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+ NOTIFICATIONS_REFRESH_REQUEST,
+ NOTIFICATIONS_EXPAND_REQUEST,
+ NOTIFICATIONS_REFRESH_FAIL,
+ NOTIFICATIONS_EXPAND_FAIL,
+ NOTIFICATIONS_CLEAR,
+ NOTIFICATIONS_SCROLL_TOP,
+ NOTIFICATIONS_DELETE_MARKED_REQUEST,
+ NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+ NOTIFICATION_MARK_FOR_DELETE,
+ NOTIFICATIONS_DELETE_MARKED_FAIL,
+ NOTIFICATIONS_ENTER_CLEARING_MODE,
+ NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+} from 'themes/glitch/actions/notifications';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import { TIMELINE_DELETE } from 'themes/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ next: null,
+ top: true,
+ unread: 0,
+ loaded: false,
+ isLoading: true,
+ cleaningMode: false,
+ // notification removal mark of new notifs loaded whilst cleaningMode is true.
+ markNewForDelete: false,
+});
+
+const notificationToMap = (state, notification) => ImmutableMap({
+ id: notification.id,
+ type: notification.type,
+ account: notification.account.id,
+ markedForDelete: state.get('markNewForDelete'),
+ status: notification.status ? notification.status.id : null,
+});
+
+const normalizeNotification = (state, notification) => {
+ const top = state.get('top');
+
+ if (!top) {
+ state = state.update('unread', unread => unread + 1);
+ }
+
+ return state.update('items', list => {
+ if (top && list.size > 40) {
+ list = list.take(20);
+ }
+
+ return list.unshift(notificationToMap(state, notification));
+ });
+};
+
+const normalizeNotifications = (state, notifications, next) => {
+ let items = ImmutableList();
+ const loaded = state.get('loaded');
+
+ notifications.forEach((n, i) => {
+ items = items.set(i, notificationToMap(state, n));
+ });
+
+ if (state.get('next') === null) {
+ state = state.set('next', next);
+ }
+
+ return state
+ .update('items', list => loaded ? items.concat(list) : list.concat(items))
+ .set('loaded', true)
+ .set('isLoading', false);
+};
+
+const appendNormalizedNotifications = (state, notifications, next) => {
+ let items = ImmutableList();
+
+ notifications.forEach((n, i) => {
+ items = items.set(i, notificationToMap(state, n));
+ });
+
+ return state
+ .update('items', list => list.concat(items))
+ .set('next', next)
+ .set('isLoading', false);
+};
+
+const filterNotifications = (state, relationship) => {
+ return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
+const updateTop = (state, top) => {
+ if (top) {
+ state = state.set('unread', 0);
+ }
+
+ return state.set('top', top);
+};
+
+const deleteByStatus = (state, statusId) => {
+ return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+};
+
+const markForDelete = (state, notificationId, yes) => {
+ return state.update('items', list => list.map(item => {
+ if(item.get('id') === notificationId) {
+ return item.set('markedForDelete', yes);
+ } else {
+ return item;
+ }
+ }));
+};
+
+const markAllForDelete = (state, yes) => {
+ return state.update('items', list => list.map(item => {
+ if(yes !== null) {
+ return item.set('markedForDelete', yes);
+ } else {
+ return item.set('markedForDelete', !item.get('markedForDelete'));
+ }
+ }));
+};
+
+const unmarkAllForDelete = (state) => {
+ return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
+};
+
+const deleteMarkedNotifs = (state) => {
+ return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
+};
+
+export default function notifications(state = initialState, action) {
+ let st;
+
+ switch(action.type) {
+ case NOTIFICATIONS_REFRESH_REQUEST:
+ case NOTIFICATIONS_EXPAND_REQUEST:
+ case NOTIFICATIONS_DELETE_MARKED_REQUEST:
+ return state.set('isLoading', true);
+ case NOTIFICATIONS_DELETE_MARKED_FAIL:
+ case NOTIFICATIONS_REFRESH_FAIL:
+ case NOTIFICATIONS_EXPAND_FAIL:
+ return state.set('isLoading', false);
+ case NOTIFICATIONS_SCROLL_TOP:
+ return updateTop(state, action.top);
+ case NOTIFICATIONS_UPDATE:
+ return normalizeNotification(state, action.notification);
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ return normalizeNotifications(state, action.notifications, action.next);
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ return appendNormalizedNotifications(state, action.notifications, action.next);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterNotifications(state, action.relationship);
+ case NOTIFICATIONS_CLEAR:
+ return state.set('items', ImmutableList()).set('next', null);
+ case TIMELINE_DELETE:
+ return deleteByStatus(state, action.id);
+
+ case NOTIFICATION_MARK_FOR_DELETE:
+ return markForDelete(state, action.id, action.yes);
+
+ case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
+ return deleteMarkedNotifs(state).set('isLoading', false);
+
+ case NOTIFICATIONS_ENTER_CLEARING_MODE:
+ st = state.set('cleaningMode', action.yes);
+ if (!action.yes) {
+ return unmarkAllForDelete(st).set('markNewForDelete', false);
+ } else {
+ return st;
+ }
+
+ case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
+ st = state;
+ if (action.yes === null) {
+ // Toggle - this is a bit confusing, as it toggles the all-none mode
+ //st = st.set('markNewForDelete', !st.get('markNewForDelete'));
+ } else {
+ st = st.set('markNewForDelete', action.yes);
+ }
+ return markAllForDelete(st, action.yes);
+
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/push_notifications.js b/app/javascript/themes/glitch/reducers/push_notifications.js
new file mode 100644
index 000000000..744e4a0eb
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/push_notifications.js
@@ -0,0 +1,51 @@
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from 'themes/glitch/actions/push_notifications';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+ subscription: null,
+ alerts: new Immutable.Map({
+ follow: false,
+ favourite: false,
+ reblog: false,
+ mention: false,
+ }),
+ isSubscribed: false,
+ browserSupport: false,
+});
+
+export default function push_subscriptions(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE: {
+ const push_subscription = action.state.get('push_subscription');
+
+ if (push_subscription) {
+ return state
+ .set('subscription', new Immutable.Map({
+ id: push_subscription.get('id'),
+ endpoint: push_subscription.get('endpoint'),
+ }))
+ .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
+ .set('isSubscribed', true);
+ }
+
+ return state;
+ }
+ case SET_SUBSCRIPTION:
+ return state
+ .set('subscription', new Immutable.Map({
+ id: action.subscription.id,
+ endpoint: action.subscription.endpoint,
+ }))
+ .set('alerts', new Immutable.Map(action.subscription.alerts))
+ .set('isSubscribed', true);
+ case SET_BROWSER_SUPPORT:
+ return state.set('browserSupport', action.value);
+ case CLEAR_SUBSCRIPTION:
+ return initialState;
+ case ALERTS_CHANGE:
+ return state.setIn(action.key, action.value);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/relationships.js b/app/javascript/themes/glitch/reducers/relationships.js
new file mode 100644
index 000000000..d9135d6da
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/relationships.js
@@ -0,0 +1,46 @@
+import {
+ ACCOUNT_FOLLOW_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_UNBLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+ ACCOUNT_UNMUTE_SUCCESS,
+ RELATIONSHIPS_FETCH_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import {
+ DOMAIN_BLOCK_SUCCESS,
+ DOMAIN_UNBLOCK_SUCCESS,
+} from 'themes/glitch/actions/domain_blocks';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+ relationships.forEach(relationship => {
+ state = normalizeRelationship(state, relationship);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function relationships(state = initialState, action) {
+ switch(action.type) {
+ case ACCOUNT_FOLLOW_SUCCESS:
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_UNBLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ case ACCOUNT_UNMUTE_SUCCESS:
+ return normalizeRelationship(state, action.relationship);
+ case RELATIONSHIPS_FETCH_SUCCESS:
+ return normalizeRelationships(state, action.relationships);
+ case DOMAIN_BLOCK_SUCCESS:
+ return state.setIn([action.accountId, 'domain_blocking'], true);
+ case DOMAIN_UNBLOCK_SUCCESS:
+ return state.setIn([action.accountId, 'domain_blocking'], false);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/reports.js b/app/javascript/themes/glitch/reducers/reports.js
new file mode 100644
index 000000000..b714374ea
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/reports.js
@@ -0,0 +1,60 @@
+import {
+ REPORT_INIT,
+ REPORT_SUBMIT_REQUEST,
+ REPORT_SUBMIT_SUCCESS,
+ REPORT_SUBMIT_FAIL,
+ REPORT_CANCEL,
+ REPORT_STATUS_TOGGLE,
+ REPORT_COMMENT_CHANGE,
+} from 'themes/glitch/actions/reports';
+import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
+
+const initialState = ImmutableMap({
+ new: ImmutableMap({
+ isSubmitting: false,
+ account_id: null,
+ status_ids: ImmutableSet(),
+ comment: '',
+ }),
+});
+
+export default function reports(state = initialState, action) {
+ switch(action.type) {
+ case REPORT_INIT:
+ return state.withMutations(map => {
+ map.setIn(['new', 'isSubmitting'], false);
+ map.setIn(['new', 'account_id'], action.account.get('id'));
+
+ if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+ map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
+ map.setIn(['new', 'comment'], '');
+ } else if (action.status) {
+ map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+ }
+ });
+ case REPORT_STATUS_TOGGLE:
+ return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => {
+ if (action.checked) {
+ return set.add(action.statusId);
+ }
+
+ return set.remove(action.statusId);
+ });
+ case REPORT_COMMENT_CHANGE:
+ return state.setIn(['new', 'comment'], action.comment);
+ case REPORT_SUBMIT_REQUEST:
+ return state.setIn(['new', 'isSubmitting'], true);
+ case REPORT_SUBMIT_FAIL:
+ return state.setIn(['new', 'isSubmitting'], false);
+ case REPORT_CANCEL:
+ case REPORT_SUBMIT_SUCCESS:
+ return state.withMutations(map => {
+ map.setIn(['new', 'account_id'], null);
+ map.setIn(['new', 'status_ids'], ImmutableSet());
+ map.setIn(['new', 'comment'], '');
+ map.setIn(['new', 'isSubmitting'], false);
+ });
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/search.js b/app/javascript/themes/glitch/reducers/search.js
new file mode 100644
index 000000000..aec9e2efb
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/search.js
@@ -0,0 +1,42 @@
+import {
+ SEARCH_CHANGE,
+ SEARCH_CLEAR,
+ SEARCH_FETCH_SUCCESS,
+ SEARCH_SHOW,
+} from 'themes/glitch/actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from 'themes/glitch/actions/compose';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ value: '',
+ submitted: false,
+ hidden: false,
+ results: ImmutableMap(),
+});
+
+export default function search(state = initialState, action) {
+ switch(action.type) {
+ case SEARCH_CHANGE:
+ return state.set('value', action.value);
+ case SEARCH_CLEAR:
+ return state.withMutations(map => {
+ map.set('value', '');
+ map.set('results', ImmutableMap());
+ map.set('submitted', false);
+ map.set('hidden', false);
+ });
+ case SEARCH_SHOW:
+ return state.set('hidden', false);
+ case COMPOSE_REPLY:
+ case COMPOSE_MENTION:
+ return state.set('hidden', true);
+ case SEARCH_FETCH_SUCCESS:
+ return state.set('results', ImmutableMap({
+ accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+ statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+ hashtags: ImmutableList(action.results.hashtags),
+ })).set('submitted', true);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/settings.js b/app/javascript/themes/glitch/reducers/settings.js
new file mode 100644
index 000000000..c22bbbd8d
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/settings.js
@@ -0,0 +1,119 @@
+import { SETTING_CHANGE, SETTING_SAVE } from 'themes/glitch/actions/settings';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'themes/glitch/actions/columns';
+import { STORE_HYDRATE } from 'themes/glitch/actions/store';
+import { EMOJI_USE } from 'themes/glitch/actions/emojis';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import uuid from 'themes/glitch/util/uuid';
+
+const initialState = ImmutableMap({
+ saved: true,
+
+ onboarded: false,
+ layout: 'auto',
+
+ skinTone: 1,
+
+ home: ImmutableMap({
+ shows: ImmutableMap({
+ reblog: true,
+ reply: true,
+ }),
+
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ notifications: ImmutableMap({
+ alerts: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+
+ shows: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+
+ sounds: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+ }),
+
+ community: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ public: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ direct: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+});
+
+const defaultColumns = fromJS([
+ { id: 'COMPOSE', uuid: uuid(), params: {} },
+ { id: 'HOME', uuid: uuid(), params: {} },
+ { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
+]);
+
+const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
+
+const moveColumn = (state, uuid, direction) => {
+ const columns = state.get('columns');
+ const index = columns.findIndex(item => item.get('uuid') === uuid);
+ const newIndex = index + direction;
+
+ let newColumns;
+
+ newColumns = columns.splice(index, 1);
+ newColumns = newColumns.splice(newIndex, 0, columns.get(index));
+
+ return state
+ .set('columns', newColumns)
+ .set('saved', false);
+};
+
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
+export default function settings(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return hydrate(state, action.state.get('settings'));
+ case SETTING_CHANGE:
+ return state
+ .setIn(action.key, action.value)
+ .set('saved', false);
+ case COLUMN_ADD:
+ return state
+ .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+ .set('saved', false);
+ case COLUMN_REMOVE:
+ return state
+ .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+ .set('saved', false);
+ case COLUMN_MOVE:
+ return moveColumn(state, action.uuid, action.direction);
+ case EMOJI_USE:
+ return updateFrequentEmojis(state, action.emoji);
+ case SETTING_SAVE:
+ return state.set('saved', true);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/status_lists.js b/app/javascript/themes/glitch/reducers/status_lists.js
new file mode 100644
index 000000000..8dc7d374e
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/status_lists.js
@@ -0,0 +1,75 @@
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from 'themes/glitch/actions/pin_statuses';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
+} from 'themes/glitch/actions/interactions';
+
+const initialState = ImmutableMap({
+ favourites: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
+ pins: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('loaded', true);
+ map.set('items', ImmutableList(statuses.map(item => item.id)));
+ }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+ }));
+};
+
+const prependOneToList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').unshift(status.get('id')));
+ }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').filter(item => item !== status.get('id')));
+ }));
+};
+
+export default function statusLists(state = initialState, action) {
+ switch(action.type) {
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return appendToList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITE_SUCCESS:
+ return prependOneToList(state, 'favourites', action.status);
+ case UNFAVOURITE_SUCCESS:
+ return removeOneFromList(state, 'favourites', action.status);
+ case PINNED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'pins', action.statuses, action.next);
+ case PIN_SUCCESS:
+ return prependOneToList(state, 'pins', action.status);
+ case UNPIN_SUCCESS:
+ return removeOneFromList(state, 'pins', action.status);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/statuses.js b/app/javascript/themes/glitch/reducers/statuses.js
new file mode 100644
index 000000000..ef8086865
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/statuses.js
@@ -0,0 +1,148 @@
+import {
+ REBLOG_REQUEST,
+ REBLOG_SUCCESS,
+ REBLOG_FAIL,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_REQUEST,
+ FAVOURITE_SUCCESS,
+ FAVOURITE_FAIL,
+ UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
+} from 'themes/glitch/actions/interactions';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+ STATUS_MUTE_SUCCESS,
+ STATUS_UNMUTE_SUCCESS,
+} from 'themes/glitch/actions/statuses';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_DELETE,
+ TIMELINE_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/timelines';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from 'themes/glitch/actions/pin_statuses';
+import { SEARCH_FETCH_SUCCESS } from 'themes/glitch/actions/search';
+import emojify from 'themes/glitch/util/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
+
+const normalizeStatus = (state, status) => {
+ if (!status) {
+ return state;
+ }
+
+ const normalStatus = { ...status };
+ normalStatus.account = status.account.id;
+
+ if (status.reblog && status.reblog.id) {
+ state = normalizeStatus(state, status.reblog);
+ normalStatus.reblog = status.reblog.id;
+ }
+
+ const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/ /g, '\n').replace(/<\/p>/g, '\n\n');
+
+ const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
+ obj[`:${emoji.shortcode}:`] = emoji;
+ return obj;
+ }, {});
+
+ normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
+ normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
+
+ return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
+};
+
+const normalizeStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeStatus(state, status);
+ });
+
+ return state;
+};
+
+const deleteStatus = (state, id, references) => {
+ references.forEach(ref => {
+ state = deleteStatus(state, ref[0], []);
+ });
+
+ return state.delete(id);
+};
+
+const filterStatuses = (state, relationship) => {
+ state.forEach(status => {
+ if (status.get('account') !== relationship.id) {
+ return;
+ }
+
+ state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+ switch(action.type) {
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeStatus(state, action.status);
+ case REBLOG_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ case PIN_SUCCESS:
+ case UNPIN_SUCCESS:
+ return normalizeStatus(state, action.response);
+ case FAVOURITE_REQUEST:
+ return state.setIn([action.status.get('id'), 'favourited'], true);
+ case FAVOURITE_FAIL:
+ return state.setIn([action.status.get('id'), 'favourited'], false);
+ case REBLOG_REQUEST:
+ return state.setIn([action.status.get('id'), 'reblogged'], true);
+ case REBLOG_FAIL:
+ return state.setIn([action.status.get('id'), 'reblogged'], false);
+ case STATUS_MUTE_SUCCESS:
+ return state.setIn([action.id, 'muted'], true);
+ case STATUS_UNMUTE_SUCCESS:
+ return state.setIn([action.id, 'muted'], false);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ case PINNED_STATUSES_FETCH_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeStatuses(state, action.statuses);
+ case TIMELINE_DELETE:
+ return deleteStatus(state, action.id, action.references);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterStatuses(state, action.relationship);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/timelines.js b/app/javascript/themes/glitch/reducers/timelines.js
new file mode 100644
index 000000000..7f19a1897
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/timelines.js
@@ -0,0 +1,149 @@
+import {
+ TIMELINE_REFRESH_REQUEST,
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_REFRESH_FAIL,
+ TIMELINE_UPDATE,
+ TIMELINE_DELETE,
+ TIMELINE_EXPAND_SUCCESS,
+ TIMELINE_EXPAND_REQUEST,
+ TIMELINE_EXPAND_FAIL,
+ TIMELINE_SCROLL_TOP,
+ TIMELINE_CONNECT,
+ TIMELINE_DISCONNECT,
+} from 'themes/glitch/actions/timelines';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const initialTimeline = ImmutableMap({
+ unread: 0,
+ online: false,
+ top: true,
+ loaded: false,
+ isLoading: false,
+ next: false,
+ items: ImmutableList(),
+});
+
+const normalizeTimeline = (state, timeline, statuses, next) => {
+ const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+ const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+ const wasLoaded = state.getIn([timeline, 'loaded']);
+ const hadNext = state.getIn([timeline, 'next']);
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ mMap.set('loaded', true);
+ mMap.set('isLoading', false);
+ if (!hadNext) mMap.set('next', next);
+ mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids);
+ }));
+};
+
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
+ const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+ const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ mMap.set('isLoading', false);
+ mMap.set('next', next);
+ mMap.set('items', oldIds.concat(ids));
+ }));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+ const top = state.getIn([timeline, 'top']);
+ const ids = state.getIn([timeline, 'items'], ImmutableList());
+ const includesId = ids.includes(status.get('id'));
+ const unread = state.getIn([timeline, 'unread'], 0);
+
+ if (includesId) {
+ return state;
+ }
+
+ let newIds = ids;
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ if (!top) mMap.set('unread', unread + 1);
+ if (top && ids.size > 40) newIds = newIds.take(20);
+ if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
+ mMap.set('items', newIds.unshift(status.get('id')));
+ }));
+};
+
+const deleteStatus = (state, id, accountId, references) => {
+ state.keySeq().forEach(timeline => {
+ state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
+ });
+
+ // Remove reblogs of deleted status
+ references.forEach(ref => {
+ state = deleteStatus(state, ref[0], ref[1], []);
+ });
+
+ return state;
+};
+
+const filterTimelines = (state, relationship, statuses) => {
+ let references;
+
+ statuses.forEach(status => {
+ if (status.get('account') !== relationship.id) {
+ return;
+ }
+
+ references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+ state = deleteStatus(state, status.get('id'), status.get('account'), references);
+ });
+
+ return state;
+};
+
+const filterTimeline = (timeline, state, relationship, statuses) =>
+ state.updateIn([timeline, 'items'], ImmutableList(), list =>
+ list.filterNot(statusId =>
+ statuses.getIn([statusId, 'account']) === relationship.id
+ ));
+
+const updateTop = (state, timeline, top) => {
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ if (top) mMap.set('unread', 0);
+ mMap.set('top', top);
+ }));
+};
+
+export default function timelines(state = initialState, action) {
+ switch(action.type) {
+ case TIMELINE_REFRESH_REQUEST:
+ case TIMELINE_EXPAND_REQUEST:
+ return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
+ case TIMELINE_REFRESH_FAIL:
+ case TIMELINE_EXPAND_FAIL:
+ return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
+ case TIMELINE_REFRESH_SUCCESS:
+ return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+ case TIMELINE_EXPAND_SUCCESS:
+ return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+ case TIMELINE_UPDATE:
+ return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
+ case TIMELINE_DELETE:
+ return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterTimelines(state, action.relationship, action.statuses);
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ return filterTimeline('home', state, action.relationship, action.statuses);
+ case TIMELINE_SCROLL_TOP:
+ return updateTop(state, action.timeline, action.top);
+ case TIMELINE_CONNECT:
+ return state.update(action.timeline, initialTimeline, map => map.set('online', true));
+ case TIMELINE_DISCONNECT:
+ return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/reducers/user_lists.js b/app/javascript/themes/glitch/reducers/user_lists.js
new file mode 100644
index 000000000..8c3a7d748
--- /dev/null
+++ b/app/javascript/themes/glitch/reducers/user_lists.js
@@ -0,0 +1,80 @@
+import {
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+ FOLLOW_REQUEST_REJECT_SUCCESS,
+} from 'themes/glitch/actions/accounts';
+import {
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from 'themes/glitch/actions/interactions';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from 'themes/glitch/actions/mutes';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ followers: ImmutableMap(),
+ following: ImmutableMap(),
+ reblogged_by: ImmutableMap(),
+ favourited_by: ImmutableMap(),
+ follow_requests: ImmutableMap(),
+ blocks: ImmutableMap(),
+ mutes: ImmutableMap(),
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+ return state.setIn([type, id], ImmutableMap({
+ next,
+ items: ImmutableList(accounts.map(item => item.id)),
+ }));
+};
+
+const appendToList = (state, type, id, accounts, next) => {
+ return state.updateIn([type, id], map => {
+ return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
+ });
+};
+
+export default function userLists(state = initialState, action) {
+ switch(action.type) {
+ case FOLLOWERS_FETCH_SUCCESS:
+ return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWERS_EXPAND_SUCCESS:
+ return appendToList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWING_FETCH_SUCCESS:
+ return normalizeList(state, 'following', action.id, action.accounts, action.next);
+ case FOLLOWING_EXPAND_SUCCESS:
+ return appendToList(state, 'following', action.id, action.accounts, action.next);
+ case REBLOGS_FETCH_SUCCESS:
+ return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+ case FAVOURITES_FETCH_SUCCESS:
+ return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+ case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+ case FOLLOW_REQUEST_REJECT_SUCCESS:
+ return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+ case BLOCKS_FETCH_SUCCESS:
+ return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+ case BLOCKS_EXPAND_SUCCESS:
+ return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+ case MUTES_FETCH_SUCCESS:
+ return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ case MUTES_EXPAND_SUCCESS:
+ return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/themes/glitch/selectors/index.js b/app/javascript/themes/glitch/selectors/index.js
new file mode 100644
index 000000000..d26d1b727
--- /dev/null
+++ b/app/javascript/themes/glitch/selectors/index.js
@@ -0,0 +1,87 @@
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+
+const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
+const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
+
+export const makeGetAccount = () => {
+ return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
+ if (base === null) {
+ return null;
+ }
+
+ return base.merge(counters).set('relationship', relationship);
+ });
+};
+
+export const makeGetStatus = () => {
+ return createSelector(
+ [
+ (state, id) => state.getIn(['statuses', id]),
+ (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+ (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+ (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+ ],
+
+ (statusBase, statusReblog, accountBase, accountReblog) => {
+ if (!statusBase) {
+ return null;
+ }
+
+ if (statusReblog) {
+ statusReblog = statusReblog.set('account', accountReblog);
+ } else {
+ statusReblog = null;
+ }
+
+ return statusBase.withMutations(map => {
+ map.set('reblog', statusReblog);
+ map.set('account', accountBase);
+ });
+ }
+ );
+};
+
+const getAlertsBase = state => state.get('alerts');
+
+export const getAlerts = createSelector([getAlertsBase], (base) => {
+ let arr = [];
+
+ base.forEach(item => {
+ arr.push({
+ message: item.get('message'),
+ title: item.get('title'),
+ key: item.get('key'),
+ dismissAfter: 5000,
+ barStyle: {
+ zIndex: 200,
+ },
+ });
+ });
+
+ return arr;
+});
+
+export const makeGetNotification = () => {
+ return createSelector([
+ (_, base) => base,
+ (state, _, accountId) => state.getIn(['accounts', accountId]),
+ ], (base, account) => {
+ return base.set('account', account);
+ });
+};
+
+export const getAccountGallery = createSelector([
+ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
+ state => state.get('statuses'),
+], (statusIds, statuses) => {
+ let medias = ImmutableList();
+
+ statusIds.forEach(statusId => {
+ const status = statuses.get(statusId);
+ medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
+ });
+
+ return medias;
+});
diff --git a/app/javascript/themes/glitch/service_worker/entry.js b/app/javascript/themes/glitch/service_worker/entry.js
new file mode 100644
index 000000000..eea4cfc3c
--- /dev/null
+++ b/app/javascript/themes/glitch/service_worker/entry.js
@@ -0,0 +1,10 @@
+import './web_push_notifications';
+
+// Cause a new version of a registered Service Worker to replace an existing one
+// that is already installed, and replace the currently active worker on open pages.
+self.addEventListener('install', function(event) {
+ event.waitUntil(self.skipWaiting());
+});
+self.addEventListener('activate', function(event) {
+ event.waitUntil(self.clients.claim());
+});
diff --git a/app/javascript/themes/glitch/service_worker/web_push_notifications.js b/app/javascript/themes/glitch/service_worker/web_push_notifications.js
new file mode 100644
index 000000000..f63cff335
--- /dev/null
+++ b/app/javascript/themes/glitch/service_worker/web_push_notifications.js
@@ -0,0 +1,159 @@
+const MAX_NOTIFICATIONS = 5;
+const GROUP_TAG = 'tag';
+
+// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
+const formatGroupTitle = (message, count) => message.replace('%{count}', count);
+
+const notify = options =>
+ self.registration.getNotifications().then(notifications => {
+ if (notifications.length === MAX_NOTIFICATIONS) {
+ // Reached the maximum number of notifications, proceed with grouping
+ const group = {
+ title: formatGroupTitle(options.data.message, notifications.length + 1),
+ body: notifications
+ .sort((n1, n2) => n1.timestamp < n2.timestamp)
+ .map(notification => notification.title).join('\n'),
+ badge: '/badge.png',
+ icon: '/android-chrome-192x192.png',
+ tag: GROUP_TAG,
+ data: {
+ url: (new URL('/web/notifications', self.location)).href,
+ count: notifications.length + 1,
+ message: options.data.message,
+ },
+ };
+
+ notifications.forEach(notification => notification.close());
+
+ return self.registration.showNotification(group.title, group);
+ } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
+ // Already grouped, proceed with appending the notification to the group
+ const group = cloneNotification(notifications[0]);
+
+ group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+ group.body = `${options.title}\n${group.body}`;
+ group.data = { ...group.data, count: group.data.count + 1 };
+
+ return self.registration.showNotification(group.title, group);
+ }
+
+ return self.registration.showNotification(options.title, options);
+ });
+
+const handlePush = (event) => {
+ const options = event.data.json();
+
+ options.body = options.data.nsfw || options.data.content;
+ options.dir = options.data.dir;
+ options.image = options.image || undefined; // Null results in a network request (404)
+ options.timestamp = options.timestamp && new Date(options.timestamp);
+
+ const expandAction = options.data.actions.find(action => action.todo === 'expand');
+
+ if (expandAction) {
+ options.actions = [expandAction];
+ options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
+ options.data.hiddenImage = options.image;
+ options.image = undefined;
+ } else {
+ options.actions = options.data.actions;
+ }
+
+ event.waitUntil(notify(options));
+};
+
+const cloneNotification = (notification) => {
+ const clone = { };
+
+ for(var k in notification) {
+ clone[k] = notification[k];
+ }
+
+ return clone;
+};
+
+const expandNotification = (notification) => {
+ const nextNotification = cloneNotification(notification);
+
+ nextNotification.body = notification.data.content;
+ nextNotification.image = notification.data.hiddenImage;
+ nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+
+ return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const makeRequest = (notification, action) =>
+ fetch(action.action, {
+ headers: {
+ 'Authorization': `Bearer ${notification.data.access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ method: action.method,
+ credentials: 'include',
+ });
+
+const findBestClient = clients => {
+ const focusedClient = clients.find(client => client.focused);
+ const visibleClient = clients.find(client => client.visibilityState === 'visible');
+
+ return focusedClient || visibleClient || clients[0];
+};
+
+const openUrl = url =>
+ self.clients.matchAll({ type: 'window' }).then(clientList => {
+ if (clientList.length !== 0) {
+ const webClients = clientList.filter(client => /\/web\//.test(client.url));
+
+ if (webClients.length !== 0) {
+ const client = findBestClient(webClients);
+ const { pathname } = new URL(url);
+
+ if (pathname.startsWith('/web/')) {
+ return client.focus().then(client => client.postMessage({
+ type: 'navigate',
+ path: pathname.slice('/web/'.length - 1),
+ }));
+ }
+ } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
+ const client = findBestClient(clientList);
+
+ return client.navigate(url).then(client => client.focus());
+ }
+ }
+
+ return self.clients.openWindow(url);
+ });
+
+const removeActionFromNotification = (notification, action) => {
+ const actions = notification.actions.filter(act => act.action !== action.action);
+ const nextNotification = cloneNotification(notification);
+
+ nextNotification.actions = actions;
+
+ return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const handleNotificationClick = (event) => {
+ const reactToNotificationClick = new Promise((resolve, reject) => {
+ if (event.action) {
+ const action = event.notification.data.actions.find(({ action }) => action === event.action);
+
+ if (action.todo === 'expand') {
+ resolve(expandNotification(event.notification));
+ } else if (action.todo === 'request') {
+ resolve(makeRequest(event.notification, action)
+ .then(() => removeActionFromNotification(event.notification, action)));
+ } else {
+ reject(`Unknown action: ${action.todo}`);
+ }
+ } else {
+ event.notification.close();
+ resolve(openUrl(event.notification.data.url));
+ }
+ });
+
+ event.waitUntil(reactToNotificationClick);
+};
+
+self.addEventListener('push', handlePush);
+self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/themes/glitch/store/configureStore.js b/app/javascript/themes/glitch/store/configureStore.js
new file mode 100644
index 000000000..1376d4cba
--- /dev/null
+++ b/app/javascript/themes/glitch/store/configureStore.js
@@ -0,0 +1,15 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import appReducer from '../reducers';
+import loadingBarMiddleware from '../middleware/loading_bar';
+import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from '../middleware/sounds';
+
+export default function configureStore() {
+ return createStore(appReducer, compose(applyMiddleware(
+ thunk,
+ loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+ errorsMiddleware(),
+ soundsMiddleware()
+ ), window.devToolsExtension ? window.devToolsExtension() : f => f));
+};
diff --git a/app/javascript/themes/glitch/styles/_mixins.scss b/app/javascript/themes/glitch/styles/_mixins.scss
new file mode 100644
index 000000000..7412991b8
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/_mixins.scss
@@ -0,0 +1,42 @@
+@mixin avatar-radius() {
+ border-radius: $ui-avatar-border-size;
+ background: transparent no-repeat;
+ background-position: 50%;
+ background-clip: padding-box;
+}
+
+@mixin avatar-size($size:48px) {
+ width: $size;
+ height: $size;
+ background-size: $size $size;
+}
+
+@mixin single-column($media, $parent: '&') {
+ .auto-columns #{$parent} {
+ @media #{$media} {
+ @content;
+ }
+ }
+ .single-column #{$parent} {
+ @content;
+ }
+}
+
+@mixin limited-single-column($media, $parent: '&') {
+ .auto-columns #{$parent}, .single-column #{$parent} {
+ @media #{$media} {
+ @content;
+ }
+ }
+}
+
+@mixin multi-columns($media, $parent: '&') {
+ .auto-columns #{$parent} {
+ @media #{$media} {
+ @content;
+ }
+ }
+ .multi-columns #{$parent} {
+ @content;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/about.scss b/app/javascript/themes/glitch/styles/about.scss
new file mode 100644
index 000000000..4ec689427
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/about.scss
@@ -0,0 +1,822 @@
+.landing-page {
+ p,
+ li {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ margin-bottom: 12px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ em {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ font-weight: 500;
+ background: transparent;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: lighten($ui-primary-color, 10%);
+ }
+
+ h1 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 26px;
+ line-height: 30px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+
+ small {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ display: block;
+ font-size: 18px;
+ font-weight: 400;
+ color: $ui-base-lighter-color;
+ }
+ }
+
+ h2 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 22px;
+ line-height: 26px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h3 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 18px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h4 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h5 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h6 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 12px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ ul,
+ ol {
+ margin-left: 20px;
+
+ &[type='a'] {
+ list-style-type: lower-alpha;
+ }
+
+ &[type='i'] {
+ list-style-type: lower-roman;
+ }
+ }
+
+ ul {
+ list-style: disc;
+ }
+
+ ol {
+ list-style: decimal;
+ }
+
+ li > ol,
+ li > ul {
+ margin-top: 6px;
+ }
+
+ hr {
+ border-color: rgba($ui-base-lighter-color, .6);
+ }
+
+ .container {
+ width: 100%;
+ box-sizing: border-box;
+ max-width: 800px;
+ margin: 0 auto;
+ word-wrap: break-word;
+ }
+
+ .header-wrapper {
+ padding-top: 15px;
+ background: $ui-base-color;
+ background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
+ position: relative;
+
+ &.compact {
+ background: $ui-base-color;
+ padding-bottom: 15px;
+
+ .hero .heading {
+ padding-bottom: 20px;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .mascot-container {
+ max-width: 800px;
+ margin: 0 auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ }
+
+ .mascot {
+ position: absolute;
+ bottom: -14px;
+ width: auto;
+ height: auto;
+ left: 60px;
+ z-index: 3;
+ }
+ }
+
+ .header {
+ line-height: 30px;
+ overflow: hidden;
+
+ .container {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .links {
+ position: relative;
+ z-index: 4;
+
+ a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $ui-primary-color;
+ text-decoration: none;
+ padding: 12px 16px;
+ line-height: 32px;
+ font-family: 'mastodon-font-display', sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+
+ &:hover {
+ color: $ui-secondary-color;
+ }
+ }
+
+ .brand {
+ a {
+ padding-left: 0;
+ padding-right: 0;
+ color: $white;
+ }
+
+ img {
+ height: 32px;
+ position: relative;
+ top: 4px;
+ left: -10px;
+ }
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+
+ li {
+ display: inline-block;
+ vertical-align: bottom;
+ margin: 0;
+
+ &:first-child a {
+ padding-left: 0;
+ }
+
+ &:last-child a {
+ padding-right: 0;
+ }
+ }
+ }
+ }
+
+ .hero {
+ margin-top: 50px;
+ align-items: center;
+ position: relative;
+
+ .floats {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+
+ div {
+ position: absolute;
+ transition: all 0.1s linear;
+ animation-name: floating;
+ animation-iteration-count: infinite;
+ animation-direction: alternate;
+ animation-timing-function: ease-in-out;
+ z-index: 2;
+ }
+
+ .float-1 {
+ width: 324px;
+ height: 170px;
+ right: -120px;
+ bottom: 0;
+ animation-duration: 3s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+
+ .float-2 {
+ width: 241px;
+ height: 100px;
+ right: 210px;
+ bottom: 0;
+ animation-duration: 3.5s;
+ animation-delay: 0.2s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+
+ .float-3 {
+ width: 267px;
+ height: 140px;
+ right: 110px;
+ top: -30px;
+ animation-duration: 4s;
+ animation-delay: 0.5s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+ }
+
+ .heading {
+ position: relative;
+ z-index: 4;
+ padding-bottom: 150px;
+ }
+
+ .simple_form,
+ .closed-registrations-message {
+ background: darken($ui-base-color, 4%);
+ width: 280px;
+ padding: 15px 20px;
+ border-radius: 4px 4px 0 0;
+ line-height: initial;
+ position: relative;
+ z-index: 4;
+
+ .actions {
+ margin-bottom: 0;
+
+ button,
+ .button,
+ .block-button {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .closed-registrations-message {
+ min-height: 330px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+ }
+ }
+
+ .about-short {
+ background: darken($ui-base-color, 4%);
+ padding: 50px 0 30px;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ .information-board {
+ background: darken($ui-base-color, 4%);
+ padding: 20px 0;
+
+ .container {
+ position: relative;
+ padding-right: 280px + 15px;
+ }
+
+ .information-board-sections {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ }
+
+ .section {
+ flex: 1 0 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ line-height: 28px;
+ color: $primary-text-color;
+ text-align: right;
+ padding: 10px 15px;
+
+ span,
+ strong {
+ display: block;
+ }
+
+ span {
+ &:last-child {
+ color: $ui-secondary-color;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ font-size: 32px;
+ line-height: 48px;
+ }
+ }
+
+ .panel {
+ position: absolute;
+ width: 280px;
+ box-sizing: border-box;
+ background: darken($ui-base-color, 8%);
+ padding: 20px;
+ padding-top: 10px;
+ border-radius: 4px 4px 0 0;
+ right: 0;
+ bottom: -40px;
+
+ .panel-header {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ color: $ui-primary-color;
+ padding-bottom: 5px;
+ margin-bottom: 15px;
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+
+ a,
+ span {
+ font-weight: 400;
+ color: darken($ui-primary-color, 10%);
+ }
+
+ a {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .owner {
+ text-align: center;
+
+ .avatar {
+ @include avatar-size(80px);
+ margin: 0 auto;
+ margin-bottom: 15px;
+
+ img {
+ @include avatar-radius();
+ @include avatar-size(80px);
+ display: block;
+ }
+ }
+
+ .name {
+ font-size: 14px;
+
+ a {
+ display: block;
+ color: $primary-text-color;
+ text-decoration: none;
+
+ &:hover {
+ .display_name {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .username {
+ display: block;
+ color: $ui-primary-color;
+ }
+ }
+ }
+ }
+
+ .features {
+ padding: 50px 0;
+
+ .container {
+ display: flex;
+ }
+
+ #mastodon-timeline {
+ display: flex;
+ -webkit-overflow-scrolling: touch;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 400;
+ color: $primary-text-color;
+ width: 330px;
+ margin-right: 30px;
+ flex: 0 0 auto;
+ background: $ui-base-color;
+ overflow: hidden;
+ border-radius: 4px;
+ box-shadow: 0 0 6px rgba($black, 0.1);
+
+ .column-header {
+ color: inherit;
+ font-family: inherit;
+ font-size: 16px;
+ line-height: inherit;
+ font-weight: inherit;
+ margin: 0;
+ padding: 15px;
+ }
+
+ .column {
+ padding: 0;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .scrollable {
+ height: 400px;
+ }
+
+ p {
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: inherit;
+ color: $primary-text-color;
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ a {
+ color: $ui-secondary-color;
+ text-decoration: none;
+ }
+ }
+ }
+
+ .about-mastodon {
+ max-width: 675px;
+
+ p {
+ margin-bottom: 20px;
+ }
+
+ .features-list {
+ margin-top: 20px;
+
+ .features-list__row {
+ display: flex;
+ padding: 10px 0;
+ justify-content: space-between;
+
+ &:first-child {
+ padding-top: 0;
+ }
+
+ .visual {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ margin-left: 15px;
+
+ .fa {
+ display: block;
+ color: $ui-primary-color;
+ font-size: 48px;
+ }
+ }
+
+ .text {
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ h6 {
+ font-size: inherit;
+ line-height: inherit;
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .extended-description {
+ padding: 50px 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ .footer-links {
+ padding-bottom: 50px;
+ text-align: right;
+ color: $ui-base-lighter-color;
+
+ p {
+ font-size: 14px;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+ }
+
+ @media screen and (max-width: 840px) {
+ .container {
+ padding: 0 20px;
+ }
+
+ .information-board {
+
+ .container {
+ padding-right: 20px;
+ }
+
+ .section {
+ text-align: center;
+ }
+
+ .panel {
+ position: static;
+ margin-top: 20px;
+ width: 100%;
+ border-radius: 4px;
+
+ .panel-header {
+ text-align: center;
+ }
+ }
+ }
+
+ .header-wrapper .mascot {
+ left: 20px;
+ }
+ }
+
+ @media screen and (max-width: 689px) {
+ .header-wrapper .mascot {
+ display: none;
+ }
+ }
+
+ @media screen and (max-width: 675px) {
+ .header-wrapper {
+ padding-top: 0;
+
+ &.compact {
+ padding-bottom: 0;
+ }
+
+ &.compact .hero .heading {
+ text-align: initial;
+ }
+ }
+
+ .header .container,
+ .features .container {
+ display: block;
+ }
+
+ .header {
+
+ .links {
+ padding-top: 15px;
+ background: darken($ui-base-color, 4%);
+
+ a {
+ padding: 12px 8px;
+ }
+
+ .nav {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-around;
+ }
+
+ .brand img {
+ left: 0;
+ top: 0;
+ }
+ }
+
+ .hero {
+ margin-top: 30px;
+ padding: 0;
+
+ .floats {
+ display: none;
+ }
+
+ .heading {
+ padding: 30px 20px;
+ text-align: center;
+ }
+
+ .simple_form,
+ .closed-registrations-message {
+ background: darken($ui-base-color, 8%);
+ width: 100%;
+ border-radius: 0;
+ box-sizing: border-box;
+ }
+ }
+ }
+
+ .features #mastodon-timeline {
+ height: 70vh;
+ width: 100%;
+ margin-bottom: 50px;
+
+ .column {
+ width: 100%;
+ }
+ }
+ }
+
+ .cta {
+ margin: 20px;
+ }
+
+ &.tag-page {
+ .features {
+ padding: 30px 0;
+
+ .container {
+ max-width: 820px;
+
+ #mastodon-timeline {
+ margin-right: 0;
+ border-top-right-radius: 0;
+ }
+
+ .about-mastodon {
+ .about-hashtag {
+ background: darken($ui-base-color, 4%);
+ padding: 0 20px 20px 30px;
+ border-radius: 0 5px 5px 0;
+
+ .brand {
+ padding-top: 20px;
+ margin-bottom: 20px;
+
+ img {
+ height: 48px;
+ width: auto;
+ }
+ }
+
+ p {
+ strong {
+ color: $ui-secondary-color;
+ font-weight: 700;
+ }
+ }
+
+ .cta {
+ margin: 0;
+
+ .button {
+ margin-right: 4px;
+ }
+ }
+ }
+
+ .features-list {
+ margin-left: 30px;
+ margin-right: 10px;
+ }
+ }
+ }
+ }
+
+ @media screen and (max-width: 675px) {
+ .features {
+ padding: 10px 0;
+
+ .container {
+ display: flex;
+ flex-direction: column;
+
+ #mastodon-timeline {
+ order: 2;
+ flex: 0 0 auto;
+ height: 60vh;
+ margin-bottom: 20px;
+ border-top-right-radius: 4px;
+ }
+
+ .about-mastodon {
+ order: 1;
+ flex: 0 0 auto;
+ max-width: 100%;
+
+ .about-hashtag {
+ background: unset;
+ padding: 0;
+ border-radius: 0;
+
+ .cta {
+ margin: 20px 0;
+ }
+ }
+
+ .features-list {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@keyframes floating {
+ from {
+ transform: translate(0, 0);
+ }
+
+ 65% {
+ transform: translate(0, 4px);
+ }
+
+ to {
+ transform: translate(0, -0);
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/accounts.scss b/app/javascript/themes/glitch/styles/accounts.scss
new file mode 100644
index 000000000..2cf98c642
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/accounts.scss
@@ -0,0 +1,589 @@
+.card {
+ background-color: lighten($ui-base-color, 4%);
+ background-size: cover;
+ background-position: center;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ overflow: hidden;
+ position: relative;
+ display: flex;
+
+ &::after {
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ display: block;
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+
+ @media screen and (max-width: 740px) {
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .card__illustration {
+ padding: 60px 0;
+ position: relative;
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .card__bio {
+ max-width: 260px;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: rgba(darken($ui-base-color, 8%), 0.8);
+ position: relative;
+ z-index: 2;
+ }
+
+ &.compact {
+ padding: 30px 0;
+ border-radius: 4px;
+
+ .avatar {
+ margin-bottom: 0;
+
+ img {
+ object-fit: cover;
+ }
+ }
+ }
+
+ .name {
+ display: block;
+ font-size: 20px;
+ line-height: 18px * 1.5;
+ color: $primary-text-color;
+ padding: 10px 15px;
+ padding-bottom: 0;
+ font-weight: 500;
+ position: relative;
+ z-index: 2;
+ margin-bottom: 30px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ small {
+ display: block;
+ font-size: 14px;
+ color: $ui-highlight-color;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .avatar {
+ @include avatar-size(120px);
+ margin: 0 auto;
+ position: relative;
+ z-index: 2;
+
+ img {
+ @include avatar-radius();
+ @include avatar-size(120px);
+ display: block;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ }
+ }
+
+ .controls {
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ z-index: 2;
+
+ .icon-button {
+ color: rgba($white, 0.8);
+ text-decoration: none;
+ font-size: 13px;
+ line-height: 13px;
+ font-weight: 500;
+
+ .fa {
+ font-weight: 400;
+ margin-right: 5px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $white;
+ }
+ }
+ }
+
+ .roles {
+ margin-bottom: 30px;
+ padding: 0 15px;
+ }
+
+ .details-counters {
+ margin-top: 30px;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ }
+
+ .counter {
+ width: 33.3%;
+ box-sizing: border-box;
+ flex: 0 0 auto;
+ color: $ui-primary-color;
+ padding: 5px 10px 0;
+ margin-bottom: 10px;
+ border-right: 1px solid lighten($ui-base-color, 4%);
+ cursor: default;
+ text-align: center;
+ position: relative;
+
+ a {
+ display: block;
+ }
+
+ &:last-child {
+ border-right: 0;
+ }
+
+ &::after {
+ display: block;
+ content: "";
+ position: absolute;
+ bottom: -10px;
+ left: 0;
+ width: 100%;
+ border-bottom: 4px solid $ui-primary-color;
+ opacity: 0.5;
+ transition: all 400ms ease;
+ }
+
+ &.active {
+ &::after {
+ border-bottom: 4px solid $ui-highlight-color;
+ opacity: 1;
+ }
+ }
+
+ &:hover {
+ &::after {
+ opacity: 1;
+ transition-duration: 100ms;
+ }
+ }
+
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .counter-label {
+ font-size: 12px;
+ display: block;
+ margin-bottom: 5px;
+ }
+
+ .counter-number {
+ font-weight: 500;
+ font-size: 18px;
+ color: $primary-text-color;
+ font-family: 'mastodon-font-display', sans-serif;
+ }
+ }
+
+ .bio {
+ font-size: 14px;
+ line-height: 18px;
+ padding: 0 15px;
+ color: $ui-secondary-color;
+ }
+
+ .metadata {
+ $meta-table-border: darken($classic-highlight-color, 20%);//#174f77;
+
+ border-collapse: collapse;
+ padding: 0;
+ margin: 15px -15px -10px -15px;
+ border: 0 none;
+ border-top: 1px solid $meta-table-border;
+ border-bottom: 1px solid $meta-table-border;
+
+ td, th {
+ padding: 10px;
+ border: 0 none;
+ border-bottom: 1px solid $meta-table-border;
+ vertical-align: middle;
+ }
+
+ tr:last-child {
+ td, th {
+ border-bottom: 0 none;
+ }
+ }
+
+ td {
+ color: $ui-primary-color;
+ width:100%; // makes it stretch
+ padding-left: 0;
+ }
+
+ th {
+ padding-left: 15px;
+ font-weight: bold;
+ text-align: left;
+ width: 94px;
+ color: $ui-secondary-color;
+ background: darken($ui-base-color, 8%);
+ //background: #131415;
+ }
+
+ a {
+ color: $classic-highlight-color;
+ }
+ }
+
+ @media screen and (max-width: 480px) {
+ display: block;
+
+ .card__bio {
+ max-width: none;
+ }
+
+ .name,
+ .roles {
+ text-align: center;
+ margin-bottom: 15px;
+ }
+
+ .bio {
+ margin-bottom: 15px;
+ }
+ }
+}
+
+.pagination {
+ padding: 30px 0;
+ text-align: center;
+ overflow: hidden;
+
+ a,
+ .current,
+ .next,
+ .prev,
+ .page,
+ .gap {
+ font-size: 14px;
+ color: $primary-text-color;
+ font-weight: 500;
+ display: inline-block;
+ padding: 6px 10px;
+ text-decoration: none;
+ }
+
+ .current {
+ background: $simple-background-color;
+ border-radius: 100px;
+ color: $ui-base-color;
+ cursor: default;
+ margin: 0 10px;
+ }
+
+ .gap {
+ cursor: default;
+ }
+
+ .prev,
+ .next {
+ text-transform: uppercase;
+ color: $ui-secondary-color;
+ }
+
+ .prev {
+ float: left;
+ padding-left: 0;
+
+ .fa {
+ display: inline-block;
+ margin-right: 5px;
+ }
+ }
+
+ .next {
+ float: right;
+ padding-right: 0;
+
+ .fa {
+ display: inline-block;
+ margin-left: 5px;
+ }
+ }
+
+ .disabled {
+ cursor: default;
+ color: lighten($ui-base-color, 10%);
+ }
+
+ @media screen and (max-width: 700px) {
+ padding: 30px 20px;
+
+ .page {
+ display: none;
+ }
+
+ .next,
+ .prev {
+ display: inline-block;
+ }
+ }
+}
+
+.accounts-grid {
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ background: darken($simple-background-color, 8%);
+ border-radius: 0 0 4px 4px;
+ padding: 20px 5px;
+ padding-bottom: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: wrap;
+ z-index: 2;
+ position: relative;
+
+ @media screen and (max-width: 740px) {
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .account-grid-card {
+ box-sizing: border-box;
+ width: 335px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ color: $ui-base-color;
+ margin: 0 5px 10px;
+ position: relative;
+
+ @media screen and (max-width: 740px) {
+ width: calc(100% - 10px);
+ }
+
+ .account-grid-card__header {
+ overflow: hidden;
+ height: 100px;
+ border-radius: 4px 4px 0 0;
+ background-color: lighten($ui-base-color, 4%);
+ background-size: cover;
+ background-position: center;
+ position: relative;
+
+ &::after {
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ display: block;
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+ }
+
+ .account-grid-card__avatar {
+ box-sizing: border-box;
+ padding: 15px;
+ position: absolute;
+ z-index: 2;
+ top: 100px - (40px + 2px);
+ left: -2px;
+ }
+
+ .avatar {
+ @include avatar-size(80px);
+
+ img {
+ display: block;
+ @include avatar-radius();
+ @include avatar-size(80px);
+ border: 2px solid $simple-background-color;
+ background: $simple-background-color;
+ }
+ }
+
+ .name {
+ padding: 15px;
+ padding-top: 10px;
+ padding-left: 15px + 80px + 15px;
+
+ a {
+ display: block;
+ color: $ui-base-color;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ font-weight: 500;
+
+ &:hover {
+ .display_name {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ .display_name {
+ font-size: 16px;
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .username {
+ color: lighten($ui-base-color, 34%);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ .note {
+ padding: 10px 15px;
+ padding-top: 15px;
+ box-sizing: border-box;
+ color: lighten($ui-base-color, 26%);
+ word-wrap: break-word;
+ min-height: 80px;
+ }
+ }
+}
+
+.nothing-here {
+ width: 100%;
+ display: block;
+ color: $ui-primary-color;
+ font-size: 14px;
+ font-weight: 500;
+ text-align: center;
+ padding: 60px 0;
+ padding-top: 55px;
+ cursor: default;
+}
+
+.account-card {
+ padding: 14px 10px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ text-align: left;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+ .detailed-status__display-name {
+ display: block;
+ overflow: hidden;
+ margin-bottom: 15px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ & > div {
+ @include avatar-size(48px);
+ float: left;
+ margin-right: 10px;
+ }
+
+ .avatar {
+ @include avatar-radius();
+ display: block;
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ cursor: default;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+
+ &:hover {
+ .display-name {
+ strong {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ .account__header__content {
+ font-size: 14px;
+ color: $ui-base-color;
+ }
+}
+
+.activity-stream-tabs {
+ background: $simple-background-color;
+ border-bottom: 1px solid $ui-secondary-color;
+ position: relative;
+ z-index: 2;
+
+ a {
+ display: inline-block;
+ padding: 15px;
+ text-decoration: none;
+ color: $ui-highlight-color;
+ text-transform: uppercase;
+ font-weight: 500;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-highlight-color, 8%);
+ }
+
+ &.active {
+ color: $ui-base-color;
+ cursor: default;
+ }
+ }
+}
+
+.account-role {
+ display: inline-block;
+ padding: 4px 6px;
+ cursor: default;
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: 500;
+ color: $ui-secondary-color;
+ background-color: rgba($ui-secondary-color, 0.1);
+ border: 1px solid rgba($ui-secondary-color, 0.5);
+
+ &.moderator {
+ color: $success-green;
+ background-color: rgba($success-green, 0.1);
+ border-color: rgba($success-green, 0.5);
+ }
+
+ &.admin {
+ color: lighten($error-red, 12%);
+ background-color: rgba(lighten($error-red, 12%), 0.1);
+ border-color: rgba(lighten($error-red, 12%), 0.5);
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/admin.scss b/app/javascript/themes/glitch/styles/admin.scss
new file mode 100644
index 000000000..87bc710af
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/admin.scss
@@ -0,0 +1,349 @@
+.admin-wrapper {
+ display: flex;
+ justify-content: center;
+ height: 100%;
+
+ .sidebar-wrapper {
+ flex: 1;
+ height: 100%;
+ background: $ui-base-color;
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .sidebar {
+ width: 240px;
+ height: 100%;
+ padding: 0;
+ overflow-y: auto;
+
+ .logo {
+ display: block;
+ margin: 40px auto;
+ width: 100px;
+ height: 100px;
+ }
+
+ ul {
+ list-style: none;
+ border-radius: 4px 0 0 4px;
+ overflow: hidden;
+ margin-bottom: 20px;
+
+ a {
+ display: block;
+ padding: 15px;
+ color: rgba($primary-text-color, 0.7);
+ text-decoration: none;
+ transition: all 200ms linear;
+ border-radius: 4px 0 0 4px;
+
+ i.fa {
+ margin-right: 5px;
+ }
+
+ &:hover {
+ color: $primary-text-color;
+ background-color: darken($ui-base-color, 5%);
+ transition: all 100ms linear;
+ }
+
+ &.selected {
+ background: darken($ui-base-color, 2%);
+ border-radius: 4px 0 0;
+ }
+ }
+
+ ul {
+ background: darken($ui-base-color, 4%);
+ border-radius: 0 0 0 4px;
+ margin: 0;
+
+ a {
+ border: 0;
+ padding: 15px 35px;
+
+ &.selected {
+ color: $primary-text-color;
+ background-color: $ui-highlight-color;
+ border-bottom: 0;
+ border-radius: 0;
+
+ &:hover {
+ background-color: lighten($ui-highlight-color, 5%);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .content-wrapper {
+ flex: 2;
+ overflow: auto;
+ }
+
+ .content {
+ max-width: 700px;
+ padding: 20px 15px;
+ padding-top: 60px;
+ padding-left: 25px;
+
+ h2 {
+ color: $ui-secondary-color;
+ font-size: 24px;
+ line-height: 28px;
+ font-weight: 400;
+ margin-bottom: 40px;
+ }
+
+ h3 {
+ color: $ui-secondary-color;
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: 400;
+ margin-bottom: 30px;
+ }
+
+ h6 {
+ font-size: 16px;
+ color: $ui-secondary-color;
+ line-height: 28px;
+ font-weight: 400;
+ }
+
+ & > p {
+ font-size: 14px;
+ line-height: 18px;
+ color: $ui-secondary-color;
+ margin-bottom: 20px;
+
+ strong {
+ color: $primary-text-color;
+ font-weight: 500;
+ }
+ }
+
+ hr {
+ margin: 20px 0;
+ border: 0;
+ background: transparent;
+ border-bottom: 1px solid $ui-base-color;
+ }
+
+ .muted-hint {
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+ }
+
+ .positive-hint {
+ color: $valid-value-color;
+ font-weight: 500;
+ }
+ }
+
+ .simple_form {
+ max-width: 400px;
+
+ &.edit_user,
+ &.new_form_admin_settings,
+ &.new_form_two_factor_confirmation,
+ &.new_form_delete_confirmation,
+ &.new_import,
+ &.new_domain_block,
+ &.edit_domain_block {
+ max-width: none;
+ }
+
+ .form_two_factor_confirmation_code,
+ .form_delete_confirmation_password {
+ max-width: 400px;
+ }
+
+ .actions {
+ max-width: 400px;
+ }
+ }
+
+ @media screen and (max-width: 600px) {
+ display: block;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+
+ .sidebar-wrapper,
+ .content-wrapper {
+ flex: 0 0 auto;
+ height: auto;
+ overflow: initial;
+ }
+
+ .sidebar {
+ width: 100%;
+ padding: 10px 0;
+ height: auto;
+
+ .logo {
+ margin: 20px auto;
+ }
+ }
+
+ .content {
+ padding-top: 20px;
+ }
+ }
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+
+ .filter-subset {
+ flex: 0 0 auto;
+ margin: 0 40px 10px 0;
+
+ &:last-child {
+ margin-bottom: 20px;
+ }
+
+ ul {
+ margin-top: 5px;
+ list-style: none;
+
+ li {
+ display: inline-block;
+ margin-right: 5px;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ text-transform: uppercase;
+ font-size: 12px;
+ }
+
+ a {
+ display: inline-block;
+ color: rgba($primary-text-color, 0.7);
+ text-decoration: none;
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: 500;
+ border-bottom: 2px solid $ui-base-color;
+
+ &:hover {
+ color: $primary-text-color;
+ border-bottom: 2px solid lighten($ui-base-color, 5%);
+ }
+
+ &.selected {
+ color: $ui-highlight-color;
+ border-bottom: 2px solid $ui-highlight-color;
+ }
+ }
+ }
+}
+
+.report-accounts {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.report-accounts__item {
+ display: flex;
+ flex: 250px;
+ flex-direction: column;
+ margin: 0 5px;
+
+ & > strong {
+ display: block;
+ margin: 0 0 10px -5px;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 18px;
+ color: $ui-secondary-color;
+ }
+
+ .account-card {
+ flex: 1 1 auto;
+ }
+}
+
+.report-status,
+.account-status {
+ display: flex;
+ margin-bottom: 10px;
+
+ .activity-stream {
+ flex: 2 0 0;
+ margin-right: 20px;
+ max-width: calc(100% - 60px);
+
+ .entry {
+ border-radius: 4px;
+ }
+ }
+}
+
+.report-status__actions,
+.account-status__actions {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+
+ .icon-button {
+ font-size: 24px;
+ width: 24px;
+ text-align: center;
+ margin-bottom: 10px;
+ }
+}
+
+.batch-form-box {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 5px;
+
+ #form_status_batch_action {
+ margin: 0 5px 5px 0;
+ font-size: 14px;
+ }
+
+ input.button {
+ margin: 0 5px 5px 0;
+ }
+
+ .media-spoiler-toggle-buttons {
+ margin-left: auto;
+
+ .button {
+ overflow: visible;
+ margin: 0 0 5px 5px;
+ float: right;
+ }
+ }
+}
+
+.batch-checkbox,
+.batch-checkbox-all {
+ display: flex;
+ align-items: center;
+ margin-right: 5px;
+}
+
+.back-link {
+ margin-bottom: 10px;
+ font-size: 14px;
+
+ a {
+ color: $classic-highlight-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/basics.scss b/app/javascript/themes/glitch/styles/basics.scss
new file mode 100644
index 000000000..b5d77ff63
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/basics.scss
@@ -0,0 +1,122 @@
+body {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ background: $ui-base-color;
+ background-size: cover;
+ background-attachment: fixed;
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 400;
+ color: $primary-text-color;
+ padding-bottom: 20px;
+ text-rendering: optimizelegibility;
+ font-feature-settings: "kern";
+ text-size-adjust: none;
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ -webkit-tap-highlight-color: transparent;
+
+ &.system-font {
+ // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+ // -apple-system => Safari <11 specific
+ // BlinkMacSystemFont => Chrome <56 on macOS specific
+ // Segoe UI => Windows 7/8/10
+ // Oxygen => KDE
+ // Ubuntu => Unity/Ubuntu
+ // Cantarell => GNOME
+ // Fira Sans => Firefox OS
+ // Droid Sans => Older Androids (<4.0)
+ // Helvetica Neue => Older macOS <10.11
+ // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+ }
+
+ &.app-body {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ background: $ui-base-color;
+ }
+
+ &.about-body {
+ background: darken($ui-base-color, 8%);
+ padding-bottom: 0;
+ }
+
+ &.tag-body {
+ background: darken($ui-base-color, 8%);
+ padding-bottom: 0;
+ }
+
+ &.embed {
+ background: transparent;
+ margin: 0;
+ padding-bottom: 0;
+
+ .container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+ }
+
+ &.admin {
+ background: darken($ui-base-color, 4%);
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ }
+
+ &.error {
+ position: absolute;
+ text-align: center;
+ color: $ui-primary-color;
+ background: $ui-base-color;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ .dialog {
+ vertical-align: middle;
+ margin: 20px;
+
+ img {
+ display: block;
+ max-width: 470px;
+ width: 100%;
+ height: auto;
+ margin-top: -120px;
+ }
+
+ h1 {
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: 400;
+ }
+ }
+ }
+}
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.app-holder {
+ &,
+ & > div {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/boost.scss b/app/javascript/themes/glitch/styles/boost.scss
new file mode 100644
index 000000000..b07b72f8e
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/boost.scss
@@ -0,0 +1,28 @@
+@function hex-color($color) {
+ @if type-of($color) == 'color' {
+ $color: str-slice(ie-hex-str($color), 4);
+ }
+ @return '%23' + unquote($color)
+}
+
+button.icon-button i.fa-retweet {
+ background-image: url("data:image/svg+xml;utf8, ");
+
+ &:hover {
+ background-image: url("data:image/svg+xml;utf8, ");
+ }
+}
+
+// Disabled variant
+button.icon-button.disabled i.fa-retweet {
+ &, &:hover {
+ background-image: url("data:image/svg+xml;utf8, ");
+ }
+}
+
+// Disabled variant for use with DMs
+.status-direct button.icon-button.disabled i.fa-retweet {
+ &, &:hover {
+ background-image: url("data:image/svg+xml;utf8, ");
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/compact_header.scss b/app/javascript/themes/glitch/styles/compact_header.scss
new file mode 100644
index 000000000..90d98cc8c
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/compact_header.scss
@@ -0,0 +1,34 @@
+.compact-header {
+ h1 {
+ font-size: 24px;
+ line-height: 28px;
+ color: $ui-primary-color;
+ font-weight: 500;
+ margin-bottom: 20px;
+ padding: 0 10px;
+ word-wrap: break-word;
+
+ @media screen and (max-width: 740px) {
+ text-align: center;
+ padding: 20px 10px 0;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ small {
+ font-weight: 400;
+ color: $ui-secondary-color;
+ }
+
+ img {
+ display: inline-block;
+ margin-bottom: -5px;
+ margin-right: 15px;
+ width: 36px;
+ height: 36px;
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/components.scss b/app/javascript/themes/glitch/styles/components.scss
new file mode 100644
index 000000000..fbee5610a
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/components.scss
@@ -0,0 +1,4827 @@
+@import 'variables';
+
+@mixin fullwidth-gallery {
+ &.full-width {
+ margin-left: -22px;
+ margin-right: -22px;
+ width: inherit;
+ }
+}
+
+.app-body {
+ -webkit-overflow-scrolling: touch;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+}
+
+.button {
+ background-color: darken($ui-highlight-color, 3%);
+ border: 10px none;
+ border-radius: 4px;
+ box-sizing: border-box;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: inline-block;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ height: 36px;
+ letter-spacing: 0;
+ line-height: 36px;
+ overflow: hidden;
+ padding: 0 16px;
+ position: relative;
+ text-align: center;
+ text-transform: uppercase;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ transition: all 100ms ease-in;
+ white-space: nowrap;
+ width: auto;
+
+ &:active,
+ &:focus,
+ &:hover {
+ background-color: lighten($ui-highlight-color, 7%);
+ transition: all 200ms ease-out;
+ }
+
+ &:disabled {
+ background-color: $ui-primary-color;
+ cursor: default;
+ }
+
+ &.button-alternative {
+ font-size: 16px;
+ line-height: 36px;
+ height: auto;
+ color: $ui-base-color;
+ background: $ui-primary-color;
+ text-transform: none;
+ padding: 4px 16px;
+
+ &:active,
+ &:focus,
+ &:hover {
+ background-color: lighten($ui-primary-color, 4%);
+ }
+ }
+
+ &.button-secondary {
+ font-size: 16px;
+ line-height: 36px;
+ height: auto;
+ color: $ui-primary-color;
+ text-transform: none;
+ background: transparent;
+ padding: 3px 15px;
+ border-radius: 4px;
+ border: 1px solid $ui-primary-color;
+
+ &:active,
+ &:focus,
+ &:hover {
+ border-color: lighten($ui-primary-color, 4%);
+ color: lighten($ui-primary-color, 4%);
+ }
+ }
+
+ &.button--block {
+ display: block;
+ width: 100%;
+ }
+}
+
+.column__wrapper {
+ display: flex;
+ flex: 1 1 auto;
+ position: relative;
+}
+
+.column-icon {
+ background: lighten($ui-base-color, 4%);
+ color: $ui-primary-color;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 15px;
+ position: absolute;
+ right: 0;
+ top: -48px;
+ z-index: 3;
+
+ &:hover {
+ color: lighten($ui-primary-color, 7%);
+ }
+}
+
+.icon-button {
+ display: inline-block;
+ padding: 0;
+ color: $ui-base-lighter-color;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: color 100ms ease-in;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-base-color, 33%);
+ transition: color 200ms ease-out;
+ }
+
+ &.disabled {
+ color: lighten($ui-base-color, 13%);
+ cursor: default;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+ }
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &.inverted {
+ color: lighten($ui-base-color, 33%);
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $ui-base-lighter-color;
+ }
+
+ &.disabled {
+ color: $ui-primary-color;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+
+ &.disabled {
+ color: lighten($ui-highlight-color, 13%);
+ }
+ }
+ }
+
+ &.overlayed {
+ box-sizing: content-box;
+ background: rgba($base-overlay-background, 0.6);
+ color: rgba($primary-text-color, 0.7);
+ border-radius: 4px;
+ padding: 2px;
+
+ &:hover {
+ background: rgba($base-overlay-background, 0.9);
+ }
+ }
+}
+
+.text-icon-button {
+ color: lighten($ui-base-color, 33%);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 11px;
+ padding: 0 3px;
+ line-height: 27px;
+ outline: 0;
+ transition: color 100ms ease-in;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $ui-base-lighter-color;
+ transition: color 200ms ease-out;
+ }
+
+ &.disabled {
+ color: lighten($ui-base-color, 13%);
+ cursor: default;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+ }
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+}
+
+.dropdown-menu {
+ position: absolute;
+}
+
+.dropdown--active .icon-button {
+ color: $ui-highlight-color;
+}
+
+.dropdown--active::after {
+ @media screen and (min-width: 631px) {
+ content: "";
+ display: block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 4.5px 7.8px;
+ border-color: transparent transparent $ui-secondary-color;
+ bottom: 8px;
+ right: 104px;
+ }
+}
+
+.invisible {
+ font-size: 0;
+ line-height: 0;
+ display: inline-block;
+ width: 0;
+ height: 0;
+ position: absolute;
+
+ img,
+ svg {
+ margin: 0 !important;
+ border: 0 !important;
+ padding: 0 !important;
+ width: 0 !important;
+ height: 0 !important;
+ }
+}
+
+.ellipsis {
+ &::after {
+ content: "…";
+ }
+}
+
+.lightbox .icon-button {
+ color: $ui-base-color;
+}
+
+.compose-form {
+ padding: 10px;
+}
+
+.compose-form__warning {
+ color: darken($ui-secondary-color, 65%);
+ margin-bottom: 15px;
+ background: $ui-primary-color;
+ box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
+ padding: 8px 10px;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 400;
+
+ strong {
+ color: darken($ui-secondary-color, 65%);
+ font-weight: 500;
+ }
+
+ a {
+ color: darken($ui-primary-color, 33%);
+ font-weight: 500;
+ text-decoration: underline;
+
+ &:hover,
+ &:active,
+ &:focus {
+ text-decoration: none;
+ }
+ }
+}
+
+.compose-form__modifiers {
+ color: $ui-base-color;
+ font-family: inherit;
+ font-size: 14px;
+ background: $simple-background-color;
+}
+
+.compose-form__buttons-wrapper {
+ display: flex;
+ justify-content: space-between;
+}
+
+.compose-form__buttons {
+ padding: 10px;
+ background: darken($simple-background-color, 8%);
+ box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
+ border-radius: 0 0 4px 4px;
+ display: flex;
+
+ .icon-button {
+ box-sizing: content-box;
+ padding: 0 3px;
+ }
+}
+
+.compose-form__buttons-separator {
+ border-left: 1px solid #c3c3c3;
+ margin: 0 3px;
+}
+
+.compose-form__upload-button-icon {
+ line-height: 27px;
+}
+
+.compose-form__sensitive-button {
+ display: none;
+
+ &.compose-form__sensitive-button--visible {
+ display: block;
+ }
+
+ .compose-form__sensitive-button__icon {
+ line-height: 27px;
+ }
+}
+
+.compose-form__upload-wrapper {
+ overflow: hidden;
+}
+
+.compose-form__uploads-wrapper {
+ display: flex;
+ flex-direction: row;
+ padding: 5px;
+ flex-wrap: wrap;
+}
+
+.compose-form__upload {
+ flex: 1 1 0;
+ min-width: 40%;
+ margin: 5px;
+
+ &-description {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+ padding: 10px;
+ opacity: 0;
+ transition: opacity .1s ease;
+
+ input {
+ background: transparent;
+ color: $ui-secondary-color;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+
+ &:focus {
+ color: $white;
+ }
+
+ &::placeholder {
+ opacity: 0.54;
+ color: $ui-secondary-color;
+ }
+ }
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ .icon-button {
+ mix-blend-mode: difference;
+ }
+}
+
+.compose-form__upload-thumbnail {
+ border-radius: 4px;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ height: 100px;
+ width: 100%;
+}
+
+.compose-form__label {
+ display: block;
+ line-height: 24px;
+ vertical-align: middle;
+
+ &.with-border {
+ border-top: 1px solid $ui-base-color;
+ padding-top: 10px;
+ }
+
+ .compose-form__label__text {
+ display: inline-block;
+ vertical-align: middle;
+ margin-bottom: 14px;
+ margin-left: 8px;
+ color: $ui-primary-color;
+ }
+}
+
+.compose-form__textarea,
+.follow-form__input {
+ background: $simple-background-color;
+
+ &:disabled {
+ background: $ui-secondary-color;
+ }
+}
+
+.compose-form__autosuggest-wrapper {
+ position: relative;
+
+ .emoji-picker-dropdown {
+ position: absolute;
+ right: 5px;
+ top: 5px;
+
+ ::-webkit-scrollbar-track:hover,
+ ::-webkit-scrollbar-track:active {
+ background-color: rgba($base-overlay-background, 0.3);
+ }
+ }
+}
+
+.compose-form__publish {
+ display: flex;
+ justify-content: flex-end;
+ min-width: 0;
+}
+
+.compose-form__publish-button-wrapper {
+ overflow: hidden;
+ padding-top: 10px;
+ white-space: nowrap;
+ display: flex;
+
+ button {
+ text-overflow: unset;
+ }
+}
+
+.compose-form__publish__side-arm {
+ padding: 0 !important;
+ width: 36px;
+ text-align: center;
+ margin-right: 2px;
+}
+
+.compose-form__publish__primary {
+ padding: 0 10px !important;
+}
+
+.emojione {
+ display: inline-block;
+ font-size: inherit;
+ vertical-align: middle;
+ object-fit: contain;
+ margin: -.2ex .15em .2ex;
+ width: 16px;
+ height: 16px;
+
+ img {
+ width: auto;
+ }
+}
+
+.reply-indicator {
+ border-radius: 4px 4px 0 0;
+ position: relative;
+ bottom: -2px;
+ background: $ui-primary-color;
+ padding: 10px;
+}
+
+.reply-indicator__header {
+ margin-bottom: 5px;
+ overflow: hidden;
+}
+
+.reply-indicator__cancel {
+ float: right;
+ line-height: 24px;
+}
+
+.reply-indicator__display-name {
+ color: $ui-base-color;
+ display: block;
+ max-width: 100%;
+ line-height: 24px;
+ overflow: hidden;
+ padding-right: 25px;
+ text-decoration: none;
+}
+
+.reply-indicator__display-avatar {
+ float: left;
+ margin-right: 5px;
+}
+
+.status__content--with-action {
+ cursor: pointer;
+}
+
+.status-check-box {
+ .status__content,
+ .reply-indicator__content {
+ color: #3a3a3a;
+ a {
+ color: #005aa9;
+ }
+ }
+}
+
+.status__content,
+.reply-indicator__content {
+ position: relative;
+ margin: 10px 0;
+ padding: 0 12px;
+ font-size: 15px;
+ line-height: 20px;
+ color: $primary-text-color;
+ word-wrap: break-word;
+ font-weight: 400;
+ overflow: visible;
+ white-space: pre-wrap;
+ padding-top: 5px;
+
+ &.status__content--with-spoiler {
+ white-space: normal;
+
+ .status__content__text {
+ white-space: pre-wrap;
+ }
+ }
+
+ .emojione {
+ width: 20px;
+ height: 20px;
+ margin: -5px 0 0;
+ }
+
+ p {
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $ui-secondary-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+
+ .fa {
+ color: lighten($ui-base-color, 40%);
+ }
+ }
+
+ &.mention {
+ &:hover {
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .fa {
+ color: lighten($ui-base-color, 30%);
+ }
+ }
+
+ .status__content__spoiler {
+ display: none;
+
+ &.status__content__spoiler--visible {
+ display: block;
+ }
+ }
+}
+
+.status__content__spoiler-link {
+ display: inline-block;
+ border-radius: 2px;
+ background: lighten($ui-base-color, 30%);
+ border: none;
+ color: lighten($ui-base-color, 8%);
+ font-weight: 500;
+ font-size: 11px;
+ padding: 0 5px;
+ text-transform: uppercase;
+ line-height: inherit;
+ cursor: pointer;
+ vertical-align: bottom;
+
+ &:hover {
+ background: lighten($ui-base-color, 33%);
+ text-decoration: none;
+ }
+
+ .status__content__spoiler-icon {
+ display: inline-block;
+ margin: 0 0 0 5px;
+ border-left: 1px solid currentColor;
+ padding: 0 0 0 4px;
+ font-size: 16px;
+ vertical-align: -2px;
+ }
+}
+
+.status__prepend-icon-wrapper {
+ float: left;
+ margin: 0 10px 0 -58px;
+ width: 48px;
+ text-align: right;
+}
+
+.notif-cleaning {
+ .status, .notification-follow {
+ padding-right: ($dismiss-overlay-width + 0.5rem);
+ }
+}
+
+.notification-follow {
+ position: relative;
+
+ // same like Status
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ .account {
+ border-bottom: 0 none;
+ }
+}
+
+.focusable {
+ &:focus {
+ outline: 0;
+ background: lighten($ui-base-color, 4%);
+
+ .status.status-direct {
+ background: lighten($ui-base-color, 12%);
+ }
+
+ .detailed-status,
+ .detailed-status__action-bar {
+ background: lighten($ui-base-color, 8%);
+ }
+ }
+}
+
+.status {
+ padding: 8px 10px;
+ position: relative;
+ height: auto;
+ min-height: 48px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ cursor: default;
+
+ @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
+ // Add margin to avoid Edge auto-hiding scrollbar appearing over content.
+ // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
+ padding-right: 26px; // 10px + 16px
+ }
+
+ @keyframes fade {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+
+ opacity: 1;
+ animation: fade 150ms linear;
+
+ .video-player {
+ margin-top: 8px;
+ }
+
+ &.status-direct {
+ background: lighten($ui-base-color, 8%);
+
+ .icon-button.disabled {
+ color: lighten($ui-base-color, 16%);
+ }
+ }
+
+ &.light {
+ .status__relative-time {
+ color: $ui-primary-color;
+ }
+
+ .status__display-name {
+ color: $ui-base-color;
+ }
+
+ .display-name {
+ strong {
+ color: $ui-base-color;
+ }
+
+ span {
+ color: $ui-primary-color;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+ }
+
+ &.collapsed {
+ background-position: center;
+ background-size: cover;
+ user-select: none;
+
+ &.has-background::before {
+ display: block;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));
+ content: "";
+ }
+
+ .display-name:hover .display-name__html {
+ text-decoration: none;
+ }
+
+ .status__content {
+ height: 20px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ a:hover {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .notification__message {
+ margin: -10px -10px 10px;
+ }
+}
+
+.notification-favourite {
+ .status.status-direct {
+ background: transparent;
+
+ .icon-button.disabled {
+ color: lighten($ui-base-color, 13%);
+ }
+ }
+}
+
+.status__relative-time {
+ display: inline-block;
+ margin-left: auto;
+ padding-left: 18px;
+ width: 120px;
+ color: $ui-base-lighter-color;
+ font-size: 14px;
+ text-align: right;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.status__display-name {
+ margin: 0 auto 0 0;
+ color: $ui-base-lighter-color;
+ overflow: hidden;
+}
+
+.status__info {
+ display: flex;
+ margin: 2px 0 5px;
+ font-size: 15px;
+ line-height: 24px;
+}
+
+.status__info__icons {
+ flex: none;
+ position: relative;
+ color: lighten($ui-base-color, 26%);
+
+ .status__visibility-icon {
+ padding-left: 6px;
+ }
+}
+
+.status-check-box {
+ border-bottom: 1px solid $ui-secondary-color;
+ display: flex;
+
+ .status__content {
+ flex: 1 1 auto;
+ padding: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.status-check-box-toggle {
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ justify-content: center;
+ padding: 10px;
+}
+
+.status__prepend {
+ margin: -10px -10px 10px;
+ color: $ui-base-lighter-color;
+ padding: 8px 10px 0 68px;
+ font-size: 14px;
+ position: relative;
+
+ .status__display-name strong {
+ color: $ui-base-lighter-color;
+ }
+
+ > span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.status__action-bar {
+ align-items: center;
+ display: flex;
+ margin: 10px 4px 0;
+}
+
+.status__action-bar-button {
+ float: left;
+ margin-right: 18px;
+ flex: 0 0 auto;
+}
+
+.status__action-bar-dropdown {
+ float: left;
+ height: 23.15px;
+ width: 23.15px;
+
+ // Dropdown style override for centering on the icon
+ .dropdown--active {
+ position: relative;
+
+ .dropdown__content.dropdown__right {
+ left: calc(50% + 3px);
+ right: initial;
+ transform: translate(-50%, 0);
+ top: 22px;
+ }
+
+ &::after {
+ right: 1px;
+ bottom: -2px;
+ }
+ }
+}
+
+.detailed-status__action-bar-dropdown {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.detailed-status {
+ background: lighten($ui-base-color, 4%);
+ padding: 14px 10px;
+
+ .status__content {
+ font-size: 19px;
+ line-height: 24px;
+
+ .emojione {
+ width: 24px;
+ height: 24px;
+ margin: -5px 0 0;
+ }
+ }
+
+ .video-player {
+ margin-top: 8px;
+ }
+}
+
+.detailed-status__meta {
+ margin-top: 15px;
+ color: $ui-base-lighter-color;
+ font-size: 14px;
+ line-height: 18px;
+}
+
+.detailed-status__action-bar {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: row;
+ padding: 10px 0;
+}
+
+.detailed-status__link {
+ color: inherit;
+ text-decoration: none;
+}
+
+.detailed-status__favorites,
+.detailed-status__reblogs {
+ display: inline-block;
+ font-weight: 500;
+ font-size: 12px;
+ margin-left: 6px;
+}
+
+.reply-indicator__content {
+ color: $ui-base-color;
+ font-size: 14px;
+
+ a {
+ color: lighten($ui-base-color, 20%);
+ }
+}
+
+.account {
+ padding: 10px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ .account__display-name {
+ flex: 1 1 auto;
+ display: block;
+ color: $ui-primary-color;
+ overflow: hidden;
+ text-decoration: none;
+ font-size: 14px;
+ }
+}
+
+.account__wrapper {
+ display: flex;
+}
+
+.account__avatar-wrapper {
+ float: left;
+ margin: 6px 16px 6px 6px;
+}
+
+.account__avatar {
+ @include avatar-radius();
+ position: relative;
+ cursor: pointer;
+
+ &-inline {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 5px;
+ }
+}
+
+.account__avatar-overlay {
+ position: relative;
+ @include avatar-size(48px);
+
+ &-base {
+ @include avatar-radius();
+ @include avatar-size(36px);
+ }
+
+ &-overlay {
+ @include avatar-radius();
+ @include avatar-size(24px);
+
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 1;
+ }
+}
+
+.account__relationship {
+ height: 18px;
+ padding: 12px 10px;
+ white-space: nowrap;
+}
+
+.account__header__wrapper {
+ flex: 0 0 auto;
+ background: lighten($ui-base-color, 4%);
+}
+
+.account__header {
+ text-align: center;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+
+ & > div {
+ background: rgba(lighten($ui-base-color, 4%), 0.9);
+ padding: 20px 10px;
+ }
+
+ .account__header__content {
+ color: $ui-secondary-color;
+ }
+
+ .account__header__display-name {
+ color: $primary-text-color;
+ display: inline-block;
+ width: 100%;
+ font-size: 20px;
+ line-height: 27px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .account__header__username {
+ color: $ui-highlight-color;
+ font-size: 14px;
+ font-weight: 400;
+ display: block;
+ margin-bottom: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.account__disclaimer {
+ padding: 10px;
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ color: $ui-base-lighter-color;
+
+ strong {
+ font-weight: 500;
+ }
+
+ a {
+ font-weight: 500;
+ color: inherit;
+ text-decoration: underline;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: none;
+ }
+ }
+}
+
+.account__header__content {
+ color: $ui-primary-color;
+ font-size: 14px;
+ font-weight: 400;
+ overflow: hidden;
+ word-break: normal;
+ word-wrap: break-word;
+
+ p {
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+}
+
+.account__header__display-name {
+ .emojione {
+ width: 25px;
+ height: 25px;
+ }
+}
+
+.account__metadata {
+ width: 100%;
+ font-size: 15px;
+ line-height: 20px;
+ overflow: hidden;
+ border-collapse: collapse;
+
+ a {
+ text-decoration: none;
+
+ &:hover{
+ text-decoration: underline;
+ }
+ }
+
+ tr {
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ }
+
+ th, td {
+ padding: 14px 20px;
+ vertical-align: middle;
+
+ & > div {
+ max-height: 40px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ th {
+ color: $ui-primary-color;
+ background: lighten($ui-base-color, 13%);
+ font-variant: small-caps;
+ max-width: 120px;
+
+ a {
+ color: $primary-text-color;
+ }
+ }
+
+ td {
+ flex: auto;
+ color: $primary-text-color;
+ background: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+ }
+}
+
+.account__action-bar {
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ line-height: 36px;
+ overflow: hidden;
+ flex: 0 0 auto;
+ display: flex;
+}
+
+.account__action-bar-dropdown {
+ flex: 0 1 calc(50% - 140px);
+ padding: 10px;
+
+ .dropdown--active {
+ .dropdown__content.dropdown__right {
+ left: 6px;
+ right: initial;
+ }
+
+ &::after {
+ bottom: initial;
+ margin-left: 11px;
+ margin-top: -7px;
+ right: initial;
+ }
+ }
+}
+
+.account__action-bar-links {
+ display: flex;
+ flex: 1 1 auto;
+ line-height: 18px;
+}
+
+.account__action-bar__tab {
+ text-decoration: none;
+ overflow: hidden;
+ flex: 0 1 80px;
+ border-left: 1px solid lighten($ui-base-color, 8%);
+ padding: 10px 5px;
+
+ & > span {
+ display: block;
+ text-transform: uppercase;
+ font-size: 11px;
+ color: $ui-primary-color;
+ }
+
+ strong {
+ display: block;
+ font-size: 15px;
+ font-weight: 500;
+ color: $primary-text-color;
+ }
+
+ abbr {
+ color: $ui-base-lighter-color;
+ }
+}
+
+.account__header__avatar {
+ @include avatar-radius();
+ @include avatar-size(90px);
+ display: block;
+ margin: 0 auto 10px;
+ overflow: hidden;
+}
+
+.account-authorize {
+ padding: 14px 10px;
+
+ .detailed-status__display-name {
+ display: block;
+ margin-bottom: 15px;
+ overflow: hidden;
+ }
+}
+
+.account-authorize__avatar {
+ float: left;
+ margin-right: 10px;
+}
+
+.status__display-name,
+.status__relative-time,
+.detailed-status__display-name,
+.detailed-status__datetime,
+.detailed-status__application,
+.account__display-name {
+ text-decoration: none;
+}
+
+.status__display-name,
+.account__display-name {
+ strong {
+ color: $primary-text-color;
+ }
+}
+
+.muted {
+ .emojione {
+ opacity: 0.5;
+ }
+}
+
+.account__display-name strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.detailed-status__application,
+.detailed-status__datetime {
+ color: inherit;
+}
+
+.detailed-status__display-name {
+ color: $ui-secondary-color;
+ display: block;
+ line-height: 24px;
+ margin-bottom: 15px;
+ overflow: hidden;
+
+ strong,
+ span {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ strong {
+ font-size: 16px;
+ color: $primary-text-color;
+ }
+}
+
+.detailed-status__display-avatar {
+ float: left;
+ margin-right: 10px;
+}
+
+.status__avatar {
+ flex: none;
+ margin: 0 10px 0 0;
+ height: 48px;
+ width: 48px;
+}
+
+.muted {
+ .status__content p,
+ .status__content a {
+ color: $ui-base-lighter-color;
+ }
+
+ .status__display-name strong {
+ color: $ui-base-lighter-color;
+ }
+
+ .status__avatar, .emojione {
+ opacity: 0.5;
+ }
+
+ a.status__content__spoiler-link {
+ background: $ui-base-lighter-color;
+ color: lighten($ui-base-color, 4%);
+
+ &:hover {
+ background: lighten($ui-base-color, 29%);
+ text-decoration: none;
+ }
+ }
+}
+
+.notification__message {
+ padding: 8px 10px 0 68px;
+ cursor: default;
+ color: $ui-primary-color;
+ font-size: 15px;
+ position: relative;
+
+ .fa {
+ color: $ui-highlight-color;
+ }
+
+ > span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.notification__favourite-icon-wrapper {
+ float: left;
+ margin: 0 10px 0 -58px;
+ width: 48px;
+ text-align: right;
+
+ .star-icon {
+ color: $gold-star;
+ }
+}
+
+.star-icon.active {
+ color: $gold-star;
+}
+
+.notification__display-name {
+ color: inherit;
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover {
+ color: $primary-text-color;
+ text-decoration: underline;
+ }
+}
+
+.display-name {
+ display: block;
+ padding: 6px 0;
+ max-width: 100%;
+ height: 36px;
+ overflow: hidden;
+
+ strong {
+ display: block;
+ height: 18px;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 18px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+
+ span {
+ display: block;
+ height: 18px;
+ font-size: 15px;
+ line-height: 18px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+
+ &:hover {
+ strong {
+ text-decoration: underline;
+ }
+ }
+}
+
+.status__relative-time,
+.detailed-status__datetime {
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.image-loader {
+ position: relative;
+
+ &.image-loader--loading {
+ .image-loader__preview-canvas {
+ filter: blur(2px);
+ }
+ }
+
+ .image-loader__img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ max-width: 100%;
+ max-height: 100%;
+ background-image: none;
+ }
+
+ &.image-loader--amorphous {
+ position: static;
+
+ .image-loader__preview-canvas {
+ display: none;
+ }
+
+ .image-loader__img {
+ position: static;
+ width: auto;
+ height: auto;
+ }
+ }
+}
+
+.navigation-bar {
+ padding: 10px;
+ display: flex;
+ flex-shrink: 0;
+ cursor: default;
+ color: $ui-primary-color;
+
+ strong {
+ color: $primary-text-color;
+ }
+
+ .permalink {
+ text-decoration: none;
+ }
+
+ .icon-button {
+ pointer-events: none;
+ opacity: 0;
+ }
+}
+
+.navigation-bar__profile {
+ flex: 1 1 auto;
+ margin-left: 8px;
+ overflow: hidden;
+}
+
+.navigation-bar__profile-account {
+ display: block;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.navigation-bar__profile-edit {
+ color: inherit;
+ text-decoration: none;
+}
+
+.dropdown {
+ display: inline-block;
+}
+
+.dropdown__content {
+ display: none;
+ position: absolute;
+}
+
+.dropdown-menu__separator {
+ border-bottom: 1px solid darken($ui-secondary-color, 8%);
+ margin: 5px 7px 6px;
+ height: 0;
+}
+
+.dropdown-menu {
+ background: $ui-secondary-color;
+ padding: 4px 0;
+ border-radius: 4px;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+ ul {
+ list-style: none;
+ }
+}
+
+.dropdown-menu__arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border: 0 solid transparent;
+
+ &.left {
+ right: -5px;
+ margin-top: -5px;
+ border-width: 5px 0 5px 5px;
+ border-left-color: $ui-secondary-color;
+ }
+
+ &.top {
+ bottom: -5px;
+ margin-left: -13px;
+ border-width: 5px 7px 0;
+ border-top-color: $ui-secondary-color;
+ }
+
+ &.bottom {
+ top: -5px;
+ margin-left: -13px;
+ border-width: 0 7px 5px;
+ border-bottom-color: $ui-secondary-color;
+ }
+
+ &.right {
+ left: -5px;
+ margin-top: -5px;
+ border-width: 5px 5px 5px 0;
+ border-right-color: $ui-secondary-color;
+ }
+}
+
+.dropdown-menu__item {
+ a {
+ font-size: 13px;
+ line-height: 18px;
+ display: block;
+ padding: 4px 14px;
+ box-sizing: border-box;
+ text-decoration: none;
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus,
+ &:hover,
+ &:active {
+ background: $ui-highlight-color;
+ color: $ui-secondary-color;
+ outline: 0;
+ }
+ }
+}
+
+.dropdown--active .dropdown__content {
+ display: block;
+ line-height: 18px;
+ max-width: 311px;
+ right: 0;
+ text-align: left;
+ z-index: 9999;
+
+ & > ul {
+ list-style: none;
+ background: $ui-secondary-color;
+ padding: 4px 0;
+ border-radius: 4px;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+ min-width: 140px;
+ position: relative;
+ }
+
+ &.dropdown__right {
+ right: 0;
+ }
+
+ &.dropdown__left {
+ & > ul {
+ left: -98px;
+ }
+ }
+
+ & > ul > li > a {
+ font-size: 13px;
+ line-height: 18px;
+ display: block;
+ padding: 4px 14px;
+ box-sizing: border-box;
+ text-decoration: none;
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus {
+ outline: 0;
+ }
+
+ &:hover {
+ background: $ui-highlight-color;
+ color: $ui-secondary-color;
+ }
+ }
+}
+
+.dropdown__icon {
+ vertical-align: middle;
+}
+
+.static-content {
+ padding: 10px;
+ padding-top: 20px;
+ color: $ui-base-lighter-color;
+
+ h1 {
+ font-size: 16px;
+ font-weight: 500;
+ margin-bottom: 40px;
+ text-align: center;
+ }
+
+ p {
+ font-size: 13px;
+ margin-bottom: 20px;
+ }
+}
+
+.columns-area {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: row;
+ justify-content: flex-start;
+ overflow-x: auto;
+ position: relative;
+ padding: 10px;
+}
+
+@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
+ .columns-area {
+ padding: 0;
+ }
+
+ .react-swipeable-view-container .columns-area {
+ height: calc(100% - 20px) !important;
+ }
+}
+
+.react-swipeable-view-container {
+ &,
+ .columns-area,
+ .drawer,
+ .column {
+ height: 100%;
+ }
+}
+
+.react-swipeable-view-container > * {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+.column {
+ width: 330px;
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ .wide & {
+ flex: auto;
+ min-width: 330px;
+ max-width: 400px;
+ }
+
+ > .scrollable {
+ background: $ui-base-color;
+ }
+}
+
+.ui {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ background: darken($ui-base-color, 7%);
+}
+
+.drawer {
+ width: 300px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+
+ .wide & {
+ flex: 1 1 200px;
+ min-width: 300px;
+ max-width: 400px;
+ }
+}
+
+.drawer__tab {
+ display: block;
+ flex: 1 1 auto;
+ padding: 15px 5px 13px;
+ color: $ui-primary-color;
+ text-decoration: none;
+ text-align: center;
+ font-size: 16px;
+ border-bottom: 2px solid transparent;
+ outline: none;
+ cursor: pointer;
+}
+
+.column,
+.drawer {
+ flex: 1 1 100%;
+ overflow: hidden;
+}
+
+@include limited-single-column('screen and (max-width: 360px)', $parent: null) {
+ .tabs-bar {
+ margin: 0;
+ }
+
+ .search {
+ margin-bottom: 0;
+ }
+}
+
+:root { // Overrides .wide stylings for mobile view
+ @include single-column('screen and (max-width: 630px)', $parent: null) {
+ .column,
+ .drawer {
+ flex: auto;
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ padding: 0;
+ }
+
+ .columns-area {
+ flex-direction: column;
+ }
+
+ .search__input,
+ .autosuggest-textarea__textarea {
+ font-size: 16px;
+ }
+ }
+}
+
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
+ .columns-area {
+ padding: 0;
+ }
+
+ .column,
+ .drawer {
+ padding: 10px;
+ padding-left: 5px;
+ padding-right: 5px;
+
+ &:first-child {
+ padding-left: 10px;
+ }
+
+ &:last-child {
+ padding-right: 10px;
+ }
+ }
+
+ .columns-area > div {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+ }
+}
+
+.drawer__pager {
+ box-sizing: border-box;
+ padding: 0;
+ flex: 1 1 auto;
+ position: relative;
+}
+
+.drawer__inner {
+ background: lighten($ui-base-color, 13%);
+ box-sizing: border-box;
+ padding: 0;
+ position: absolute;
+ height: 100%;
+ width: 100%;
+
+ &.darker {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: $ui-base-color;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.pseudo-drawer {
+ background: lighten($ui-base-color, 13%);
+ font-size: 13px;
+ text-align: left;
+}
+
+.drawer__header {
+ flex: 0 0 auto;
+ font-size: 16px;
+ background: lighten($ui-base-color, 8%);
+ margin-bottom: 10px;
+ display: flex;
+ flex-direction: row;
+
+ a {
+ transition: background 100ms ease-in;
+
+ &:hover {
+ background: lighten($ui-base-color, 3%);
+ transition: background 200ms ease-out;
+ }
+ }
+}
+
+.tabs-bar {
+ display: flex;
+ background: lighten($ui-base-color, 8%);
+ flex: 0 0 auto;
+ overflow-y: auto;
+ margin: 10px;
+ margin-bottom: 0;
+}
+
+.tabs-bar__link {
+ display: block;
+ flex: 1 1 auto;
+ padding: 15px 10px;
+ color: $primary-text-color;
+ text-decoration: none;
+ text-align: center;
+ font-size: 14px;
+ font-weight: 500;
+ border-bottom: 2px solid lighten($ui-base-color, 8%);
+ transition: all 200ms linear;
+
+ .fa {
+ font-weight: 400;
+ font-size: 16px;
+ }
+
+ &.active {
+ border-bottom: 2px solid $ui-highlight-color;
+ color: $ui-highlight-color;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ @include multi-columns('screen and (min-width: 631px)') {
+ background: lighten($ui-base-color, 14%);
+ transition: all 100ms linear;
+ }
+ }
+
+ span {
+ margin-left: 5px;
+ display: none;
+ }
+}
+
+@include limited-single-column('screen and (max-width: 600px)', $parent: null) {
+ .tabs-bar__link {
+ span {
+ display: inline;
+ }
+ }
+}
+
+@include multi-columns('screen and (min-width: 631px)', $parent: null) {
+ .tabs-bar {
+ display: none;
+ }
+}
+
+.scrollable {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ flex: 1 1 auto;
+ -webkit-overflow-scrolling: touch;
+ will-change: transform; // improves perf in mobile Chrome
+
+ &.optionally-scrollable {
+ overflow-y: auto;
+ }
+
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: strict;
+ }
+}
+
+.scrollable.fullscreen {
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: none;
+ }
+}
+
+.column-back-button {
+ background: lighten($ui-base-color, 4%);
+ color: $ui-highlight-color;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ border: 0;
+ text-align: unset;
+ padding: 15px;
+ margin: 0;
+ z-index: 3;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.column-header__back-button {
+ background: lighten($ui-base-color, 4%);
+ border: 0;
+ font-family: inherit;
+ color: $ui-highlight-color;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ padding: 0 5px 0 0;
+ z-index: 3;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:last-child {
+ padding: 0 15px 0 0;
+ }
+}
+
+.column-back-button__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.column-back-button--slim {
+ position: relative;
+}
+
+.column-back-button--slim-button {
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ padding: 15px;
+ position: absolute;
+ right: 0;
+ top: -48px;
+}
+
+.react-toggle {
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+ user-select: none;
+ -webkit-tap-highlight-color: rgba($base-overlay-background, 0);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.react-toggle-screenreader-only {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
+
+.react-toggle--disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ transition: opacity 0.25s;
+}
+
+.react-toggle-track {
+ width: 50px;
+ height: 24px;
+ padding: 0;
+ border-radius: 30px;
+ background-color: $ui-base-color;
+ transition: all 0.2s ease;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background-color: darken($ui-base-color, 10%);
+}
+
+.react-toggle--checked .react-toggle-track {
+ background-color: $ui-highlight-color;
+}
+
+.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background-color: lighten($ui-highlight-color, 10%);
+}
+
+.react-toggle-track-check {
+ position: absolute;
+ width: 14px;
+ height: 10px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ line-height: 0;
+ left: 8px;
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-check {
+ opacity: 1;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle-track-x {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ line-height: 0;
+ right: 10px;
+ opacity: 1;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-x {
+ opacity: 0;
+}
+
+.react-toggle-thumb {
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 22px;
+ height: 22px;
+ border: 1px solid $ui-base-color;
+ border-radius: 50%;
+ background-color: darken($simple-background-color, 2%);
+ box-sizing: border-box;
+ transition: all 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-thumb {
+ left: 27px;
+ border-color: $ui-highlight-color;
+}
+
+.column-link {
+ background: lighten($ui-base-color, 8%);
+ color: $primary-text-color;
+ display: block;
+ font-size: 16px;
+ padding: 15px;
+ text-decoration: none;
+ cursor: pointer;
+ outline: none;
+
+ &:hover {
+ background: lighten($ui-base-color, 11%);
+ }
+}
+
+.column-link__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.column-subheading {
+ background: $ui-base-color;
+ color: $ui-base-lighter-color;
+ padding: 8px 20px;
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+ cursor: default;
+}
+
+.autosuggest-textarea,
+.spoiler-input {
+ position: relative;
+}
+
+.autosuggest-textarea__textarea,
+.spoiler-input__input {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ color: $ui-base-color;
+ background: $simple-background-color;
+ padding: 10px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ border: 0;
+ outline: 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ @include limited-single-column('screen and (max-width: 600px)') {
+ font-size: 16px;
+ }
+}
+
+.spoiler-input__input {
+ border-radius: 4px;
+}
+
+.autosuggest-textarea__textarea {
+ min-height: 100px;
+ border-radius: 4px 4px 0 0;
+ padding-bottom: 0;
+ padding-right: 10px + 22px;
+ resize: none;
+
+ @include limited-single-column('screen and (max-width: 600px)') {
+ height: 100px !important; // prevent auto-resize textarea
+ resize: vertical;
+ }
+}
+
+.autosuggest-textarea__suggestions {
+ box-sizing: border-box;
+ display: none;
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ z-index: 99;
+ box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+ background: $ui-secondary-color;
+ border-radius: 0 0 4px 4px;
+ color: $ui-base-color;
+ font-size: 14px;
+ padding: 6px;
+
+ &.autosuggest-textarea__suggestions--visible {
+ display: block;
+ }
+}
+
+.autosuggest-textarea__suggestions__item {
+ padding: 10px;
+ cursor: pointer;
+ border-radius: 4px;
+
+ &:hover,
+ &:focus,
+ &:active,
+ &.selected {
+ background: darken($ui-secondary-color, 10%);
+ }
+}
+
+.autosuggest-account,
+.autosuggest-emoji {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ line-height: 18px;
+ font-size: 14px;
+}
+
+.autosuggest-account-icon,
+.autosuggest-emoji img {
+ display: block;
+ margin-right: 8px;
+ width: 16px;
+ height: 16px;
+}
+
+.autosuggest-account .display-name__account {
+ color: lighten($ui-base-color, 36%);
+}
+
+.character-counter__wrapper {
+ line-height: 36px;
+ margin: 0 16px 0 8px;
+ padding-top: 10px;
+}
+
+.character-counter {
+ cursor: default;
+ font-size: 16px;
+}
+
+.character-counter--over {
+ color: $warning-red;
+}
+
+.getting-started__wrapper {
+ position: relative;
+ overflow-y: auto;
+}
+
+.getting-started__footer {
+ display: flex;
+ flex-direction: column;
+}
+
+.getting-started {
+ box-sizing: border-box;
+ padding-bottom: 235px;
+ background: url('../images/mastodon-getting-started.png') no-repeat 0 100%;
+ flex: 1 0 auto;
+
+ p {
+ color: $ui-secondary-color;
+ }
+
+ a {
+ color: $ui-base-lighter-color;
+ }
+}
+
+.setting-text {
+ color: $ui-primary-color;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid $ui-primary-color;
+ box-sizing: border-box;
+ display: block;
+ font-family: inherit;
+ margin-bottom: 10px;
+ padding: 7px 0;
+ width: 100%;
+
+ &:focus,
+ &:active {
+ color: $primary-text-color;
+ border-bottom-color: $ui-highlight-color;
+ }
+
+ @include limited-single-column('screen and (max-width: 600px)') {
+ font-size: 16px;
+ }
+
+ &.light {
+ color: $ui-base-color;
+ border-bottom: 2px solid lighten($ui-base-color, 27%);
+
+ &:focus,
+ &:active {
+ color: $ui-base-color;
+ border-bottom-color: $ui-highlight-color;
+ }
+ }
+}
+
+@import 'boost';
+
+button.icon-button i.fa-retweet {
+ background-position: 0 0;
+ height: 19px;
+ transition: background-position 0.9s steps(10);
+ transition-duration: 0s;
+ vertical-align: middle;
+ width: 22px;
+
+ &::before {
+ display: none !important;
+ }
+}
+
+button.icon-button.active i.fa-retweet {
+ transition-duration: 0.9s;
+ background-position: 0 100%;
+}
+
+.status-card {
+ display: flex;
+ cursor: pointer;
+ font-size: 14px;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-radius: 4px;
+ color: $ui-base-lighter-color;
+ margin-top: 14px;
+ text-decoration: none;
+ overflow: hidden;
+
+ &:hover {
+ background: lighten($ui-base-color, 8%);
+ }
+}
+
+.status-card-video,
+.status-card-rich,
+.status-card-photo {
+ margin-top: 14px;
+ overflow: hidden;
+
+ iframe {
+ width: 100%;
+ height: auto;
+ }
+}
+
+.status-card-photo {
+ display: block;
+ text-decoration: none;
+
+ img {
+ display: block;
+ width: 100%;
+ height: auto;
+ margin: 0;
+ }
+}
+
+.status-card-video {
+ iframe {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.status-card__title {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 5px;
+ color: $ui-primary-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status-card__content {
+ flex: 1 1 auto;
+ overflow: hidden;
+ padding: 14px 14px 14px 8px;
+}
+
+.status-card__description {
+ color: $ui-primary-color;
+}
+
+.status-card__host {
+ display: block;
+ margin-top: 5px;
+ font-size: 13px;
+}
+
+.status-card__image {
+ flex: 0 0 100px;
+ background: lighten($ui-base-color, 8%);
+}
+
+.status-card.horizontal {
+ display: block;
+
+ .status-card__image {
+ width: 100%;
+ }
+
+ .status-card__image-image {
+ border-radius: 4px 4px 0 0;
+ }
+}
+
+.status-card__image-image {
+ border-radius: 4px 0 0 4px;
+ display: block;
+ height: auto;
+ margin: 0;
+ width: 100%;
+}
+
+.load-more {
+ display: block;
+ color: $ui-base-lighter-color;
+ background-color: transparent;
+ border: 0;
+ font-size: inherit;
+ text-align: center;
+ line-height: inherit;
+ margin: 0;
+ padding: 15px;
+ width: 100%;
+ clear: both;
+
+ &:hover {
+ background: lighten($ui-base-color, 2%);
+ }
+}
+
+.missing-indicator {
+ text-align: center;
+ font-size: 16px;
+ font-weight: 500;
+ color: lighten($ui-base-color, 16%);
+ background: $ui-base-color;
+ cursor: default;
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
+
+ & > div {
+ background: url('../images/mastodon-not-found.png') no-repeat center -50px;
+ padding-top: 210px;
+ width: 100%;
+ }
+}
+
+.column-header__wrapper {
+ position: relative;
+ flex: 0 0 auto;
+
+ &.active {
+ &::before {
+ display: block;
+ content: "";
+ position: absolute;
+ top: 35px;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ width: 60%;
+ pointer-events: none;
+ height: 28px;
+ z-index: 1;
+ background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
+ }
+ }
+}
+
+.column-header {
+ display: flex;
+ padding: 15px;
+ font-size: 16px;
+ background: lighten($ui-base-color, 4%);
+ flex: 0 0 auto;
+ cursor: pointer;
+ position: relative;
+ z-index: 2;
+ outline: 0;
+
+ &.active {
+ box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
+
+ .column-header__icon {
+ color: $ui-highlight-color;
+ text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);
+ }
+ }
+
+ &:focus,
+ &:active {
+ outline: 0;
+ }
+}
+
+.column-header__buttons {
+ height: 48px;
+ display: flex;
+ margin: -15px;
+ margin-left: 0;
+}
+
+.column-header__button {
+ background: lighten($ui-base-color, 4%);
+ border: 0;
+ color: $ui-primary-color;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0 15px;
+
+ &:hover {
+ color: lighten($ui-primary-color, 7%);
+ }
+
+ &.active {
+ color: $primary-text-color;
+ background: lighten($ui-base-color, 8%);
+
+ &:hover {
+ color: $primary-text-color;
+ background: lighten($ui-base-color, 8%);
+ }
+ }
+
+ // glitch - added focus ring for keyboard navigation
+ &:focus {
+ text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
+ }
+}
+
+.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {
+ border-top: 1px solid $ui-base-color;
+}
+
+.notification__dismiss-overlay {
+ overflow: hidden;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: -1px;
+ padding-left: 15px; // space for the box shadow to be visible
+
+ z-index: 999;
+ align-items: center;
+ justify-content: flex-end;
+ cursor: pointer;
+
+ display: flex;
+
+ .wrappy {
+ width: $dismiss-overlay-width;
+ align-self: stretch;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: lighten($ui-base-color, 8%);
+ border-left: 1px solid lighten($ui-base-color, 20%);
+ box-shadow: 0 0 5px black;
+ border-bottom: 1px solid $ui-base-color;
+ }
+
+ .ckbox {
+ border: 2px solid $ui-primary-color;
+ border-radius: 2px;
+ width: 30px;
+ height: 30px;
+ font-size: 20px;
+ color: $ui-primary-color;
+ text-shadow: 0 0 5px black;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ &:focus {
+ outline: 0 !important;
+
+ .ckbox {
+ box-shadow: 0 0 1px 1px $ui-highlight-color;
+ }
+ }
+}
+
+.column-header__notif-cleaning-buttons {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-around;
+
+ button {
+ @extend .column-header__button;
+ background: transparent;
+ text-align: center;
+ padding: 10px 0;
+ white-space: pre-wrap;
+ }
+
+ b {
+ font-weight: bold;
+ }
+}
+
+// The notifs drawer with no padding to have more space for the buttons
+.column-header__collapsible-inner.nopad-drawer {
+ padding: 0;
+}
+
+.column-header__collapsible {
+ max-height: 70vh;
+ overflow: hidden;
+ overflow-y: auto;
+ color: $ui-primary-color;
+ transition: max-height 150ms ease-in-out, opacity 300ms linear;
+ opacity: 1;
+
+ &.collapsed {
+ max-height: 0;
+ opacity: 0.5;
+ }
+
+ &.animating {
+ overflow-y: hidden;
+ }
+
+ // notif cleaning drawer
+ &.ncd {
+ transition: none;
+ &.collapsed {
+ max-height: 0;
+ opacity: 0.7;
+ }
+ }
+}
+
+.column-header__collapsible-inner {
+ background: lighten($ui-base-color, 8%);
+ padding: 15px;
+}
+
+.column-header__setting-btn {
+ &:hover {
+ color: lighten($ui-primary-color, 4%);
+ text-decoration: underline;
+ }
+}
+
+.column-header__setting-arrows {
+ float: right;
+
+ .column-header__setting-btn {
+ padding: 0 10px;
+
+ &:last-child {
+ padding-right: 0;
+ }
+ }
+}
+
+.column-header__title {
+ display: inline-block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ flex: 1;
+}
+
+.text-btn {
+ display: inline-block;
+ padding: 0;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.column-header__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.loading-indicator {
+ color: lighten($ui-base-color, 26%);
+ font-size: 12px;
+ font-weight: 400;
+ text-transform: uppercase;
+ overflow: visible;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+
+ span {
+ display: block;
+ float: left;
+ margin-left: 50%;
+ transform: translateX(-50%);
+ margin: 82px 0 0 50%;
+ white-space: nowrap;
+ animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+ }
+}
+
+.loading-indicator__figure {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 0;
+ height: 0;
+ box-sizing: border-box;
+ border: 0 solid lighten($ui-base-color, 26%);
+ border-radius: 50%;
+ animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+}
+
+@keyframes loader-figure {
+ 0% {
+ width: 0;
+ height: 0;
+ background-color: lighten($ui-base-color, 26%);
+ }
+
+ 29% {
+ background-color: lighten($ui-base-color, 26%);
+ }
+
+ 30% {
+ width: 42px;
+ height: 42px;
+ background-color: transparent;
+ border-width: 21px;
+ opacity: 1;
+ }
+
+ 100% {
+ width: 42px;
+ height: 42px;
+ border-width: 0;
+ opacity: 0;
+ background-color: transparent;
+ }
+}
+
+@keyframes loader-label {
+ 0% { opacity: 0.25; }
+ 30% { opacity: 1; }
+ 100% { opacity: 0.25; }
+}
+
+.video-error-cover {
+ align-items: center;
+ background: $base-overlay-background;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ margin-top: 8px;
+ position: relative;
+ text-align: center;
+ z-index: 100;
+}
+
+.media-spoiler {
+ background: $base-overlay-background;
+ color: $ui-primary-color;
+ border: 0;
+ width: 100%;
+ height: 100%;
+ justify-content: center;
+ position: relative;
+ text-align: center;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+
+ .status__content > & {
+ margin-top: 15px; // Add margin when used bare for NSFW video player
+ }
+
+ @include fullwidth-gallery;
+}
+
+.media-spoiler__warning {
+ display: block;
+ font-size: 14px;
+}
+
+.media-spoiler__trigger {
+ display: block;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.spoiler-button {
+ display: none;
+ left: 4px;
+ position: absolute;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+ top: 4px;
+ z-index: 100;
+
+ &.spoiler-button--visible {
+ display: block;
+ }
+}
+
+.modal-container--preloader {
+ background: lighten($ui-base-color, 8%);
+}
+
+.account--panel {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: row;
+ padding: 10px 0;
+}
+
+.account--panel__button,
+.detailed-status__button {
+ flex: 1 1 auto;
+ text-align: center;
+}
+
+.column-settings__outer {
+ background: lighten($ui-base-color, 8%);
+ padding: 15px;
+}
+
+.column-settings__section {
+ color: $ui-primary-color;
+ cursor: default;
+ display: block;
+ font-weight: 500;
+ margin-bottom: 10px;
+}
+
+.column-settings__row {
+ .text-btn {
+ margin-bottom: 15px;
+ }
+}
+
+.modal-container__nav {
+ align-items: center;
+ background: rgba($base-overlay-background, 0.5);
+ box-sizing: border-box;
+ border: 0;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: flex;
+ font-size: 24px;
+ height: 100%;
+ padding: 30px 15px;
+ position: absolute;
+ top: 0;
+}
+
+.modal-container__nav--left {
+ left: -61px;
+}
+
+.modal-container__nav--right {
+ right: -61px;
+}
+
+.account--follows-info {
+ color: $primary-text-color;
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ opacity: 0.7;
+ display: inline-block;
+ vertical-align: top;
+ background-color: rgba($base-overlay-background, 0.4);
+ text-transform: uppercase;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 4px;
+ border-radius: 4px;
+}
+
+.account--action-button {
+ position: absolute;
+ top: 10px;
+ right: 20px;
+}
+
+.setting-toggle {
+ display: block;
+ line-height: 24px;
+}
+
+.setting-toggle__label,
+.setting-meta__label {
+ color: $ui-primary-color;
+ display: inline-block;
+ margin-bottom: 14px;
+ margin-left: 8px;
+ vertical-align: middle;
+}
+
+.setting-meta__label {
+ color: $ui-primary-color;
+ float: right;
+}
+
+.empty-column-indicator,
+.error-column {
+ color: lighten($ui-base-color, 20%);
+ background: $ui-base-color;
+ text-align: center;
+ padding: 20px;
+ font-size: 15px;
+ font-weight: 400;
+ cursor: default;
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: strict;
+ }
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.error-column {
+ flex-direction: column;
+}
+
+@keyframes heartbeat {
+ from {
+ transform: scale(1);
+ transform-origin: center center;
+ animation-timing-function: ease-out;
+ }
+
+ 10% {
+ transform: scale(0.91);
+ animation-timing-function: ease-in;
+ }
+
+ 17% {
+ transform: scale(0.98);
+ animation-timing-function: ease-out;
+ }
+
+ 33% {
+ transform: scale(0.87);
+ animation-timing-function: ease-in;
+ }
+
+ 45% {
+ transform: scale(1);
+ animation-timing-function: ease-out;
+ }
+}
+
+.pulse-loading {
+ animation: heartbeat 1.5s ease-in-out infinite both;
+}
+
+.emoji-picker-dropdown__menu {
+ background: $simple-background-color;
+ position: absolute;
+ box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ margin-top: 5px;
+
+ .emoji-mart-scroll {
+ transition: opacity 200ms ease;
+ }
+
+ &.selecting .emoji-mart-scroll {
+ opacity: 0.5;
+ }
+}
+
+.emoji-picker-dropdown__modifiers {
+ position: absolute;
+ top: 60px;
+ right: 11px;
+ cursor: pointer;
+}
+
+.emoji-picker-dropdown__modifiers__menu {
+ position: absolute;
+ z-index: 4;
+ top: -4px;
+ left: -8px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+ overflow: hidden;
+
+ button {
+ display: block;
+ cursor: pointer;
+ border: 0;
+ padding: 4px 8px;
+ background: transparent;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: rgba($ui-secondary-color, 0.4);
+ }
+ }
+
+ .emoji-mart-emoji {
+ height: 22px;
+ }
+}
+
+.emoji-mart-emoji {
+ span {
+ background-repeat: no-repeat;
+ }
+}
+
+.upload-area {
+ align-items: center;
+ background: rgba($base-overlay-background, 0.8);
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ left: 0;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ visibility: hidden;
+ width: 100%;
+ z-index: 2000;
+
+ * {
+ pointer-events: none;
+ }
+}
+
+.upload-area__drop {
+ width: 320px;
+ height: 160px;
+ display: flex;
+ box-sizing: border-box;
+ position: relative;
+ padding: 8px;
+}
+
+.upload-area__background {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: -1;
+ border-radius: 4px;
+ background: $ui-base-color;
+ box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+}
+
+.upload-area__content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $ui-secondary-color;
+ font-size: 18px;
+ font-weight: 500;
+ border: 2px dashed $ui-base-lighter-color;
+ border-radius: 4px;
+}
+
+.upload-progress {
+ padding: 10px;
+ color: $ui-base-lighter-color;
+ overflow: hidden;
+ display: flex;
+
+ .fa {
+ font-size: 34px;
+ margin-right: 10px;
+ }
+
+ span {
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: 500;
+ display: block;
+ }
+}
+
+.upload-progess__message {
+ flex: 1 1 auto;
+}
+
+.upload-progress__backdrop {
+ width: 100%;
+ height: 6px;
+ border-radius: 6px;
+ background: $ui-base-lighter-color;
+ position: relative;
+ margin-top: 5px;
+}
+
+.upload-progress__tracker {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 6px;
+ background: $ui-highlight-color;
+ border-radius: 6px;
+}
+
+.emoji-button {
+ display: block;
+ font-size: 24px;
+ line-height: 24px;
+ margin-left: 2px;
+ width: 24px;
+ outline: 0;
+ cursor: pointer;
+
+ &:active,
+ &:focus {
+ outline: 0 !important;
+ }
+
+ img {
+ filter: grayscale(100%);
+ opacity: 0.8;
+ display: block;
+ margin: 0;
+ width: 22px;
+ height: 22px;
+ margin-top: 2px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ img {
+ opacity: 1;
+ filter: none;
+ }
+ }
+}
+
+.dropdown--active .emoji-button img {
+ opacity: 1;
+ filter: none;
+}
+
+.privacy-dropdown__dropdown {
+ position: absolute;
+ background: $simple-background-color;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ margin-left: 40px;
+ overflow: hidden;
+}
+
+.privacy-dropdown__option {
+ color: $ui-base-color;
+ padding: 10px;
+ cursor: pointer;
+ display: flex;
+
+ &:hover,
+ &.active {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+
+ .privacy-dropdown__option__content {
+ color: $primary-text-color;
+
+ strong {
+ color: $primary-text-color;
+ }
+ }
+ }
+
+ &.active:hover {
+ background: lighten($ui-highlight-color, 4%);
+ }
+}
+
+.privacy-dropdown__option__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 10px;
+}
+
+.privacy-dropdown__option__content {
+ flex: 1 1 auto;
+ color: darken($ui-primary-color, 24%);
+
+ strong {
+ font-weight: 500;
+ display: block;
+ color: $ui-base-color;
+ }
+}
+
+.privacy-dropdown.active {
+ .privacy-dropdown__value {
+ background: $simple-background-color;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+
+ .icon-button {
+ transition: none;
+ }
+
+ &.active {
+ background: $ui-highlight-color;
+
+ .icon-button {
+ color: $primary-text-color;
+ }
+ }
+ }
+
+ .privacy-dropdown__dropdown {
+ display: block;
+ box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
+ }
+}
+
+.advanced-options-dropdown {
+ position: relative;
+}
+
+.advanced-options-dropdown__dropdown {
+ display: none;
+ position: absolute;
+ left: 0;
+ top: 27px;
+ width: 210px;
+ background: $simple-background-color;
+ border-radius: 0 4px 4px;
+ z-index: 2;
+ overflow: hidden;
+}
+
+.advanced-options-dropdown__option {
+ color: $ui-base-color;
+ padding: 10px;
+ cursor: pointer;
+ display: flex;
+
+ &:hover,
+ &.active {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+
+ .advanced-options-dropdown__option__content {
+ color: $primary-text-color;
+
+ strong {
+ color: $primary-text-color;
+ }
+ }
+ }
+
+ &.active:hover {
+ background: lighten($ui-highlight-color, 4%);
+ }
+}
+
+.advanced-options-dropdown__option__toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 10px;
+}
+
+.advanced-options-dropdown__option__content {
+ flex: 1 1 auto;
+ color: darken($ui-primary-color, 24%);
+
+ strong {
+ font-weight: 500;
+ display: block;
+ color: $ui-base-color;
+ }
+}
+
+.advanced-options-dropdown.open {
+ .advanced-options-dropdown__value {
+ background: $simple-background-color;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+ }
+
+ .advanced-options-dropdown__dropdown {
+ display: block;
+ box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
+ }
+}
+
+
+.search {
+ position: relative;
+ margin-bottom: 10px;
+}
+
+.search__input {
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ padding-right: 30px;
+ font-family: inherit;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+
+ @include limited-single-column('screen and (max-width: 600px)') {
+ font-size: 16px;
+ }
+}
+
+.search__icon {
+ .fa {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 2;
+ display: inline-block;
+ opacity: 0;
+ transition: all 100ms linear;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: $ui-secondary-color;
+ cursor: default;
+ pointer-events: none;
+
+ &.active {
+ pointer-events: auto;
+ opacity: 0.3;
+ }
+ }
+
+ .fa-search {
+ transform: rotate(90deg);
+
+ &.active {
+ pointer-events: none;
+ transform: rotate(0deg);
+ }
+ }
+
+ .fa-times-circle {
+ top: 11px;
+ transform: rotate(0deg);
+ cursor: pointer;
+
+ &.active {
+ transform: rotate(90deg);
+ }
+
+ &:hover {
+ color: $primary-text-color;
+ }
+ }
+}
+
+.search-results__header {
+ color: $ui-base-lighter-color;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid darken($ui-base-color, 4%);
+ padding: 15px 10px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.search-results__section {
+ background: $ui-base-color;
+}
+
+.search-results__hashtag {
+ display: block;
+ padding: 10px;
+ color: $ui-secondary-color;
+ text-decoration: none;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-secondary-color, 4%);
+ text-decoration: underline;
+ }
+}
+
+.modal-root {
+ transition: opacity 0.3s linear;
+ will-change: opacity;
+ z-index: 9999;
+}
+
+.modal-root__overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba($base-overlay-background, 0.7);
+}
+
+.modal-root__container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ align-content: space-around;
+ z-index: 9999;
+ pointer-events: none;
+ user-select: none;
+}
+
+.modal-root__modal {
+ pointer-events: auto;
+ display: flex;
+ z-index: 9999;
+}
+
+.media-modal {
+ max-width: 80vw;
+ max-height: 80vh;
+ position: relative;
+
+ .extended-video-player,
+ img,
+ canvas,
+ video {
+ max-width: 80vw;
+ max-height: 80vh;
+ width: auto;
+ height: auto;
+ margin: auto;
+ }
+
+ .extended-video-player,
+ video {
+ display: flex;
+ width: 80vw;
+ height: 80vh;
+ }
+
+ img,
+ canvas {
+ display: block;
+ background: url('../images/void.png') repeat;
+ object-fit: contain;
+ }
+
+ .react-swipeable-view-container {
+ max-width: 80vw;
+ }
+}
+
+.media-modal__content {
+ background: $base-overlay-background;
+}
+
+.media-modal__pagination {
+ width: 100%;
+ text-align: center;
+ position: absolute;
+ left: 0;
+ bottom: -40px;
+}
+
+.media-modal__page-dot {
+ display: inline-block;
+}
+
+.media-modal__button {
+ background-color: $white;
+ height: 12px;
+ width: 12px;
+ border-radius: 6px;
+ margin: 10px;
+ padding: 0;
+ border: 0;
+ font-size: 0;
+}
+
+.media-modal__button--active {
+ background-color: $ui-highlight-color;
+}
+
+.media-modal__close {
+ position: absolute;
+ right: 4px;
+ top: 4px;
+ z-index: 100;
+}
+
+.onboarding-modal,
+.error-modal,
+.embed-modal {
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.onboarding-modal__pager {
+ height: 80vh;
+ width: 80vw;
+ max-width: 520px;
+ max-height: 420px;
+
+ .react-swipeable-view-container > div {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ padding: 25px;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ user-select: text;
+ }
+}
+
+.error-modal__body {
+ height: 80vh;
+ width: 80vw;
+ max-width: 520px;
+ max-height: 420px;
+ position: relative;
+
+ & > div {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ padding: 25px;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ opacity: 0;
+ user-select: text;
+ }
+}
+
+.error-modal__body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+@media screen and (max-width: 550px) {
+ .onboarding-modal {
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ }
+
+ .onboarding-modal__pager {
+ width: 100%;
+ height: auto;
+ max-width: none;
+ max-height: none;
+ flex: 1 1 auto;
+ }
+}
+
+.onboarding-modal__paginator,
+.error-modal__footer {
+ flex: 0 0 auto;
+ background: darken($ui-secondary-color, 8%);
+ display: flex;
+ padding: 25px;
+
+ & > div {
+ min-width: 33px;
+ }
+
+ .onboarding-modal__nav,
+ .error-modal__nav {
+ color: darken($ui-secondary-color, 34%);
+ background-color: transparent;
+ border: 0;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0;
+ line-height: inherit;
+ height: auto;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: darken($ui-secondary-color, 38%);
+ }
+
+ &.onboarding-modal__done,
+ &.onboarding-modal__next {
+ color: $ui-highlight-color;
+ }
+ }
+}
+
+.error-modal__footer {
+ justify-content: center;
+}
+
+.onboarding-modal__dots {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.onboarding-modal__dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 14px;
+ background: darken($ui-secondary-color, 16%);
+ margin: 0 3px;
+ cursor: pointer;
+
+ &:hover {
+ background: darken($ui-secondary-color, 18%);
+ }
+
+ &.active {
+ cursor: default;
+ background: darken($ui-secondary-color, 24%);
+ }
+}
+
+.onboarding-modal__page__wrapper {
+ pointer-events: none;
+
+ &.onboarding-modal__page__wrapper--active {
+ pointer-events: auto;
+ }
+}
+
+.onboarding-modal__page {
+ cursor: default;
+ line-height: 21px;
+
+ h1 {
+ font-size: 18px;
+ font-weight: 500;
+ color: $ui-base-color;
+ margin-bottom: 20px;
+ }
+
+ a {
+ color: $ui-highlight-color;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: lighten($ui-highlight-color, 4%);
+ }
+ }
+
+ p {
+ font-size: 16px;
+ color: lighten($ui-base-color, 8%);
+ margin-top: 10px;
+ margin-bottom: 10px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ strong {
+ font-weight: 500;
+ background: $ui-base-color;
+ color: $ui-secondary-color;
+ border-radius: 4px;
+ font-size: 14px;
+ padding: 3px 6px;
+ }
+ }
+}
+
+.onboarding-modal__page-one {
+ display: flex;
+ align-items: center;
+}
+
+.onboarding-modal__page-one__elephant-friend {
+ background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
+ width: 155px;
+ height: 193px;
+ margin-right: 15px;
+}
+
+@media screen and (max-width: 400px) {
+ .onboarding-modal__page-one {
+ flex-direction: column;
+ align-items: normal;
+ }
+
+ .onboarding-modal__page-one__elephant-friend {
+ width: 100%;
+ height: 30vh;
+ max-height: 160px;
+ margin-bottom: 5vh;
+ }
+}
+
+.onboarding-modal__page-two,
+.onboarding-modal__page-three,
+.onboarding-modal__page-four,
+.onboarding-modal__page-five {
+ p {
+ text-align: left;
+ }
+
+ .figure {
+ background: darken($ui-base-color, 8%);
+ color: $ui-secondary-color;
+ margin-bottom: 20px;
+ border-radius: 4px;
+ padding: 10px;
+ text-align: center;
+ font-size: 14px;
+ box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);
+
+ .onboarding-modal__image {
+ border-radius: 4px;
+ margin-bottom: 10px;
+ }
+
+ &.non-interactive {
+ pointer-events: none;
+ text-align: left;
+ }
+ }
+}
+
+.onboarding-modal__page-four__columns {
+ .row {
+ display: flex;
+ margin-bottom: 20px;
+
+ & > div {
+ flex: 1 1 0;
+ margin: 0 10px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ p {
+ text-align: center;
+ }
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .column-header {
+ color: $primary-text-color;
+ }
+}
+
+@media screen and (max-width: 320px) and (max-height: 600px) {
+ .onboarding-modal__page p {
+ font-size: 14px;
+ line-height: 20px;
+ }
+
+ .onboarding-modal__page-two .figure,
+ .onboarding-modal__page-three .figure,
+ .onboarding-modal__page-four .figure,
+ .onboarding-modal__page-five .figure {
+ font-size: 12px;
+ margin-bottom: 10px;
+ }
+
+ .onboarding-modal__page-four__columns .row {
+ margin-bottom: 10px;
+ }
+
+ .onboarding-modal__page-four__columns .column-header {
+ padding: 5px;
+ font-size: 12px;
+ }
+}
+
+.onboarding-modal__image {
+ border-radius: 8px;
+ width: 70vw;
+ max-width: 450px;
+ max-height: auto;
+ display: block;
+ margin: auto;
+ margin-bottom: 20px;
+}
+
+.onboard-sliders {
+ display: inline-block;
+ max-width: 30px;
+ max-height: auto;
+ margin-left: 10px;
+}
+
+.boost-modal,
+.confirmation-modal,
+.report-modal,
+.actions-modal,
+.mute-modal {
+ background: lighten($ui-secondary-color, 8%);
+ color: $ui-base-color;
+ border-radius: 8px;
+ overflow: hidden;
+ max-width: 90vw;
+ width: 480px;
+ position: relative;
+ flex-direction: column;
+
+ .status__display-name {
+ display: flex;
+ }
+}
+
+.actions-modal {
+ .status {
+ background: $white;
+ border-bottom-color: $ui-secondary-color;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .dropdown-menu__separator {
+ border-bottom-color: $ui-secondary-color;
+ }
+}
+
+.boost-modal__container {
+ overflow-x: scroll;
+ padding: 10px;
+
+ .status {
+ user-select: text;
+ border-bottom: 0;
+ }
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.report-modal__action-bar {
+ display: flex;
+ justify-content: space-between;
+ background: $ui-secondary-color;
+ padding: 10px;
+ line-height: 36px;
+
+ & > div {
+ flex: 1 1 auto;
+ text-align: right;
+ color: lighten($ui-base-color, 33%);
+ padding-right: 10px;
+ }
+
+ .button {
+ flex: 0 0 auto;
+ }
+}
+
+.boost-modal__status-header {
+ font-size: 15px;
+}
+
+.boost-modal__status-time {
+ float: right;
+ font-size: 14px;
+}
+
+.confirmation-modal {
+ max-width: 85vw;
+
+ @media screen and (min-width: 480px) {
+ max-width: 380px;
+ }
+}
+
+.mute-modal {
+ line-height: 24px;
+}
+
+.mute-modal .react-toggle {
+ vertical-align: middle;
+}
+
+.report-modal__statuses,
+.report-modal__comment {
+ padding: 10px;
+}
+
+.report-modal__statuses {
+ min-height: 20vh;
+ max-height: 40vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.report-modal__comment {
+ .setting-text {
+ margin-top: 10px;
+ }
+}
+
+.actions-modal {
+ .status {
+ overflow-y: auto;
+ max-height: 300px;
+ }
+
+ max-height: 80vh;
+ max-width: 80vw;
+
+ .actions-modal__item-label {
+ font-weight: 500;
+ }
+
+ ul {
+ overflow-y: auto;
+ flex-shrink: 0;
+
+ li:empty {
+ margin: 0;
+ }
+
+ li:not(:empty) {
+ a {
+ color: $ui-base-color;
+ display: flex;
+ padding: 12px 16px;
+ font-size: 15px;
+ align-items: center;
+ text-decoration: none;
+
+ &,
+ button {
+ transition: none;
+ }
+
+ &.active,
+ &:hover,
+ &:active,
+ &:focus {
+ &,
+ button {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ }
+ }
+
+ button:first-child {
+ margin-right: 10px;
+ }
+ }
+ }
+ }
+}
+
+.confirmation-modal__action-bar,
+.mute-modal__action-bar {
+ .confirmation-modal__cancel-button,
+ .mute-modal__cancel-button {
+ background-color: transparent;
+ color: darken($ui-secondary-color, 34%);
+ font-size: 14px;
+ font-weight: 500;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: darken($ui-secondary-color, 38%);
+ }
+ }
+}
+
+.confirmation-modal__container,
+.mute-modal__container,
+.report-modal__target {
+ padding: 30px;
+ font-size: 16px;
+ text-align: center;
+
+ strong {
+ font-weight: 500;
+ }
+}
+
+.loading-bar {
+ background-color: $ui-highlight-color;
+ height: 3px;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.media-gallery__gifv__label {
+ display: block;
+ position: absolute;
+ color: $primary-text-color;
+ background: rgba($base-overlay-background, 0.5);
+ bottom: 6px;
+ left: 6px;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ z-index: 1;
+ pointer-events: none;
+ opacity: 0.9;
+ transition: opacity 0.1s ease;
+}
+
+.media-gallery__gifv {
+ &.autoplay {
+ .media-gallery__gifv__label {
+ display: none;
+ }
+ }
+
+ &:hover {
+ .media-gallery__gifv__label {
+ opacity: 1;
+ }
+ }
+}
+
+.attachment-list {
+ display: flex;
+ font-size: 14px;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-radius: 4px;
+ margin-top: 14px;
+ overflow: hidden;
+}
+
+.attachment-list__icon {
+ flex: 0 0 auto;
+ color: $ui-base-lighter-color;
+ padding: 8px 18px;
+ cursor: default;
+ border-right: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+
+ .fa {
+ display: block;
+ }
+}
+
+.attachment-list__list {
+ list-style: none;
+ padding: 4px 0;
+ padding-left: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ li {
+ display: block;
+ padding: 4px 0;
+ }
+
+ a {
+ text-decoration: none;
+ color: $ui-base-lighter-color;
+ font-weight: 500;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+/* Media Gallery */
+.media-gallery {
+ box-sizing: border-box;
+ margin-top: 15px;
+ overflow: hidden;
+ position: relative;
+ background: $base-shadow-color;
+ width: 100%;
+
+ .detailed-status & {
+ margin-left:-10px;
+ width: calc(100% + 22px);
+ }
+
+ @include fullwidth-gallery;
+}
+
+.media-gallery__item {
+ border: none;
+ box-sizing: border-box;
+ display: block;
+ float: left;
+ position: relative;
+
+ &.standalone {
+ .media-gallery__item-gifv-thumbnail {
+ transform: none;
+ }
+ }
+}
+
+.media-gallery__item-thumbnail {
+ cursor: zoom-in;
+ text-decoration: none;
+ width: 100%;
+ height: 100%;
+ line-height: 0;
+ display: flex;
+
+ img {
+ width: 100%;
+ object-fit: contain;
+
+ &:not(.letterbox) {
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+}
+
+.media-gallery__gifv {
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.media-gallery__item-gifv-thumbnail {
+ cursor: zoom-in;
+ height: 100%;
+ position: relative;
+ z-index: 1;
+ object-fit: contain;
+
+ &:not(.letterbox) {
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.media-gallery__item-thumbnail-label {
+ clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
+ clip: rect(1px, 1px, 1px, 1px);
+ overflow: hidden;
+ position: absolute;
+}
+/* End Media Gallery */
+
+/* Status Video Player */
+.status__video-player {
+ display: flex;
+ align-items: center;
+ background: $base-shadow-color;
+ box-sizing: border-box;
+ cursor: default; /* May not be needed */
+ margin-top: 15px;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ @include fullwidth-gallery;
+}
+
+.status__video-player-video {
+ position: relative;
+ width: 100%;
+ z-index: 1;
+
+ &:not(.letterbox) {
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.status__video-player-expand,
+.status__video-player-mute {
+ color: $primary-text-color;
+ opacity: 0.8;
+ position: absolute;
+ right: 4px;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+}
+
+.status__video-player-spoiler {
+ display: none;
+ color: $primary-text-color;
+ left: 4px;
+ position: absolute;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+ top: 4px;
+ z-index: 100;
+
+ &.status__video-player-spoiler--visible {
+ display: block;
+ }
+}
+
+.status__video-player-expand {
+ bottom: 4px;
+ z-index: 100;
+}
+
+.status__video-player-mute {
+ top: 4px;
+ z-index: 5;
+}
+
+.video-player {
+ overflow: hidden;
+ position: relative;
+ background: $base-shadow-color;
+ max-width: 100%;
+
+ video {
+ height: 100%;
+ width: 100%;
+ z-index: 1;
+ }
+
+ &.fullscreen {
+ width: 100% !important;
+ height: 100% !important;
+ margin: 0;
+
+ video {
+ max-width: 100% !important;
+ max-height: 100% !important;
+ }
+ }
+
+ &.inline {
+ video {
+ object-fit: cover;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+ }
+
+ &__controls {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 60%, transparent);
+ padding: 0 10px;
+ opacity: 0;
+ transition: opacity .1s ease;
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ &.inactive {
+ video,
+ .video-player__controls {
+ visibility: hidden;
+ }
+ }
+
+ &__spoiler {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 4;
+ border: 0;
+ background: $base-shadow-color;
+ color: $ui-primary-color;
+ transition: none;
+ pointer-events: none;
+
+ &.active {
+ display: block;
+ pointer-events: auto;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-primary-color, 8%);
+ }
+ }
+
+ &__title {
+ display: block;
+ font-size: 14px;
+ }
+
+ &__subtitle {
+ display: block;
+ font-size: 11px;
+ font-weight: 500;
+ }
+ }
+
+ &__buttons {
+ padding-bottom: 10px;
+ font-size: 16px;
+
+ &.left {
+ float: left;
+
+ button {
+ padding-right: 10px;
+ }
+ }
+
+ &.right {
+ float: right;
+
+ button {
+ padding-left: 10px;
+ }
+ }
+
+ button {
+ background: transparent;
+ padding: 0;
+ border: 0;
+ color: $white;
+
+ &:active,
+ &:hover,
+ &:focus {
+ color: $ui-highlight-color;
+ }
+ }
+ }
+
+ &__seek {
+ cursor: pointer;
+ height: 24px;
+ position: relative;
+
+ &::before {
+ content: "";
+ width: 100%;
+ background: rgba($white, 0.35);
+ display: block;
+ position: absolute;
+ height: 4px;
+ top: 10px;
+ }
+
+ &__progress,
+ &__buffer {
+ display: block;
+ position: absolute;
+ height: 4px;
+ top: 10px;
+ background: $ui-highlight-color;
+ }
+
+ &__buffer {
+ background: rgba($white, 0.2);
+ }
+
+ &__handle {
+ position: absolute;
+ z-index: 3;
+ opacity: 0;
+ border-radius: 50%;
+ width: 12px;
+ height: 12px;
+ top: 6px;
+ margin-left: -6px;
+ transition: opacity .1s ease;
+ background: $ui-highlight-color;
+ pointer-events: none;
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ &:hover {
+ .video-player__seek__handle {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.media-spoiler-video {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+ margin-top: 15px;
+ position: relative;
+ width: 100%;
+
+ @include fullwidth-gallery;
+
+ border: 0;
+ display: block;
+}
+
+.media-spoiler-video-play-icon {
+ border-radius: 100px;
+ color: rgba($primary-text-color, 0.8);
+ font-size: 36px;
+ left: 50%;
+ padding: 5px;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+}
+/* End Video Player */
+
+.account-gallery__container {
+ margin: -2px;
+ padding: 4px;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.account-gallery__item {
+ flex: 1 1 auto;
+ width: calc(100% / 3 - 4px);
+ height: 95px;
+ margin: 2px;
+
+ a {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: $base-overlay-background;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+ color: inherit;
+ text-decoration: none;
+
+ &:hover,
+ &:active,
+ &:focus {
+ outline: 0;
+ }
+ }
+}
+
+.account-section-headline {
+ color: $ui-base-lighter-color;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ padding: 15px 10px;
+ font-size: 14px;
+ font-weight: 500;
+ position: relative;
+ cursor: default;
+
+ &::before,
+ &::after {
+ display: block;
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 18px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 10px 10px;
+ border-color: transparent transparent lighten($ui-base-color, 4%);
+ }
+
+ &::after {
+ bottom: -1px;
+ border-color: transparent transparent $ui-base-color;
+ }
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 0;
+}
+
+.search-popout {
+ background: $simple-background-color;
+ border-radius: 4px;
+ padding: 10px 14px;
+ padding-bottom: 14px;
+ margin-top: 10px;
+ color: $ui-primary-color;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+ h4 {
+ text-transform: uppercase;
+ color: $ui-primary-color;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ li {
+ padding: 4px 0;
+ }
+
+ ul {
+ margin-bottom: 10px;
+ }
+
+ em {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+}
+
+noscript {
+ text-align: center;
+
+ img {
+ width: 200px;
+ opacity: 0.5;
+ animation: flicker 4s infinite;
+ }
+
+ div {
+ font-size: 14px;
+ margin: 30px auto;
+ color: $ui-secondary-color;
+ max-width: 400px;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
+
+@keyframes flicker {
+ 0% { opacity: 1; }
+ 30% { opacity: 0.75; }
+ 100% { opacity: 1; }
+}
+
+@media screen and (max-width: 630px) and (max-height: 400px) {
+ $duration: 400ms;
+ $delay: 100ms;
+
+ .tabs-bar,
+ .search {
+ will-change: margin-top;
+ transition: margin-top $duration $delay;
+ }
+
+ .navigation-bar {
+ will-change: padding-bottom;
+ transition: padding-bottom $duration $delay;
+ }
+
+ .navigation-bar {
+ & > a:first-child {
+ will-change: margin-top, margin-left, width;
+ transition: margin-top $duration $delay, margin-left $duration ($duration + $delay);
+ }
+
+ & > .navigation-bar__profile-edit {
+ will-change: margin-top;
+ transition: margin-top $duration $delay;
+ }
+
+ & > .icon-button {
+ will-change: opacity;
+ transition: opacity $duration $delay;
+ }
+ }
+
+ .is-composing {
+ .tabs-bar,
+ .search {
+ margin-top: -50px;
+ }
+
+ .navigation-bar {
+ padding-bottom: 0;
+
+ & > a:first-child {
+ margin-top: -50px;
+ margin-left: -40px;
+ }
+
+ .navigation-bar__profile {
+ padding-top: 2px;
+ }
+
+ .navigation-bar__profile-edit {
+ position: absolute;
+ margin-top: -50px;
+ }
+
+ .icon-button {
+ pointer-events: auto;
+ opacity: 1;
+ }
+ }
+ }
+
+ // fixes for the navbar-under mode
+ .is-composing.navbar-under {
+ .search {
+ margin-top: -20px;
+ margin-bottom: -20px;
+ .search__icon {
+ display: none;
+ }
+ }
+ }
+}
+
+// more fixes for the navbar-under mode
+@mixin fix-margins-for-navbar-under {
+ .tabs-bar {
+ margin-top: 0 !important;
+ margin-bottom: -6px !important;
+ }
+}
+
+.single-column.navbar-under {
+ @include fix-margins-for-navbar-under;
+}
+
+.auto-columns.navbar-under {
+ @media screen and (max-width: 360px) {
+ @include fix-margins-for-navbar-under;
+ }
+}
+
+.auto-columns.navbar-under .react-swipeable-view-container .columns-area,
+.single-column.navbar-under .react-swipeable-view-container .columns-area {
+ @media screen and (max-width: 360px) {
+ height: 100% !important;
+ }
+}
+
+.embed-modal {
+ max-width: 80vw;
+ max-height: 80vh;
+
+ h4 {
+ padding: 30px;
+ font-weight: 500;
+ font-size: 16px;
+ text-align: center;
+ }
+
+ .embed-modal__container {
+ padding: 10px;
+
+ .hint {
+ margin-bottom: 15px;
+ }
+
+ .embed-modal__html {
+ color: $ui-secondary-color;
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ font-family: 'mastodon-font-monospace', monospace;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+ margin-bottom: 15px;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+ }
+
+ .embed-modal__iframe {
+ width: 400px;
+ max-width: 100%;
+ overflow: hidden;
+ border: 0;
+ }
+ }
+}
+
+@import 'doodle';
diff --git a/app/javascript/themes/glitch/styles/containers.scss b/app/javascript/themes/glitch/styles/containers.scss
new file mode 100644
index 000000000..af2589e23
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/containers.scss
@@ -0,0 +1,116 @@
+.container {
+ width: 700px;
+ margin: 0 auto;
+ margin-top: 40px;
+
+ @media screen and (max-width: 740px) {
+ width: 100%;
+ margin: 0;
+ }
+}
+
+.logo-container {
+ margin: 100px auto;
+ margin-bottom: 50px;
+
+ @media screen and (max-width: 400px) {
+ margin: 30px auto;
+ margin-bottom: 20px;
+ }
+
+ h1 {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img {
+ height: 42px;
+ margin-right: 10px;
+ }
+
+ a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $primary-text-color;
+ text-decoration: none;
+ outline: 0;
+ padding: 12px 16px;
+ line-height: 32px;
+ font-family: 'mastodon-font-display', sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+ }
+ }
+}
+
+.compose-standalone {
+ .compose-form {
+ width: 400px;
+ margin: 0 auto;
+ padding: 20px 0;
+ margin-top: 40px;
+ box-sizing: border-box;
+
+ @media screen and (max-width: 400px) {
+ width: 100%;
+ margin-top: 0;
+ padding: 20px;
+ }
+ }
+}
+
+.account-header {
+ width: 400px;
+ margin: 0 auto;
+ display: flex;
+ font-size: 13px;
+ line-height: 18px;
+ box-sizing: border-box;
+ padding: 20px 0;
+ padding-bottom: 0;
+ margin-bottom: -30px;
+ margin-top: 40px;
+
+ @media screen and (max-width: 440px) {
+ width: 100%;
+ margin: 0;
+ margin-bottom: 10px;
+ padding: 20px;
+ padding-bottom: 0;
+ }
+
+ .avatar {
+ width: 40px;
+ height: 40px;
+ margin-right: 8px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: 0;
+ border-radius: 4px;
+ }
+ }
+
+ .name {
+ flex: 1 1 auto;
+ color: $ui-secondary-color;
+ width: calc(100% - 88px);
+
+ .username {
+ display: block;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ .logout-link {
+ display: block;
+ font-size: 32px;
+ line-height: 40px;
+ margin-left: 8px;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/doodle.scss b/app/javascript/themes/glitch/styles/doodle.scss
new file mode 100644
index 000000000..a4a1cfc84
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/doodle.scss
@@ -0,0 +1,86 @@
+$doodleBg: #d9e1e8;
+.doodle-modal {
+ @extend .boost-modal;
+ width: unset;
+}
+
+.doodle-modal__container {
+ background: $doodleBg;
+ text-align: center;
+ line-height: 0; // remove weird gap under canvas
+ canvas {
+ border: 5px solid $doodleBg;
+ }
+}
+
+.doodle-modal__action-bar {
+ @extend .boost-modal__action-bar;
+
+ .filler {
+ flex-grow: 1;
+ margin: 0;
+ padding: 0;
+ }
+
+ .doodle-toolbar {
+ line-height: 1;
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 0;
+ justify-content: space-around;
+
+ &.with-inputs {
+ label {
+ display: inline-block;
+ width: 70px;
+ text-align: right;
+ margin-right: 2px;
+ }
+
+ input[type="number"],input[type="text"] {
+ width: 40px;
+ }
+ span.val {
+ display: inline-block;
+ text-align: left;
+ width: 50px;
+ }
+ }
+ }
+
+ .doodle-palette {
+ padding-right: 0 !important;
+ border: 1px solid black;
+ line-height: .2rem;
+ flex-grow: 0;
+ background: white;
+
+ button {
+ appearance: none;
+ width: 1rem;
+ height: 1rem;
+ margin: 0; padding: 0;
+ text-align: center;
+ color: black;
+ text-shadow: 0 0 1px white;
+ cursor: pointer;
+ box-shadow: inset 0 0 1px rgba(white, .5);
+ border: 1px solid black;
+ outline-offset:-1px;
+
+ &.foreground {
+ outline: 1px dashed white;
+ }
+
+ &.background {
+ outline: 1px dashed red;
+ }
+
+ &.foreground.background {
+ outline: 1px dashed red;
+ border-color: white;
+ }
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/emoji_picker.scss b/app/javascript/themes/glitch/styles/emoji_picker.scss
new file mode 100644
index 000000000..2b46d30fc
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/emoji_picker.scss
@@ -0,0 +1,199 @@
+.emoji-mart {
+ &,
+ * {
+ box-sizing: border-box;
+ line-height: 1.15;
+ }
+
+ font-size: 13px;
+ display: inline-block;
+ color: $ui-base-color;
+
+ .emoji-mart-emoji {
+ padding: 6px;
+ }
+}
+
+.emoji-mart-bar {
+ border: 0 solid darken($ui-secondary-color, 8%);
+
+ &:first-child {
+ border-bottom-width: 1px;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ background: $ui-secondary-color;
+ }
+
+ &:last-child {
+ border-top-width: 1px;
+ border-bottom-left-radius: 5px;
+ border-bottom-right-radius: 5px;
+ display: none;
+ }
+}
+
+.emoji-mart-anchors {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 6px;
+ color: $ui-primary-color;
+ line-height: 0;
+}
+
+.emoji-mart-anchor {
+ position: relative;
+ flex: 1;
+ text-align: center;
+ padding: 12px 4px;
+ overflow: hidden;
+ transition: color .1s ease-out;
+ cursor: pointer;
+
+ &:hover {
+ color: darken($ui-primary-color, 4%);
+ }
+}
+
+.emoji-mart-anchor-selected {
+ color: darken($ui-highlight-color, 3%);
+
+ &:hover {
+ color: darken($ui-highlight-color, 3%);
+ }
+
+ .emoji-mart-anchor-bar {
+ bottom: 0;
+ }
+}
+
+.emoji-mart-anchor-bar {
+ position: absolute;
+ bottom: -3px;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background-color: darken($ui-highlight-color, 3%);
+}
+
+.emoji-mart-anchors {
+ i {
+ display: inline-block;
+ width: 100%;
+ max-width: 22px;
+ }
+
+ svg {
+ fill: currentColor;
+ max-height: 18px;
+ }
+}
+
+.emoji-mart-scroll {
+ overflow-y: scroll;
+ height: 270px;
+ max-height: 35vh;
+ padding: 0 6px 6px;
+ background: $simple-background-color;
+ will-change: transform;
+}
+
+.emoji-mart-search {
+ padding: 10px;
+ padding-right: 45px;
+ background: $simple-background-color;
+
+ input {
+ font-size: 14px;
+ font-weight: 400;
+ padding: 7px 9px;
+ font-family: inherit;
+ display: block;
+ width: 100%;
+ background: rgba($ui-secondary-color, 0.3);
+ color: $ui-primary-color;
+ border: 1px solid $ui-secondary-color;
+ border-radius: 4px;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+ }
+}
+
+.emoji-mart-category .emoji-mart-emoji {
+ cursor: pointer;
+
+ span {
+ z-index: 1;
+ position: relative;
+ text-align: center;
+ }
+
+ &:hover::before {
+ z-index: 0;
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba($ui-secondary-color, 0.7);
+ border-radius: 100%;
+ }
+}
+
+.emoji-mart-category-label {
+ z-index: 2;
+ position: relative;
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+
+ span {
+ display: block;
+ width: 100%;
+ font-weight: 500;
+ padding: 5px 6px;
+ background: $simple-background-color;
+ }
+}
+
+.emoji-mart-emoji {
+ position: relative;
+ display: inline-block;
+ font-size: 0;
+
+ span {
+ width: 22px;
+ height: 22px;
+ }
+}
+
+.emoji-mart-no-results {
+ font-size: 14px;
+ text-align: center;
+ padding-top: 70px;
+ color: $ui-primary-color;
+
+ .emoji-mart-category-label {
+ display: none;
+ }
+
+ .emoji-mart-no-results-label {
+ margin-top: .2em;
+ }
+
+ .emoji-mart-emoji:hover::before {
+ content: none;
+ }
+}
+
+.emoji-mart-preview {
+ display: none;
+}
diff --git a/app/javascript/themes/glitch/styles/footer.scss b/app/javascript/themes/glitch/styles/footer.scss
new file mode 100644
index 000000000..2d953b34e
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/footer.scss
@@ -0,0 +1,30 @@
+.footer {
+ text-align: center;
+ margin-top: 30px;
+ font-size: 12px;
+ color: darken($ui-secondary-color, 25%);
+
+ .domain {
+ font-weight: 500;
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+ }
+
+ .powered-by,
+ .single-user-login {
+ font-weight: 400;
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ font-weight: 500;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/forms.scss b/app/javascript/themes/glitch/styles/forms.scss
new file mode 100644
index 000000000..61fcf286f
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/forms.scss
@@ -0,0 +1,540 @@
+code {
+ font-family: 'mastodon-font-monospace', monospace;
+ font-weight: 400;
+}
+
+.form-container {
+ max-width: 400px;
+ padding: 20px;
+ margin: 0 auto;
+}
+
+.simple_form {
+ .input {
+ margin-bottom: 15px;
+ overflow: hidden;
+ }
+
+ span.hint {
+ display: block;
+ color: $ui-primary-color;
+ font-size: 12px;
+ margin-top: 4px;
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font-size: 13px;
+ font-weight: 500;
+ color: $ui-primary-color;
+ padding-bottom: 8px;
+ margin-bottom: 8px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ }
+
+ p.hint {
+ margin-bottom: 15px;
+ color: $ui-primary-color;
+
+ &.subtle-hint {
+ text-align: center;
+ font-size: 12px;
+ line-height: 18px;
+ margin-top: 15px;
+ margin-bottom: 0;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+ }
+ }
+
+ .card {
+ margin-bottom: 15px;
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ .label_input {
+ display: flex;
+
+ label {
+ flex: 0 0 auto;
+ }
+
+ input {
+ flex: 1 1 auto;
+ }
+ }
+
+ .input.with_label {
+ padding: 15px 0;
+ margin-bottom: 0;
+
+ .label_input {
+ flex-wrap: wrap;
+ align-items: flex-start;
+ }
+
+ &.select .label_input {
+ align-items: initial;
+ }
+
+ .label_input > label {
+ font-family: inherit;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ padding-top: 5px;
+ margin-bottom: 5px;
+ flex: 1;
+ min-width: 150px;
+ word-wrap: break-word;
+
+ &.select {
+ flex: 0;
+ }
+
+ & ~ * {
+ margin-left: 10px;
+ }
+ }
+
+ ul {
+ flex: 390px;
+ }
+
+ &.boolean {
+ padding: initial;
+ margin-bottom: initial;
+
+ .label_input > label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ label.checkbox {
+ position: relative;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+ }
+ }
+
+ .input.with_block_label {
+ & > label {
+ font-family: inherit;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ padding-top: 5px;
+ }
+
+ .hint {
+ margin-bottom: 15px;
+ }
+
+ li {
+ float: left;
+ width: 50%;
+ }
+ }
+
+ .fields-group {
+ margin-bottom: 25px;
+ }
+
+ .input.radio_buttons .radio label {
+ margin-bottom: 5px;
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ .input.boolean {
+ margin-bottom: 5px;
+
+ label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ label.checkbox {
+ position: relative;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+
+ input[type=checkbox] {
+ position: absolute;
+ left: 0;
+ top: 5px;
+ margin: 0;
+ }
+
+ .hint {
+ padding-left: 25px;
+ margin-left: 0;
+ }
+ }
+
+ .check_boxes {
+ .checkbox {
+ label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ position: relative;
+ padding-top: 5px;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+
+ input[type=checkbox] {
+ position: absolute;
+ left: 0;
+ top: 5px;
+ margin: 0;
+ }
+ }
+ }
+
+ input[type=text],
+ input[type=number],
+ input[type=email],
+ input[type=password],
+ textarea {
+ background: transparent;
+ box-sizing: border-box;
+ border: 0;
+ border-bottom: 2px solid $ui-primary-color;
+ border-radius: 2px 2px 0 0;
+ padding: 7px 4px;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ width: 100%;
+ outline: 0;
+ font-family: inherit;
+ resize: vertical;
+
+ &:invalid {
+ box-shadow: none;
+ }
+
+ &:focus:invalid {
+ border-bottom-color: $error-value-color;
+ }
+
+ &:required:valid {
+ border-bottom-color: $valid-value-color;
+ }
+
+ &:active,
+ &:focus {
+ border-bottom-color: $ui-highlight-color;
+ background: rgba($base-overlay-background, 0.1);
+ }
+ }
+
+ .input.field_with_errors {
+ label {
+ color: $error-value-color;
+ }
+
+ input[type=text],
+ input[type=email],
+ input[type=password] {
+ border-bottom-color: $error-value-color;
+ }
+
+ .error {
+ display: block;
+ font-weight: 500;
+ color: $error-value-color;
+ margin-top: 4px;
+ }
+ }
+
+ .actions {
+ margin-top: 30px;
+ display: flex;
+ }
+
+ button,
+ .button,
+ .block-button {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 4px;
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ font-size: 18px;
+ line-height: inherit;
+ height: auto;
+ padding: 10px;
+ text-transform: uppercase;
+ text-decoration: none;
+ text-align: center;
+ box-sizing: border-box;
+ cursor: pointer;
+ font-weight: 500;
+ outline: 0;
+ margin-bottom: 10px;
+ margin-right: 10px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ &:hover {
+ background-color: lighten($ui-highlight-color, 5%);
+ }
+
+ &:active,
+ &:focus {
+ background-color: darken($ui-highlight-color, 5%);
+ }
+
+ &.negative {
+ background: $error-value-color;
+
+ &:hover {
+ background-color: lighten($error-value-color, 5%);
+ }
+
+ &:active,
+ &:focus {
+ background-color: darken($error-value-color, 5%);
+ }
+ }
+ }
+
+ select {
+ font-size: 16px;
+ max-height: 29px;
+ }
+
+ .input-with-append {
+ position: relative;
+
+ .input input {
+ padding-right: 127px;
+ }
+
+ .append {
+ position: absolute;
+ right: 0;
+ top: 0;
+ padding: 7px 4px;
+ padding-bottom: 9px;
+ font-size: 16px;
+ color: $ui-base-lighter-color;
+ font-family: inherit;
+ pointer-events: none;
+ cursor: default;
+ }
+ }
+}
+
+.flash-message {
+ background: lighten($ui-base-color, 8%);
+ color: $ui-primary-color;
+ border-radius: 4px;
+ padding: 15px 10px;
+ margin-bottom: 30px;
+ box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+ text-align: center;
+
+ p {
+ margin-bottom: 15px;
+ }
+
+ .oauth-code {
+ color: $ui-secondary-color;
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ font-family: 'mastodon-font-monospace', monospace;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ @media screen and (max-width: 740px) and (min-width: 441px) {
+ margin-top: 40px;
+ }
+}
+
+.form-footer {
+ margin-top: 30px;
+ text-align: center;
+
+ a {
+ color: $ui-primary-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.oauth-prompt,
+.follow-prompt {
+ margin-bottom: 30px;
+ text-align: center;
+ color: $ui-primary-color;
+
+ h2 {
+ font-size: 16px;
+ margin-bottom: 30px;
+ }
+
+ strong {
+ color: $ui-secondary-color;
+ font-weight: 500;
+ }
+
+ @media screen and (max-width: 740px) and (min-width: 441px) {
+ margin-top: 40px;
+ }
+}
+
+.qr-wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+}
+
+.qr-code {
+ flex: 0 0 auto;
+ background: $simple-background-color;
+ padding: 4px;
+ margin: 0 10px 20px 0;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ display: inline-block;
+
+ svg {
+ display: block;
+ margin: 0;
+ }
+}
+
+.qr-alternative {
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ flex: 150px;
+
+ samp {
+ display: block;
+ font-size: 14px;
+ }
+}
+
+.table-form {
+ p {
+ margin-bottom: 15px;
+
+ strong {
+ font-weight: 500;
+ }
+ }
+}
+
+.simple_form,
+.table-form {
+ .warning {
+ box-sizing: border-box;
+ background: rgba($error-value-color, 0.5);
+ color: $primary-text-color;
+ text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
+ box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 15px;
+
+ a {
+ color: $primary-text-color;
+ text-decoration: underline;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: none;
+ }
+ }
+
+ strong {
+ font-weight: 600;
+ display: block;
+ margin-bottom: 5px;
+
+ .fa {
+ font-weight: 400;
+ }
+ }
+ }
+}
+
+.action-pagination {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+
+ .actions,
+ .pagination {
+ flex: 1 1 auto;
+ }
+
+ .actions {
+ padding: 30px 0;
+ padding-right: 20px;
+ flex: 0 0 auto;
+ }
+}
+
+.post-follow-actions {
+ text-align: center;
+ color: $ui-primary-color;
+
+ div {
+ margin-bottom: 4px;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/index.scss b/app/javascript/themes/glitch/styles/index.scss
new file mode 100644
index 000000000..0eb6ac6d8
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/index.scss
@@ -0,0 +1,22 @@
+@import 'mixins';
+@import 'variables';
+@import 'fonts/roboto';
+@import 'fonts/roboto-mono';
+@import 'fonts/montserrat';
+
+@import 'reset';
+@import 'basics';
+@import 'containers';
+@import 'lists';
+@import 'footer';
+@import 'compact_header';
+@import 'landing_strip';
+@import 'forms';
+@import 'accounts';
+@import 'stream_entries';
+@import 'components';
+@import 'emoji_picker';
+@import 'about';
+@import 'tables';
+@import 'admin';
+@import 'rtl';
diff --git a/app/javascript/themes/glitch/styles/landing_strip.scss b/app/javascript/themes/glitch/styles/landing_strip.scss
new file mode 100644
index 000000000..0bf9daafd
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/landing_strip.scss
@@ -0,0 +1,36 @@
+.landing-strip,
+.memoriam-strip {
+ background: rgba(darken($ui-base-color, 7%), 0.8);
+ color: $ui-primary-color;
+ font-weight: 400;
+ padding: 14px;
+ border-radius: 4px;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+
+ strong,
+ a {
+ font-weight: 500;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+
+ .logo {
+ width: 30px;
+ height: 30px;
+ flex: 0 0 auto;
+ margin-right: 15px;
+ }
+
+ @media screen and (max-width: 740px) {
+ margin-bottom: 0;
+ }
+}
+
+.memoriam-strip {
+ background: rgba($base-shadow-color, 0.7);
+}
diff --git a/app/javascript/themes/glitch/styles/lists.scss b/app/javascript/themes/glitch/styles/lists.scss
new file mode 100644
index 000000000..6019cd800
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/lists.scss
@@ -0,0 +1,19 @@
+.no-list {
+ list-style: none;
+
+ li {
+ display: inline-block;
+ margin: 0 5px;
+ }
+}
+
+.recovery-codes {
+ list-style: none;
+ margin: 0 auto;
+
+ li {
+ font-size: 125%;
+ line-height: 1.5;
+ letter-spacing: 1px;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/reset copy.scss b/app/javascript/themes/glitch/styles/reset copy.scss
new file mode 100644
index 000000000..cc5ba9d7c
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/reset copy.scss
@@ -0,0 +1,91 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+
+body {
+ line-height: 1;
+}
+
+ol, ul {
+ list-style: none;
+}
+
+blockquote, q {
+ quotes: none;
+}
+
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: lighten($ui-base-color, 4%);
+ border: 0px none $base-border-color;
+ border-radius: 50px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: lighten($ui-base-color, 6%);
+}
+
+::-webkit-scrollbar-thumb:active {
+ background: lighten($ui-base-color, 4%);
+}
+
+::-webkit-scrollbar-track {
+ border: 0px none $base-border-color;
+ border-radius: 0;
+ background: rgba($base-overlay-background, 0.1);
+}
+
+::-webkit-scrollbar-track:hover {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-track:active {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
diff --git a/app/javascript/themes/glitch/styles/reset.scss b/app/javascript/themes/glitch/styles/reset.scss
new file mode 100644
index 000000000..cc5ba9d7c
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/reset.scss
@@ -0,0 +1,91 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+
+body {
+ line-height: 1;
+}
+
+ol, ul {
+ list-style: none;
+}
+
+blockquote, q {
+ quotes: none;
+}
+
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: lighten($ui-base-color, 4%);
+ border: 0px none $base-border-color;
+ border-radius: 50px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: lighten($ui-base-color, 6%);
+}
+
+::-webkit-scrollbar-thumb:active {
+ background: lighten($ui-base-color, 4%);
+}
+
+::-webkit-scrollbar-track {
+ border: 0px none $base-border-color;
+ border-radius: 0;
+ background: rgba($base-overlay-background, 0.1);
+}
+
+::-webkit-scrollbar-track:hover {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-track:active {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
diff --git a/app/javascript/themes/glitch/styles/rtl.scss b/app/javascript/themes/glitch/styles/rtl.scss
new file mode 100644
index 000000000..67bfa8a38
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/rtl.scss
@@ -0,0 +1,254 @@
+body.rtl {
+ direction: rtl;
+
+ .column-link__icon,
+ .column-header__icon {
+ margin-right: 0;
+ margin-left: 5px;
+ }
+
+ .character-counter__wrapper {
+ margin-right: 8px;
+ margin-left: 16px;
+ }
+
+ .navigation-bar__profile {
+ margin-left: 0;
+ margin-right: 8px;
+ }
+
+ .search__input {
+ padding-right: 10px;
+ padding-left: 30px;
+ }
+
+ .search__icon .fa {
+ right: auto;
+ left: 10px;
+ }
+
+ .column-header__buttons {
+ left: 0;
+ right: auto;
+ }
+
+ .column-header__back-button {
+ padding-left: 5px;
+ padding-right: 0;
+ }
+
+ .column-header__setting-arrows {
+ float: left;
+ }
+
+ .compose-form__modifiers {
+ border-radius: 0 0 0 4px;
+ }
+
+ .setting-toggle {
+ margin-left: 0;
+ margin-right: 8px;
+ }
+
+ .setting-meta__label {
+ float: left;
+ }
+
+ .status__avatar {
+ left: auto;
+ right: 10px;
+ }
+
+ .status,
+ .activity-stream .status.light {
+ padding-left: 10px;
+ padding-right: 68px;
+ }
+
+ .status__info .status__display-name,
+ .activity-stream .status.light .status__display-name {
+ padding-left: 25px;
+ padding-right: 0;
+ }
+
+ .activity-stream .pre-header {
+ padding-right: 68px;
+ padding-left: 0;
+ }
+
+ .status__prepend {
+ margin-left: 0;
+ margin-right: 68px;
+ }
+
+ .status__prepend-icon-wrapper {
+ left: auto;
+ right: -26px;
+ }
+
+ .activity-stream .pre-header .pre-header__icon {
+ left: auto;
+ right: 42px;
+ }
+
+ .account__avatar-overlay-overlay {
+ right: auto;
+ left: 0;
+ }
+
+ .column-back-button--slim-button {
+ right: auto;
+ left: 0;
+ }
+
+ .status__relative-time,
+ .activity-stream .status.light .status__header .status__meta {
+ float: left;
+ }
+
+ .activity-stream .detailed-status.light .detailed-status__display-name > div {
+ float: right;
+ margin-right: 0;
+ margin-left: 10px;
+ }
+
+ .activity-stream .detailed-status.light .detailed-status__meta span > span {
+ margin-left: 0;
+ margin-right: 6px;
+ }
+
+ .status__action-bar-button {
+ float: right;
+ margin-right: 0;
+ margin-left: 18px;
+ }
+
+ .status__action-bar-dropdown {
+ float: right;
+ }
+
+ .privacy-dropdown__dropdown {
+ margin-left: 0;
+ margin-right: 40px;
+ }
+
+ .privacy-dropdown__option__icon {
+ margin-left: 10px;
+ margin-right: 0;
+ }
+
+ .detailed-status__display-avatar {
+ margin-right: 0;
+ margin-left: 10px;
+ float: right;
+ }
+
+ .detailed-status__favorites,
+ .detailed-status__reblogs {
+ margin-left: 0;
+ margin-right: 6px;
+ }
+
+ .fa-ul {
+ margin-left: 0;
+ margin-left: 2.14285714em;
+ }
+
+ .fa-li {
+ left: auto;
+ right: -2.14285714em;
+ }
+
+ .admin-wrapper .sidebar ul a i.fa,
+ a.table-action-link i.fa {
+ margin-right: 0;
+ margin-left: 5px;
+ }
+
+ .simple_form .check_boxes .checkbox label,
+ .simple_form .input.with_label.boolean label.checkbox {
+ padding-left: 0;
+ padding-right: 25px;
+ }
+
+ .simple_form .check_boxes .checkbox input[type="checkbox"],
+ .simple_form .input.boolean input[type="checkbox"] {
+ left: auto;
+ right: 0;
+ }
+
+ .simple_form .input-with-append .input input {
+ padding-left: 127px;
+ padding-right: 0;
+ }
+
+ .simple_form .input-with-append .append {
+ right: auto;
+ left: 0;
+ }
+
+ .table th,
+ .table td {
+ text-align: right;
+ }
+
+ .filters .filter-subset {
+ margin-right: 0;
+ margin-left: 45px;
+ }
+
+ .landing-page .header-wrapper .mascot {
+ right: 60px;
+ left: auto;
+ }
+
+ .landing-page .header .hero .floats .float-1 {
+ left: -120px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-2 {
+ left: 210px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-3 {
+ left: 110px;
+ right: auto;
+ }
+
+ .landing-page .header .links .brand img {
+ left: 0;
+ }
+
+ .landing-page .fa-external-link {
+ padding-right: 5px;
+ padding-left: 0 !important;
+ }
+
+ .landing-page .features #mastodon-timeline {
+ margin-right: 0;
+ margin-left: 30px;
+ }
+
+ @media screen and (min-width: 631px) {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+
+ &:first-child {
+ padding-left: 5px;
+ padding-right: 10px;
+ }
+ }
+
+ .columns-area > div {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/stream_entries.scss b/app/javascript/themes/glitch/styles/stream_entries.scss
new file mode 100644
index 000000000..453070b7c
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/stream_entries.scss
@@ -0,0 +1,335 @@
+.activity-stream {
+ clear: both;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+ .entry {
+ background: $simple-background-color;
+
+ .detailed-status.light,
+ .status.light {
+ border-bottom: 1px solid $ui-secondary-color;
+ animation: none;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-bottom: 0;
+ border-radius: 0 0 4px 4px;
+ }
+ }
+
+ &:first-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 4px 4px 0 0;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 4px;
+ }
+ }
+ }
+
+ @media screen and (max-width: 740px) {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 !important;
+ }
+ }
+ }
+
+ &.with-header {
+ .entry {
+ &:first-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 0 4px 4px;
+ }
+ }
+ }
+ }
+ }
+
+ .status.light {
+ padding: 14px 14px 14px (48px + 14px * 2);
+ position: relative;
+ min-height: 48px;
+ cursor: default;
+
+ .status__header {
+ font-size: 15px;
+
+ .status__meta {
+ float: right;
+ font-size: 14px;
+
+ .status__relative-time {
+ color: $ui-primary-color;
+ }
+ }
+ }
+
+ .status__display-name {
+ display: block;
+ max-width: 100%;
+ padding-right: 25px;
+ color: $ui-base-color;
+ }
+
+ .status__avatar {
+ position: absolute;
+ @include avatar-size(48px);
+ margin-left: -62px;
+
+ & > div {
+ @include avatar-size(48px);
+ }
+
+ img {
+ @include avatar-radius();
+ display: block;
+ }
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ //overflow: hidden;
+ //white-space: nowrap;
+ //text-overflow: ellipsis;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+ }
+
+ .detailed-status.light {
+ padding: 14px;
+ background: $simple-background-color;
+ cursor: default;
+
+ .detailed-status__display-name {
+ display: block;
+ overflow: hidden;
+ margin-bottom: 15px;
+
+ & > div {
+ float: left;
+ margin-right: 10px;
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+ }
+
+ .avatar {
+ @include avatar-size(48px);
+
+ img {
+ @include avatar-radius();
+ display: block;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+
+ .detailed-status__meta {
+ margin-top: 15px;
+ color: $ui-primary-color;
+ font-size: 14px;
+ line-height: 18px;
+
+ a {
+ color: inherit;
+ }
+
+ span > span {
+ font-weight: 500;
+ font-size: 12px;
+ margin-left: 6px;
+ display: inline-block;
+ }
+ }
+
+ .status-card {
+ border-color: lighten($ui-secondary-color, 4%);
+ color: darken($ui-primary-color, 4%);
+
+ &:hover {
+ background: lighten($ui-secondary-color, 4%);
+ }
+ }
+
+ .status-card__title,
+ .status-card__description {
+ color: $ui-base-color;
+ }
+
+ .status-card__image {
+ background: $ui-secondary-color;
+ }
+ }
+
+ .media-spoiler {
+ background: $ui-primary-color;
+ color: $white;
+ transition: all 100ms linear;
+
+ &:hover,
+ &:active,
+ &:focus {
+ background: darken($ui-primary-color, 5%);
+ color: unset;
+ }
+ }
+
+ .pre-header {
+ padding: 14px 0;
+ padding-left: (48px + 14px * 2);
+ padding-bottom: 0;
+ margin-bottom: -4px;
+ color: $ui-primary-color;
+ font-size: 14px;
+ position: relative;
+
+ .pre-header__icon {
+ position: absolute;
+ left: (48px + 14px * 2 - 30px);
+ }
+
+ .status__display-name.muted strong {
+ color: $ui-primary-color;
+ }
+ }
+
+ .open-in-web-link {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.embed {
+ .activity-stream {
+ box-shadow: none;
+
+ .entry {
+
+ .detailed-status.light {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ .detailed-status__display-name {
+ flex: 1;
+ margin: 0 5px 15px 0;
+ }
+
+ .button.button-secondary.logo-button {
+ flex: 0 auto;
+ font-size: 14px;
+
+ svg {
+ width: 20px;
+ height: auto;
+ vertical-align: middle;
+ margin-right: 5px;
+
+ path:first-child {
+ fill: $ui-primary-color;
+ }
+
+ path:last-child {
+ fill: $simple-background-color;
+ }
+ }
+
+ &:active,
+ &:focus,
+ &:hover {
+ svg path:first-child {
+ fill: lighten($ui-primary-color, 4%);
+ }
+ }
+ }
+
+ .status__content,
+ .detailed-status__meta {
+ flex: 100%;
+ }
+ }
+ }
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/tables.scss b/app/javascript/themes/glitch/styles/tables.scss
new file mode 100644
index 000000000..ad46f5f9f
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/tables.scss
@@ -0,0 +1,76 @@
+.table {
+ width: 100%;
+ max-width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+
+ th,
+ td {
+ padding: 8px;
+ line-height: 18px;
+ vertical-align: top;
+ border-top: 1px solid $ui-base-color;
+ text-align: left;
+ }
+
+ & > thead > tr > th {
+ vertical-align: bottom;
+ border-bottom: 2px solid $ui-base-color;
+ border-top: 0;
+ font-weight: 500;
+ }
+
+ & > tbody > tr > th {
+ font-weight: 500;
+ }
+
+ & > tbody > tr:nth-child(odd) > td,
+ & > tbody > tr:nth-child(odd) > th {
+ background: $ui-base-color;
+ }
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ &.inline-table > tbody > tr:nth-child(odd) > td,
+ &.inline-table > tbody > tr:nth-child(odd) > th {
+ background: transparent;
+ }
+}
+
+.table-wrapper {
+ overflow: auto;
+ margin-bottom: 20px;
+}
+
+samp {
+ font-family: 'mastodon-font-monospace', monospace;
+}
+
+a.table-action-link {
+ text-decoration: none;
+ display: inline-block;
+ margin-right: 5px;
+ padding: 0 10px;
+ color: rgba($primary-text-color, 0.7);
+ font-weight: 500;
+
+ &:hover {
+ color: $primary-text-color;
+ }
+
+ i.fa {
+ font-weight: 400;
+ margin-right: 5px;
+ }
+}
diff --git a/app/javascript/themes/glitch/styles/variables.scss b/app/javascript/themes/glitch/styles/variables.scss
new file mode 100644
index 000000000..f42d9c8c5
--- /dev/null
+++ b/app/javascript/themes/glitch/styles/variables.scss
@@ -0,0 +1,35 @@
+// Commonly used web colors
+$black: #000000; // Black
+$white: #ffffff; // White
+$success-green: #79bd9a; // Padua
+$error-red: #df405a; // Cerise
+$warning-red: #ff5050; // Sunset Orange
+$gold-star: #ca8f04; // Dark Goldenrod
+
+// Values from the classic Mastodon UI
+$classic-base-color: #282c37; // Midnight Express
+$classic-primary-color: #9baec8; // Echo Blue
+$classic-secondary-color: #d9e1e8; // Pattens Blue
+$classic-highlight-color: #2b90d9; // Summer Sky
+
+// Variables for defaults in UI
+$base-shadow-color: $black !default;
+$base-overlay-background: $black !default;
+$base-border-color: $white !default;
+$simple-background-color: $white !default;
+$primary-text-color: $white !default;
+$valid-value-color: $success-green !default;
+$error-value-color: $error-red !default;
+
+// Tell UI to use selected colors
+$ui-base-color: $classic-base-color !default; // Darkest
+$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
+$ui-primary-color: $classic-primary-color !default; // Lighter
+$ui-secondary-color: $classic-secondary-color !default; // Lightest
+$ui-highlight-color: $classic-highlight-color !default; // Vibrant
+
+// Avatar border size (8% default, 100% for rounded avatars)
+$ui-avatar-border-size: 8%;
+
+// More variables
+$dismiss-overlay-width: 4rem;
diff --git a/app/javascript/themes/glitch/theme.yml b/app/javascript/themes/glitch/theme.yml
new file mode 100644
index 000000000..49fba8f40
--- /dev/null
+++ b/app/javascript/themes/glitch/theme.yml
@@ -0,0 +1,18 @@
+# (REQUIRED) The location of the pack file inside `pack_directory`.
+pack: index.js
+
+# (OPTIONAL) The directory which contains the pack file.
+# Defaults to the theme directory (`app/javascript/themes/[theme]`),
+# but in the case of the vanilla Mastodon theme the pack file is
+# somewhere else.
+# pack_directory: app/javascript/packs
+
+# (OPTIONAL) Additional javascript resources to preload, for use with
+# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you
+# derive these pathnames from `themes/[your-theme]` to ensure that
+# they stay unique. (Of course, vanilla doesn't do this ^^;;)
+preload:
+- themes/glitch/async/getting_started
+- themes/glitch/async/compose
+- themes/glitch/async/home_timeline
+- themes/glitch/async/notifications
diff --git a/app/javascript/themes/glitch/util/api.js b/app/javascript/themes/glitch/util/api.js
new file mode 100644
index 000000000..ecc703c0a
--- /dev/null
+++ b/app/javascript/themes/glitch/util/api.js
@@ -0,0 +1,26 @@
+import axios from 'axios';
+import LinkHeader from './link_header';
+
+export const getLinks = response => {
+ const value = response.headers.link;
+
+ if (!value) {
+ return { refs: [] };
+ }
+
+ return LinkHeader.parse(value);
+};
+
+export default getState => axios.create({
+ headers: {
+ 'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
+ },
+
+ transformResponse: [function (data) {
+ try {
+ return JSON.parse(data);
+ } catch(Exception) {
+ return data;
+ }
+ }],
+});
diff --git a/app/javascript/themes/glitch/util/async-components.js b/app/javascript/themes/glitch/util/async-components.js
new file mode 100644
index 000000000..91e85fed5
--- /dev/null
+++ b/app/javascript/themes/glitch/util/async-components.js
@@ -0,0 +1,115 @@
+export function EmojiPicker () {
+ return import(/* webpackChunkName: "themes/glitch/async/emoji_picker" */'themes/glitch/util/emoji/emoji_picker');
+}
+
+export function Compose () {
+ return import(/* webpackChunkName: "themes/glitch/async/compose" */'themes/glitch/features/compose');
+}
+
+export function Notifications () {
+ return import(/* webpackChunkName: "themes/glitch/async/notifications" */'themes/glitch/features/notifications');
+}
+
+export function HomeTimeline () {
+ return import(/* webpackChunkName: "themes/glitch/async/home_timeline" */'themes/glitch/features/home_timeline');
+}
+
+export function PublicTimeline () {
+ return import(/* webpackChunkName: "themes/glitch/async/public_timeline" */'themes/glitch/features/public_timeline');
+}
+
+export function CommunityTimeline () {
+ return import(/* webpackChunkName: "themes/glitch/async/community_timeline" */'themes/glitch/features/community_timeline');
+}
+
+export function HashtagTimeline () {
+ return import(/* webpackChunkName: "themes/glitch/async/hashtag_timeline" */'themes/glitch/features/hashtag_timeline');
+}
+
+export function DirectTimeline() {
+ return import(/* webpackChunkName: "themes/glitch/async/direct_timeline" */'themes/glitch/features/direct_timeline');
+}
+
+export function Status () {
+ return import(/* webpackChunkName: "themes/glitch/async/status" */'themes/glitch/features/status');
+}
+
+export function GettingStarted () {
+ return import(/* webpackChunkName: "themes/glitch/async/getting_started" */'themes/glitch/features/getting_started');
+}
+
+export function PinnedStatuses () {
+ return import(/* webpackChunkName: "themes/glitch/async/pinned_statuses" */'themes/glitch/features/pinned_statuses');
+}
+
+export function AccountTimeline () {
+ return import(/* webpackChunkName: "themes/glitch/async/account_timeline" */'themes/glitch/features/account_timeline');
+}
+
+export function AccountGallery () {
+ return import(/* webpackChunkName: "themes/glitch/async/account_gallery" */'themes/glitch/features/account_gallery');
+}
+
+export function Followers () {
+ return import(/* webpackChunkName: "themes/glitch/async/followers" */'themes/glitch/features/followers');
+}
+
+export function Following () {
+ return import(/* webpackChunkName: "themes/glitch/async/following" */'themes/glitch/features/following');
+}
+
+export function Reblogs () {
+ return import(/* webpackChunkName: "themes/glitch/async/reblogs" */'themes/glitch/features/reblogs');
+}
+
+export function Favourites () {
+ return import(/* webpackChunkName: "themes/glitch/async/favourites" */'themes/glitch/features/favourites');
+}
+
+export function FollowRequests () {
+ return import(/* webpackChunkName: "themes/glitch/async/follow_requests" */'themes/glitch/features/follow_requests');
+}
+
+export function GenericNotFound () {
+ return import(/* webpackChunkName: "themes/glitch/async/generic_not_found" */'themes/glitch/features/generic_not_found');
+}
+
+export function FavouritedStatuses () {
+ return import(/* webpackChunkName: "themes/glitch/async/favourited_statuses" */'themes/glitch/features/favourited_statuses');
+}
+
+export function Blocks () {
+ return import(/* webpackChunkName: "themes/glitch/async/blocks" */'themes/glitch/features/blocks');
+}
+
+export function Mutes () {
+ return import(/* webpackChunkName: "themes/glitch/async/mutes" */'themes/glitch/features/mutes');
+}
+
+export function OnboardingModal () {
+ return import(/* webpackChunkName: "themes/glitch/async/onboarding_modal" */'themes/glitch/features/ui/components/onboarding_modal');
+}
+
+export function MuteModal () {
+ return import(/* webpackChunkName: "themes/glitch/async/mute_modal" */'themes/glitch/features/ui/components/mute_modal');
+}
+
+export function ReportModal () {
+ return import(/* webpackChunkName: "themes/glitch/async/report_modal" */'themes/glitch/features/ui/components/report_modal');
+}
+
+export function SettingsModal () {
+ return import(/* webpackChunkName: "themes/glitch/async/settings_modal" */'themes/glitch/features/local_settings');
+}
+
+export function MediaGallery () {
+ return import(/* webpackChunkName: "themes/glitch/async/media_gallery" */'themes/glitch/components/media_gallery');
+}
+
+export function Video () {
+ return import(/* webpackChunkName: "themes/glitch/async/video" */'themes/glitch/features/video');
+}
+
+export function EmbedModal () {
+ return import(/* webpackChunkName: "themes/glitch/async/embed_modal" */'themes/glitch/features/ui/components/embed_modal');
+}
diff --git a/app/javascript/themes/glitch/util/base_polyfills.js b/app/javascript/themes/glitch/util/base_polyfills.js
new file mode 100644
index 000000000..7856b26f9
--- /dev/null
+++ b/app/javascript/themes/glitch/util/base_polyfills.js
@@ -0,0 +1,18 @@
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import isNaN from 'is-nan';
+
+if (!Array.prototype.includes) {
+ includes.shim();
+}
+
+if (!Object.assign) {
+ Object.assign = assign;
+}
+
+if (!Number.isNaN) {
+ Number.isNaN = isNaN;
+}
diff --git a/app/javascript/themes/glitch/util/bio_metadata.js b/app/javascript/themes/glitch/util/bio_metadata.js
new file mode 100644
index 000000000..599ec20e2
--- /dev/null
+++ b/app/javascript/themes/glitch/util/bio_metadata.js
@@ -0,0 +1,331 @@
+/*
+
+`util/bio_metadata`
+===================
+
+> For more information on the contents of this file, please contact:
+>
+> - kibigo! [@kibi@glitch.social]
+
+This file provides two functions for dealing with bio metadata. The
+functions are:
+
+ - __`processBio(content)` :__
+ Processes `content` to extract any frontmatter. The returned
+ object has two properties: `text`, which contains the text of
+ `content` sans-frontmatter, and `metadata`, which is an array
+ of key-value pairs (in two-element array format). If no
+ frontmatter was provided in `content`, then `metadata` will be
+ an empty array.
+
+ - __`createBio(note, data)` :__
+ Reverses the process in `processBio()`; takes a `note` and an
+ array of two-element arrays (which should give keys and values)
+ and outputs a string containing a well-formed bio with
+ frontmatter.
+
+*/
+
+// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+/*********************************************************************\
+
+ To my lovely code maintainers,
+
+ The syntax recognized by the Mastodon frontend for its bio metadata
+ feature is a subset of that provided by the YAML 1.2 specification.
+ In particular, Mastodon recognizes metadata which is provided as an
+ implicit YAML map, where each key-value pair takes up only a single
+ line (no multi-line values are permitted). To simplify the level of
+ processing required, Mastodon metadata frontmatter has been limited
+ to only allow those characters in the `c-printable` set, as defined
+ by the YAML 1.2 specification, instead of permitting those from the
+ `nb-json` characters inside double-quoted strings like YAML proper.
+ ¶ It is important to note that Mastodon only borrows the *syntax*
+ of YAML, not its semantics. This is to say, Mastodon won't make any
+ attempt to interpret the data it receives. `true` will not become a
+ boolean; `56` will not be interpreted as a number. Rather, each key
+ and every value will be read as a string, and as a string they will
+ remain. The order of the pairs is unchanged, and any duplicate keys
+ are preserved. However, YAML escape sequences will be replaced with
+ the proper interpretations according to the YAML 1.2 specification.
+ ¶ The implementation provided below interprets ` ` as `\n` and
+ allows for an open
tag at the beginning of the bio. It replaces
+ the escaped character entities `'` and `"` with single or
+ double quotes, respectively, prior to processing. However, no other
+ escaped characters are replaced, not even those which might have an
+ impact on the syntax otherwise. These minor allowances are provided
+ because the Mastodon backend will insert these things automatically
+ into a bio before sending it through the API, so it is important we
+ account for them. Aside from this, the YAML frontmatter must be the
+ very first thing in the bio, leading with three consecutive hyphen-
+ minues (`---`), and ending with the same or, alternatively, instead
+ with three periods (`...`). No limits have been set with respect to
+ the number of characters permitted in the frontmatter, although one
+ should note that only limited space is provided for them in the UI.
+ ¶ The regular expression used to check the existence of, and then
+ process, the YAML frontmatter has been split into a number of small
+ components in the code below, in the vain hope that it will be much
+ easier to read and to maintain. I leave it to the future readers of
+ this code to determine the extent of my successes in this endeavor.
+
+ UPDATE 19 Oct 2017: We no longer allow character escapes inside our
+ double-quoted strings for ease of processing. We now internally use
+ the name "ƔAML" in our code to clarify that this is Not Quite YAML.
+
+ Sending love + warmth eternal,
+ - kibigo [@kibi@glitch.social]
+
+\*********************************************************************/
+
+/* "u" FLAG COMPATABILITY */
+
+let compat_mode = false;
+try {
+ new RegExp('.', 'u');
+} catch (e) {
+ compat_mode = true;
+}
+
+/* CONVENIENCE FUNCTIONS */
+
+const unirex = str => compat_mode ? new RegExp(str) : new RegExp(str, 'u');
+const rexstr = exp => '(?:' + exp.source + ')';
+
+/* CHARACTER CLASSES */
+
+const DOCUMENT_START = /^/;
+const DOCUMENT_END = /$/;
+const ALLOWED_CHAR = unirex( // `c-printable` in the YAML 1.2 spec.
+ compat_mode ? '[\t\n\r\x20-\x7e\x85\xa0-\ufffd]' : '[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]'
+ );
+const WHITE_SPACE = /[ \t]/;
+const LINE_BREAK = /\r?\n|\r| /;
+const INDICATOR = /[-?:,[\]{}*!|>'"%@`]/;
+const FLOW_CHAR = /[,[\]{}]/;
+
+/* NEGATED CHARACTER CLASSES */
+
+const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
+const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
+const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
+const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
+const NOT_ALLOWED_CHAR = unirex(
+ '(?!' + rexstr(ALLOWED_CHAR) + ')[^]'
+);
+
+/* BASIC CONSTRUCTS */
+
+const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*');
+const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
+const NEW_LINE = unirex(
+ rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
+);
+const SOME_NEW_LINES = unirex(
+ '(?:' + rexstr(NEW_LINE) + ')+'
+);
+const POSSIBLE_STARTS = unirex(
+ rexstr(DOCUMENT_START) + rexstr(/
]*>/) + '?'
+);
+const POSSIBLE_ENDS = unirex(
+ rexstr(SOME_NEW_LINES) + '|' +
+ rexstr(DOCUMENT_END) + '|' +
+ rexstr(/<\/p>/)
+);
+const QUOTE_CHAR = unirex(
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')[^"]'
+);
+const ANY_QUOTE_CHAR = unirex(
+ rexstr(QUOTE_CHAR) + '*'
+);
+
+const ESCAPED_APOS = unirex(
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
+);
+const ANY_ESCAPED_APOS = unirex(
+ rexstr(ESCAPED_APOS) + '*'
+);
+const FIRST_KEY_CHAR = unirex(
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+ rexstr(NOT_INDICATOR) + '|' +
+ rexstr(/[?:-]/) +
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+ '(?=' + rexstr(NOT_FLOW_CHAR) + ')'
+);
+const FIRST_VALUE_CHAR = unirex(
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+ rexstr(NOT_INDICATOR) + '|' +
+ rexstr(/[?:-]/) +
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+ // Flow indicators are allowed in values.
+);
+const LATER_KEY_CHAR = unirex(
+ rexstr(WHITE_SPACE) + '|' +
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+ '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
+ rexstr(/[^:#]#?/) + '|' +
+ rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+const LATER_VALUE_CHAR = unirex(
+ rexstr(WHITE_SPACE) + '|' +
+ '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+ '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+ // Flow indicators are allowed in values.
+ rexstr(/[^:#]#?/) + '|' +
+ rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+
+/* YAML CONSTRUCTS */
+
+const ƔAML_START = unirex(
+ rexstr(ANY_WHITE_SPACE) + '---'
+);
+const ƔAML_END = unirex(
+ rexstr(ANY_WHITE_SPACE) + '(?:---|\.\.\.)'
+);
+const ƔAML_LOOKAHEAD = unirex(
+ '(?=' +
+ rexstr(ƔAML_START) +
+ rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
+ rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS) +
+ ')'
+);
+const ƔAML_DOUBLE_QUOTE = unirex(
+ '"' + rexstr(ANY_QUOTE_CHAR) + '"'
+);
+const ƔAML_SINGLE_QUOTE = unirex(
+ '\'' + rexstr(ANY_ESCAPED_APOS) + '\''
+);
+const ƔAML_SIMPLE_KEY = unirex(
+ rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
+);
+const ƔAML_SIMPLE_VALUE = unirex(
+ rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
+);
+const ƔAML_KEY = unirex(
+ rexstr(ƔAML_DOUBLE_QUOTE) + '|' +
+ rexstr(ƔAML_SINGLE_QUOTE) + '|' +
+ rexstr(ƔAML_SIMPLE_KEY)
+);
+const ƔAML_VALUE = unirex(
+ rexstr(ƔAML_DOUBLE_QUOTE) + '|' +
+ rexstr(ƔAML_SINGLE_QUOTE) + '|' +
+ rexstr(ƔAML_SIMPLE_VALUE)
+);
+const ƔAML_SEPARATOR = unirex(
+ rexstr(ANY_WHITE_SPACE) +
+ ':' + rexstr(WHITE_SPACE) +
+ rexstr(ANY_WHITE_SPACE)
+);
+const ƔAML_LINE = unirex(
+ '(' + rexstr(ƔAML_KEY) + ')' +
+ rexstr(ƔAML_SEPARATOR) +
+ '(' + rexstr(ƔAML_VALUE) + ')'
+);
+
+/* FRONTMATTER REGEX */
+
+const ƔAML_FRONTMATTER = unirex(
+ rexstr(POSSIBLE_STARTS) +
+ rexstr(ƔAML_LOOKAHEAD) +
+ rexstr(ƔAML_START) + rexstr(SOME_NEW_LINES) +
+ '(?:' +
+ rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE) + rexstr(SOME_NEW_LINES) +
+ '){0,5}' +
+ rexstr(ƔAML_END) + rexstr(POSSIBLE_ENDS)
+);
+
+/* SEARCHES */
+
+const FIND_ƔAML_LINE = unirex(
+ rexstr(NEW_LINE) + rexstr(ANY_WHITE_SPACE) + rexstr(ƔAML_LINE)
+);
+
+/* STRING PROCESSING */
+
+function processString (str) {
+ switch (str.charAt(0)) {
+ case '"':
+ return str.substring(1, str.length - 1);
+ case '\'':
+ return str
+ .substring(1, str.length - 1)
+ .replace(/''/g, '\'');
+ default:
+ return str;
+ }
+}
+
+/* BIO PROCESSING */
+
+export function processBio(content) {
+ content = content.replace(/"/g, '"').replace(/'/g, '\'');
+ let result = {
+ text: content,
+ metadata: [],
+ };
+ let ɣaml = content.match(ƔAML_FRONTMATTER);
+ if (!ɣaml) {
+ return result;
+ } else {
+ ɣaml = ɣaml[0];
+ }
+ const start = content.search(ƔAML_START);
+ const end = start + ɣaml.length - ɣaml.search(ƔAML_START);
+ result.text = content.substr(end);
+ let metadata = null;
+ let query = new RegExp(rexstr(FIND_ƔAML_LINE), 'g'); // Some browsers don't allow flags unless both args are strings
+ while ((metadata = query.exec(ɣaml))) {
+ result.metadata.push([
+ processString(metadata[1]),
+ processString(metadata[2]),
+ ]);
+ }
+ return result;
+}
+
+/* BIO CREATION */
+
+export function createBio(note, data) {
+ if (!note) note = '';
+ let frontmatter = '';
+ if ((data && data.length) || note.match(/^\s*---\s+/)) {
+ if (!data) frontmatter = '---\n...\n';
+ else {
+ frontmatter += '---\n';
+ for (let i = 0; i < data.length; i++) {
+ let key = '' + data[i][0];
+ let val = '' + data[i][1];
+
+ // Key processing
+ if (key === (key.match(ƔAML_SIMPLE_KEY) || [])[0]) /* do nothing */;
+ else if (key === (key.match(ANY_QUOTE_CHAR) || [])[0]) key = '"' + key + '"';
+ else {
+ key = key
+ .replace(/'/g, '\'\'')
+ .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�');
+ key = '\'' + key + '\'';
+ }
+
+ // Value processing
+ if (val === (val.match(ƔAML_SIMPLE_VALUE) || [])[0]) /* do nothing */;
+ else if (val === (val.match(ANY_QUOTE_CHAR) || [])[0]) val = '"' + val + '"';
+ else {
+ key = key
+ .replace(/'/g, '\'\'')
+ .replace(new RegExp(rexstr(NOT_ALLOWED_CHAR), compat_mode ? 'g' : 'gu'), '�');
+ key = '\'' + key + '\'';
+ }
+
+ frontmatter += key + ': ' + val + '\n';
+ }
+ frontmatter += '...\n';
+ }
+ }
+ return frontmatter + note;
+}
diff --git a/app/javascript/themes/glitch/util/counter.js b/app/javascript/themes/glitch/util/counter.js
new file mode 100644
index 000000000..700ba2163
--- /dev/null
+++ b/app/javascript/themes/glitch/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+ return inputText
+ .replace(urlRegex, urlPlaceholder)
+ .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
+};
diff --git a/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js b/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js
new file mode 100644
index 000000000..d43dd005c
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js
@@ -0,0 +1,77 @@
+import emojify from '..';
+
+describe('emoji', () => {
+ describe('.emojify', () => {
+ it('ignores unknown shortcodes', () => {
+ expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
+ });
+
+ it('ignores shortcodes inside of tags', () => {
+ expect(emojify('
')).toEqual('
');
+ });
+
+ it('works with unclosed tags', () => {
+ expect(emojify('hello>')).toEqual('hello>');
+ expect(emojify(' {
+ expect(emojify('smile:')).toEqual('smile:');
+ expect(emojify(':smile')).toEqual(':smile');
+ });
+
+ it('does unicode', () => {
+ expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
+ ' ');
+ expect(emojify('👨👩👧👧')).toEqual(
+ ' ');
+ expect(emojify('👩👩👦')).toEqual(' ');
+ expect(emojify('\u2757')).toEqual(
+ ' ');
+ });
+
+ it('does multiple unicode', () => {
+ expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
+ ' ');
+ expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
+ ' ');
+ expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
+ ' ');
+ expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
+ 'foo bar');
+ });
+
+ it('ignores unicode inside of tags', () => {
+ expect(emojify('
')).toEqual('
');
+ });
+
+ it('does multiple emoji properly (issue 5188)', () => {
+ expect(emojify('👌🌈💕')).toEqual(' ');
+ expect(emojify('👌 🌈 💕')).toEqual(' ');
+ });
+
+ it('does an emoji that has no shortcode', () => {
+ expect(emojify('🕉️')).toEqual(' ');
+ });
+
+ it('does an emoji whose filename is irregular', () => {
+ expect(emojify('↙️')).toEqual(' ');
+ });
+
+ it('avoid emojifying on invisible text', () => {
+ expect(emojify('http:// example.com/te st😄 '))
+ .toEqual('http:// example.com/te st😄 ');
+ expect(emojify(':luigi: ', { ':luigi:': { static_url: 'luigi.exe' } }))
+ .toEqual(':luigi: ');
+ });
+
+ it('avoid emojifying on invisible text with nested tags', () => {
+ expect(emojify('😄bar 😴 😇'))
+ .toEqual('😄bar 😴 ');
+ expect(emojify('😄😕 😴 😇'))
+ .toEqual('😄😕 😴 ');
+ expect(emojify('😄 😴 😇'))
+ .toEqual('😄 😴 ');
+ });
+ });
+});
diff --git a/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js b/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js
new file mode 100644
index 000000000..53efa5743
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js
@@ -0,0 +1,130 @@
+import { pick } from 'lodash';
+import { emojiIndex } from 'emoji-mart';
+import { search } from '../emoji_mart_search_light';
+
+const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
+
+describe('emoji_index', () => {
+ it('should give same result for emoji_index_light and emoji-mart', () => {
+ const expected = [
+ {
+ id: 'pineapple',
+ unified: '1f34d',
+ native: '🍍',
+ },
+ ];
+ expect(search('pineapple').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('orders search results correctly', () => {
+ const expected = [
+ {
+ id: 'apple',
+ unified: '1f34e',
+ native: '🍎',
+ },
+ {
+ id: 'pineapple',
+ unified: '1f34d',
+ native: '🍍',
+ },
+ {
+ id: 'green_apple',
+ unified: '1f34f',
+ native: '🍏',
+ },
+ {
+ id: 'iphone',
+ unified: '1f4f1',
+ native: '📱',
+ },
+ ];
+ expect(search('apple').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('handles custom emoji', () => {
+ const custom = [
+ {
+ id: 'mastodon',
+ name: 'mastodon',
+ short_names: ['mastodon'],
+ text: '',
+ emoticons: [],
+ keywords: ['mastodon'],
+ imageUrl: 'http://example.com',
+ custom: true,
+ },
+ ];
+ search('', { custom });
+ emojiIndex.search('', { custom });
+ const expected = [
+ {
+ id: 'mastodon',
+ custom: true,
+ },
+ ];
+ expect(search('masto').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('should filter only emojis we care about, exclude pineapple', () => {
+ const emojisToShowFilter = unified => unified !== '1F34D';
+ expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+ .not.toContain('pineapple');
+ expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+ .not.toContain('pineapple');
+ });
+
+ it('can include/exclude categories', () => {
+ expect(search('flag', { include: ['people'] })).toEqual([]);
+ expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
+ });
+
+ it('does an emoji whose unified name is irregular', () => {
+ const expected = [
+ {
+ 'id': 'water_polo',
+ 'unified': '1f93d',
+ 'native': '🤽',
+ },
+ {
+ 'id': 'man-playing-water-polo',
+ 'unified': '1f93d-200d-2642-fe0f',
+ 'native': '🤽♂️',
+ },
+ {
+ 'id': 'woman-playing-water-polo',
+ 'unified': '1f93d-200d-2640-fe0f',
+ 'native': '🤽♀️',
+ },
+ ];
+ expect(search('polo').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('can search for thinking_face', () => {
+ const expected = [
+ {
+ id: 'thinking_face',
+ unified: '1f914',
+ native: '🤔',
+ },
+ ];
+ expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('can search for woman-facepalming', () => {
+ const expected = [
+ {
+ id: 'woman-facepalming',
+ unified: '1f926-200d-2640-fe0f',
+ native: '🤦♀️',
+ },
+ ];
+ expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
+ });
+});
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_compressed.js b/app/javascript/themes/glitch/util/emoji/emoji_compressed.js
new file mode 100644
index 000000000..e5b834a74
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_compressed.js
@@ -0,0 +1,93 @@
+// @preval
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
+// This file contains the compressed version of the emoji data from
+// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
+// It's designed to be emitted in an array format to take up less space
+// over the wire.
+
+const { unicodeToFilename } = require('./unicode_to_filename');
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const emojiMap = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const { default: emojiMartData } = require('emoji-mart/dist/data');
+
+const excluded = ['®', '©', '™'];
+const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+ shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
+
+const stripModifiers = unicode => {
+ skins.forEach(tone => {
+ unicode = unicode.replace(tone, '');
+ });
+
+ return unicode;
+};
+
+Object.keys(emojiMap).forEach(key => {
+ if (excluded.includes(key)) {
+ delete emojiMap[key];
+ return;
+ }
+
+ const normalizedKey = stripModifiers(key);
+ let shortcode = shortcodeMap[normalizedKey];
+
+ if (!shortcode) {
+ shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+ }
+
+ const filename = emojiMap[key];
+
+ const filenameData = [key];
+
+ if (unicodeToFilename(key) !== filename) {
+ // filename can't be derived using unicodeToFilename
+ filenameData.push(filename);
+ }
+
+ if (typeof shortcode === 'undefined') {
+ emojisWithoutShortCodes.push(filenameData);
+ } else {
+ if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
+ shortCodesToEmojiData[shortcode] = [[]];
+ }
+ shortCodesToEmojiData[shortcode][0].push(filenameData);
+ }
+});
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+ const { native } = emojiIndex.emojis[key];
+ let { short_names, search, unified } = emojiMartData.emojis[key];
+ if (short_names[0] !== key) {
+ throw new Error('The compresser expects the first short_code to be the ' +
+ 'key. It may need to be rewritten if the emoji change such that this ' +
+ 'is no longer the case.');
+ }
+
+ short_names = short_names.slice(1); // first short name can be inferred from the key
+
+ const searchData = [native, short_names, search];
+ if (unicodeToUnifiedName(native) !== unified) {
+ // unified name can't be derived from unicodeToUnifiedName
+ searchData.push(unified);
+ }
+
+ shortCodesToEmojiData[key].push(searchData);
+});
+
+// JSON.parse/stringify is to emulate what @preval is doing and avoid any
+// inconsistent behavior in dev mode
+module.exports = JSON.parse(JSON.stringify([
+ shortCodesToEmojiData,
+ emojiMartData.skins,
+ emojiMartData.categories,
+ emojiMartData.short_names,
+ emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_map.json b/app/javascript/themes/glitch/util/emoji/emoji_map.json
new file mode 100644
index 000000000..13753ba84
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_map.json
@@ -0,0 +1 @@
+{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","👱♀":"1f471-200d-2640-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","👁🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🏳️🌈":"1f3f3-fe0f-200d-1f308","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","👁️🗨️":"1f441-200d-1f5e8","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"}
\ No newline at end of file
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_mart_data_light.js b/app/javascript/themes/glitch/util/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_mart_data_light.js
@@ -0,0 +1,41 @@
+// The output of this module is designed to mimic emoji-mart's
+// "data" object, such that we can use it for a light version of emoji-mart's
+// emojiIndex.search functionality.
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
+
+const emojis = {};
+
+// decompress
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+ let [
+ filenameData, // eslint-disable-line no-unused-vars
+ searchData,
+ ] = shortCodesToEmojiData[shortCode];
+ let [
+ native,
+ short_names,
+ search,
+ unified,
+ ] = searchData;
+
+ if (!unified) {
+ // unified name can be derived from unicodeToUnifiedName
+ unified = unicodeToUnifiedName(native);
+ }
+
+ short_names = [shortCode].concat(short_names);
+ emojis[shortCode] = {
+ native,
+ search,
+ short_names,
+ unified,
+ };
+});
+
+module.exports = {
+ emojis,
+ skins,
+ categories,
+ short_names,
+};
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_mart_search_light.js b/app/javascript/themes/glitch/util/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..5755bf1c4
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_mart_search_light.js
@@ -0,0 +1,157 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
+
+import data from './emoji_mart_data_light';
+import { getData, getSanitizedData, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+
+for (let emoji in data.emojis) {
+ let emojiData = data.emojis[emoji];
+ let { short_names, emoticons } = emojiData;
+ let id = short_names[0];
+
+ if (emoticons) {
+ emoticons.forEach(emoticon => {
+ if (emoticonsList[emoticon]) {
+ return;
+ }
+
+ emoticonsList[emoticon] = id;
+ });
+ }
+
+ emojisList[id] = getSanitizedData(id);
+ originalPool[id] = emojiData;
+}
+
+function addCustomToPool(custom, pool) {
+ custom.forEach((emoji) => {
+ let emojiId = emoji.id || emoji.short_names[0];
+
+ if (emojiId && !pool[emojiId]) {
+ pool[emojiId] = getData(emoji);
+ emojisList[emojiId] = getSanitizedData(emoji);
+ }
+ });
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
+ addCustomToPool(custom, originalPool);
+
+ maxResults = maxResults || 75;
+ include = include || [];
+ exclude = exclude || [];
+
+ let results = null,
+ pool = originalPool;
+
+ if (value.length) {
+ if (value === '-' || value === '-1') {
+ return [emojisList['-1']];
+ }
+
+ let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
+ allResults = [];
+
+ if (values.length > 2) {
+ values = [values[0], values[1]];
+ }
+
+ if (include.length || exclude.length) {
+ pool = {};
+
+ data.categories.forEach(category => {
+ let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
+ let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
+ if (!isIncluded || isExcluded) {
+ return;
+ }
+
+ category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
+ });
+
+ if (custom.length) {
+ let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
+ let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
+ if (customIsIncluded && !customIsExcluded) {
+ addCustomToPool(custom, pool);
+ }
+ }
+ }
+
+ allResults = values.map((value) => {
+ let aPool = pool,
+ aIndex = index,
+ length = 0;
+
+ for (let charIndex = 0; charIndex < value.length; charIndex++) {
+ const char = value[charIndex];
+ length++;
+
+ aIndex[char] = aIndex[char] || {};
+ aIndex = aIndex[char];
+
+ if (!aIndex.results) {
+ let scores = {};
+
+ aIndex.results = [];
+ aIndex.pool = {};
+
+ for (let id in aPool) {
+ let emoji = aPool[id],
+ { search } = emoji,
+ sub = value.substr(0, length),
+ subIndex = search.indexOf(sub);
+
+ if (subIndex !== -1) {
+ let score = subIndex + 1;
+ if (sub === id) score = 0;
+
+ aIndex.results.push(emojisList[id]);
+ aIndex.pool[id] = emoji;
+
+ scores[id] = score;
+ }
+ }
+
+ aIndex.results.sort((a, b) => {
+ let aScore = scores[a.id],
+ bScore = scores[b.id];
+
+ return aScore - bScore;
+ });
+ }
+
+ aPool = aIndex.pool;
+ }
+
+ return aIndex.results;
+ }).filter(a => a);
+
+ if (allResults.length > 1) {
+ results = intersect.apply(null, allResults);
+ } else if (allResults.length) {
+ results = allResults[0];
+ } else {
+ results = [];
+ }
+ }
+
+ if (results) {
+ if (emojisToShowFilter) {
+ results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
+ }
+
+ if (results && results.length > maxResults) {
+ results = results.slice(0, maxResults);
+ }
+ }
+
+ return results;
+}
+
+export { search };
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_picker.js b/app/javascript/themes/glitch/util/emoji/emoji_picker.js
new file mode 100644
index 000000000..7e145381e
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji';
+
+export {
+ Picker,
+ Emoji,
+};
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_unicode_mapping_light.js b/app/javascript/themes/glitch/util/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_unicode_mapping_light.js
@@ -0,0 +1,35 @@
+// A mapping of unicode strings to an object containing the filename
+// (i.e. the svg filename) and a shortCode intended to be shown
+// as a "title" attribute in an HTML element (aka tooltip).
+
+const [
+ shortCodesToEmojiData,
+ skins, // eslint-disable-line no-unused-vars
+ categories, // eslint-disable-line no-unused-vars
+ short_names, // eslint-disable-line no-unused-vars
+ emojisWithoutShortCodes,
+] = require('./emoji_compressed');
+const { unicodeToFilename } = require('./unicode_to_filename');
+
+// decompress
+const unicodeMapping = {};
+
+function processEmojiMapData(emojiMapData, shortCode) {
+ let [ native, filename ] = emojiMapData;
+ if (!filename) {
+ // filename name can be derived from unicodeToFilename
+ filename = unicodeToFilename(native);
+ }
+ unicodeMapping[native] = {
+ shortCode: shortCode,
+ filename: filename,
+ };
+}
+
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+ let [ filenameData ] = shortCodesToEmojiData[shortCode];
+ filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
+});
+emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
+
+module.exports = unicodeMapping;
diff --git a/app/javascript/themes/glitch/util/emoji/emoji_utils.js b/app/javascript/themes/glitch/util/emoji/emoji_utils.js
new file mode 100644
index 000000000..dbf725c1f
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/emoji_utils.js
@@ -0,0 +1,258 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
+
+import data from './emoji_mart_data_light';
+
+const buildSearch = (data) => {
+ const search = [];
+
+ let addToSearch = (strings, split) => {
+ if (!strings) {
+ return;
+ }
+
+ (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
+ (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
+ s = s.toLowerCase();
+
+ if (search.indexOf(s) === -1) {
+ search.push(s);
+ }
+ });
+ });
+ };
+
+ addToSearch(data.short_names, true);
+ addToSearch(data.name, true);
+ addToSearch(data.keywords, false);
+ addToSearch(data.emoticons, false);
+
+ return search.join(',');
+};
+
+const _String = String;
+
+const stringFromCodePoint = _String.fromCodePoint || function () {
+ let MAX_SIZE = 0x4000;
+ let codeUnits = [];
+ let highSurrogate;
+ let lowSurrogate;
+ let index = -1;
+ let length = arguments.length;
+ if (!length) {
+ return '';
+ }
+ let result = '';
+ while (++index < length) {
+ let codePoint = Number(arguments[index]);
+ if (
+ !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
+ codePoint < 0 || // not a valid Unicode code point
+ codePoint > 0x10FFFF || // not a valid Unicode code point
+ Math.floor(codePoint) !== codePoint // not an integer
+ ) {
+ throw RangeError('Invalid code point: ' + codePoint);
+ }
+ if (codePoint <= 0xFFFF) { // BMP code point
+ codeUnits.push(codePoint);
+ } else { // Astral code point; split in surrogate halves
+ // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+ codePoint -= 0x10000;
+ highSurrogate = (codePoint >> 10) + 0xD800;
+ lowSurrogate = (codePoint % 0x400) + 0xDC00;
+ codeUnits.push(highSurrogate, lowSurrogate);
+ }
+ if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+ result += String.fromCharCode.apply(null, codeUnits);
+ codeUnits.length = 0;
+ }
+ }
+ return result;
+};
+
+
+const _JSON = JSON;
+
+const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
+const SKINS = [
+ '1F3FA', '1F3FB', '1F3FC',
+ '1F3FD', '1F3FE', '1F3FF',
+];
+
+function unifiedToNative(unified) {
+ let unicodes = unified.split('-'),
+ codePoints = unicodes.map((u) => `0x${u}`);
+
+ return stringFromCodePoint.apply(null, codePoints);
+}
+
+function sanitize(emoji) {
+ let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
+ id = emoji.id || short_names[0],
+ colons = `:${id}:`;
+
+ if (custom) {
+ return {
+ id,
+ name,
+ colons,
+ emoticons,
+ custom,
+ imageUrl,
+ };
+ }
+
+ if (skin_tone) {
+ colons += `:skin-tone-${skin_tone}:`;
+ }
+
+ return {
+ id,
+ name,
+ colons,
+ emoticons,
+ unified: unified.toLowerCase(),
+ skin: skin_tone || (skin_variations ? 1 : null),
+ native: unifiedToNative(unified),
+ };
+}
+
+function getSanitizedData() {
+ return sanitize(getData(...arguments));
+}
+
+function getData(emoji, skin, set) {
+ let emojiData = {};
+
+ if (typeof emoji === 'string') {
+ let matches = emoji.match(COLONS_REGEX);
+
+ if (matches) {
+ emoji = matches[1];
+
+ if (matches[2]) {
+ skin = parseInt(matches[2]);
+ }
+ }
+
+ if (data.short_names.hasOwnProperty(emoji)) {
+ emoji = data.short_names[emoji];
+ }
+
+ if (data.emojis.hasOwnProperty(emoji)) {
+ emojiData = data.emojis[emoji];
+ }
+ } else if (emoji.id) {
+ if (data.short_names.hasOwnProperty(emoji.id)) {
+ emoji.id = data.short_names[emoji.id];
+ }
+
+ if (data.emojis.hasOwnProperty(emoji.id)) {
+ emojiData = data.emojis[emoji.id];
+ skin = skin || emoji.skin;
+ }
+ }
+
+ if (!Object.keys(emojiData).length) {
+ emojiData = emoji;
+ emojiData.custom = true;
+
+ if (!emojiData.search) {
+ emojiData.search = buildSearch(emoji);
+ }
+ }
+
+ emojiData.emoticons = emojiData.emoticons || [];
+ emojiData.variations = emojiData.variations || [];
+
+ if (emojiData.skin_variations && skin > 1 && set) {
+ emojiData = JSON.parse(_JSON.stringify(emojiData));
+
+ let skinKey = SKINS[skin - 1],
+ variationData = emojiData.skin_variations[skinKey];
+
+ if (!variationData.variations && emojiData.variations) {
+ delete emojiData.variations;
+ }
+
+ if (variationData[`has_img_${set}`]) {
+ emojiData.skin_tone = skin;
+
+ for (let k in variationData) {
+ let v = variationData[k];
+ emojiData[k] = v;
+ }
+ }
+ }
+
+ if (emojiData.variations && emojiData.variations.length) {
+ emojiData = JSON.parse(_JSON.stringify(emojiData));
+ emojiData.unified = emojiData.variations.shift();
+ }
+
+ return emojiData;
+}
+
+function uniq(arr) {
+ return arr.reduce((acc, item) => {
+ if (acc.indexOf(item) === -1) {
+ acc.push(item);
+ }
+ return acc;
+ }, []);
+}
+
+function intersect(a, b) {
+ const uniqA = uniq(a);
+ const uniqB = uniq(b);
+
+ return uniqA.filter(item => uniqB.indexOf(item) >= 0);
+}
+
+function deepMerge(a, b) {
+ let o = {};
+
+ for (let key in a) {
+ let originalValue = a[key],
+ value = originalValue;
+
+ if (b.hasOwnProperty(key)) {
+ value = b[key];
+ }
+
+ if (typeof value === 'object') {
+ value = deepMerge(originalValue, value);
+ }
+
+ o[key] = value;
+ }
+
+ return o;
+}
+
+// https://github.com/sonicdoe/measure-scrollbar
+function measureScrollbar() {
+ const div = document.createElement('div');
+
+ div.style.width = '100px';
+ div.style.height = '100px';
+ div.style.overflow = 'scroll';
+ div.style.position = 'absolute';
+ div.style.top = '-9999px';
+
+ document.body.appendChild(div);
+ const scrollbarWidth = div.offsetWidth - div.clientWidth;
+ document.body.removeChild(div);
+
+ return scrollbarWidth;
+}
+
+export {
+ getData,
+ getSanitizedData,
+ uniq,
+ intersect,
+ deepMerge,
+ unifiedToNative,
+ measureScrollbar,
+};
diff --git a/app/javascript/themes/glitch/util/emoji/index.js b/app/javascript/themes/glitch/util/emoji/index.js
new file mode 100644
index 000000000..8c45a58fe
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/index.js
@@ -0,0 +1,95 @@
+import { autoPlayGif } from 'themes/glitch/util/initial_state';
+import unicodeMapping from './emoji_unicode_mapping_light';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+const assetHost = process.env.CDN_HOST || '';
+
+const emojify = (str, customEmojis = {}) => {
+ const tagCharsWithoutEmojis = '<&';
+ const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
+ let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
+ for (;;) {
+ let match, i = 0, tag;
+ while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
+ i += str.codePointAt(i) < 65536 ? 1 : 2;
+ }
+ let rend, replacement = '';
+ if (i === str.length) {
+ break;
+ } else if (str[i] === ':') {
+ if (!(() => {
+ rend = str.indexOf(':', i + 1) + 1;
+ if (!rend) return false; // no pair of ':'
+ const lt = str.indexOf('<', i + 1);
+ if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
+ const shortname = str.slice(i, rend);
+ // now got a replacee as ':shortname:'
+ // if you want additional emoji handler, add statements below which set replacement and return true.
+ if (shortname in customEmojis) {
+ const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
+ replacement = ` `;
+ return true;
+ }
+ return false;
+ })()) rend = ++i;
+ } else if (tag >= 0) { // <, &
+ rend = str.indexOf('>;'[tag], i + 1) + 1;
+ if (!rend) {
+ break;
+ }
+ if (tag === 0) {
+ if (invisible) {
+ if (str[i + 1] === '/') { // closing tag
+ if (!--invisible) {
+ tagChars = tagCharsWithEmojis;
+ }
+ } else if (str[rend - 2] !== '/') { // opening tag
+ invisible++;
+ }
+ } else {
+ if (str.startsWith('', i)) {
+ // avoid emojifying on invisible text
+ invisible = 1;
+ tagChars = tagCharsWithoutEmojis;
+ }
+ }
+ }
+ i = rend;
+ } else { // matched to unicode emoji
+ const { filename, shortCode } = unicodeMapping[match];
+ const title = shortCode ? `:${shortCode}:` : '';
+ replacement = ` `;
+ rend = i + match.length;
+ }
+ rtn += str.slice(0, i) + replacement;
+ str = str.slice(rend);
+ }
+ return rtn + str;
+};
+
+export default emojify;
+
+export const buildCustomEmojis = (customEmojis) => {
+ const emojis = [];
+
+ customEmojis.forEach(emoji => {
+ const shortcode = emoji.get('shortcode');
+ const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
+ const name = shortcode.replace(':', '');
+
+ emojis.push({
+ id: name,
+ name,
+ short_names: [name],
+ text: '',
+ emoticons: [],
+ keywords: [name],
+ imageUrl: url,
+ custom: true,
+ });
+ });
+
+ return emojis;
+};
diff --git a/app/javascript/themes/glitch/util/emoji/unicode_to_filename.js b/app/javascript/themes/glitch/util/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/unicode_to_filename.js
@@ -0,0 +1,26 @@
+// taken from:
+// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
+exports.unicodeToFilename = (str) => {
+ let result = '';
+ let charCode = 0;
+ let p = 0;
+ let i = 0;
+ while (i < str.length) {
+ charCode = str.charCodeAt(i++);
+ if (p) {
+ if (result.length > 0) {
+ result += '-';
+ }
+ result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
+ p = 0;
+ } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
+ p = charCode;
+ } else {
+ if (result.length > 0) {
+ result += '-';
+ }
+ result += charCode.toString(16);
+ }
+ }
+ return result;
+};
diff --git a/app/javascript/themes/glitch/util/emoji/unicode_to_unified_name.js b/app/javascript/themes/glitch/util/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..808ac197e
--- /dev/null
+++ b/app/javascript/themes/glitch/util/emoji/unicode_to_unified_name.js
@@ -0,0 +1,17 @@
+function padLeft(str, num) {
+ while (str.length < num) {
+ str = '0' + str;
+ }
+ return str;
+}
+
+exports.unicodeToUnifiedName = (str) => {
+ let output = '';
+ for (let i = 0; i < str.length; i += 2) {
+ if (i > 0) {
+ output += '-';
+ }
+ output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
+ }
+ return output;
+};
diff --git a/app/javascript/themes/glitch/util/extra_polyfills.js b/app/javascript/themes/glitch/util/extra_polyfills.js
new file mode 100644
index 000000000..3acc55abd
--- /dev/null
+++ b/app/javascript/themes/glitch/util/extra_polyfills.js
@@ -0,0 +1,5 @@
+import 'intersection-observer';
+import 'requestidlecallback';
+import objectFitImages from 'object-fit-images';
+
+objectFitImages();
diff --git a/app/javascript/themes/glitch/util/fullscreen.js b/app/javascript/themes/glitch/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/themes/glitch/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+ document.webkitFullscreenElement ||
+ document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ }
+};
+
+export const requestFullscreen = el => {
+ if (el.requestFullscreen) {
+ el.requestFullscreen();
+ } else if (el.webkitRequestFullscreen) {
+ el.webkitRequestFullscreen();
+ } else if (el.mozRequestFullScreen) {
+ el.mozRequestFullScreen();
+ }
+};
+
+export const attachFullscreenListener = (listener) => {
+ if ('onfullscreenchange' in document) {
+ document.addEventListener('fullscreenchange', listener);
+ } else if ('onwebkitfullscreenchange' in document) {
+ document.addEventListener('webkitfullscreenchange', listener);
+ } else if ('onmozfullscreenchange' in document) {
+ document.addEventListener('mozfullscreenchange', listener);
+ }
+};
+
+export const detachFullscreenListener = (listener) => {
+ if ('onfullscreenchange' in document) {
+ document.removeEventListener('fullscreenchange', listener);
+ } else if ('onwebkitfullscreenchange' in document) {
+ document.removeEventListener('webkitfullscreenchange', listener);
+ } else if ('onmozfullscreenchange' in document) {
+ document.removeEventListener('mozfullscreenchange', listener);
+ }
+};
diff --git a/app/javascript/themes/glitch/util/get_rect_from_entry.js b/app/javascript/themes/glitch/util/get_rect_from_entry.js
new file mode 100644
index 000000000..c266cd7dc
--- /dev/null
+++ b/app/javascript/themes/glitch/util/get_rect_from_entry.js
@@ -0,0 +1,21 @@
+
+// Get the bounding client rect from an IntersectionObserver entry.
+// This is to work around a bug in Chrome: https://crbug.com/737228
+
+let hasBoundingRectBug;
+
+function getRectFromEntry(entry) {
+ if (typeof hasBoundingRectBug !== 'boolean') {
+ const boundingRect = entry.target.getBoundingClientRect();
+ const observerRect = entry.boundingClientRect;
+ hasBoundingRectBug = boundingRect.height !== observerRect.height ||
+ boundingRect.top !== observerRect.top ||
+ boundingRect.width !== observerRect.width ||
+ boundingRect.bottom !== observerRect.bottom ||
+ boundingRect.left !== observerRect.left ||
+ boundingRect.right !== observerRect.right;
+ }
+ return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
+}
+
+export default getRectFromEntry;
diff --git a/app/javascript/themes/glitch/util/initial_state.js b/app/javascript/themes/glitch/util/initial_state.js
new file mode 100644
index 000000000..ef5d8b0ef
--- /dev/null
+++ b/app/javascript/themes/glitch/util/initial_state.js
@@ -0,0 +1,21 @@
+const element = document.getElementById('initial-state');
+const initialState = element && function () {
+ const result = JSON.parse(element.textContent);
+ try {
+ result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
+ } catch (e) {
+ result.local_settings = {};
+ }
+ return result;
+}();
+
+const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
+
+export const reduceMotion = getMeta('reduce_motion');
+export const autoPlayGif = getMeta('auto_play_gif');
+export const unfollowModal = getMeta('unfollow_modal');
+export const boostModal = getMeta('boost_modal');
+export const deleteModal = getMeta('delete_modal');
+export const me = getMeta('me');
+
+export default initialState;
diff --git a/app/javascript/themes/glitch/util/intersection_observer_wrapper.js b/app/javascript/themes/glitch/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..2b24c6583
--- /dev/null
+++ b/app/javascript/themes/glitch/util/intersection_observer_wrapper.js
@@ -0,0 +1,57 @@
+// Wrapper for IntersectionObserver in order to make working with it
+// a bit easier. We also follow this performance advice:
+// "If you need to observe multiple elements, it is both possible and
+// advised to observe multiple elements using the same IntersectionObserver
+// instance by calling observe() multiple times."
+// https://developers.google.com/web/updates/2016/04/intersectionobserver
+
+class IntersectionObserverWrapper {
+
+ callbacks = {};
+ observerBacklog = [];
+ observer = null;
+
+ connect (options) {
+ const onIntersection = (entries) => {
+ entries.forEach(entry => {
+ const id = entry.target.getAttribute('data-id');
+ if (this.callbacks[id]) {
+ this.callbacks[id](entry);
+ }
+ });
+ };
+
+ this.observer = new IntersectionObserver(onIntersection, options);
+ this.observerBacklog.forEach(([ id, node, callback ]) => {
+ this.observe(id, node, callback);
+ });
+ this.observerBacklog = null;
+ }
+
+ observe (id, node, callback) {
+ if (!this.observer) {
+ this.observerBacklog.push([ id, node, callback ]);
+ } else {
+ this.callbacks[id] = callback;
+ this.observer.observe(node);
+ }
+ }
+
+ unobserve (id, node) {
+ if (this.observer) {
+ delete this.callbacks[id];
+ this.observer.unobserve(node);
+ }
+ }
+
+ disconnect () {
+ if (this.observer) {
+ this.callbacks = {};
+ this.observer.disconnect();
+ this.observer = null;
+ }
+ }
+
+}
+
+export default IntersectionObserverWrapper;
diff --git a/app/javascript/themes/glitch/util/is_mobile.js b/app/javascript/themes/glitch/util/is_mobile.js
new file mode 100644
index 000000000..80e8e0a8a
--- /dev/null
+++ b/app/javascript/themes/glitch/util/is_mobile.js
@@ -0,0 +1,34 @@
+import detectPassiveEvents from 'detect-passive-events';
+
+const LAYOUT_BREAKPOINT = 630;
+
+export function isMobile(width, columns) {
+ switch (columns) {
+ case 'multiple':
+ return false;
+ case 'single':
+ return true;
+ default:
+ return width <= LAYOUT_BREAKPOINT;
+ }
+};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+let userTouching = false;
+let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+function touchListener() {
+ userTouching = true;
+ window.removeEventListener('touchstart', touchListener, listenerOptions);
+}
+
+window.addEventListener('touchstart', touchListener, listenerOptions);
+
+export function isUserTouching() {
+ return userTouching;
+}
+
+export function isIOS() {
+ return iOS;
+};
diff --git a/app/javascript/themes/glitch/util/link_header.js b/app/javascript/themes/glitch/util/link_header.js
new file mode 100644
index 000000000..a3e7ccf1c
--- /dev/null
+++ b/app/javascript/themes/glitch/util/link_header.js
@@ -0,0 +1,33 @@
+import Link from 'http-link-header';
+import querystring from 'querystring';
+
+Link.parseAttrs = (link, parts) => {
+ let match = null;
+ let attr = '';
+ let value = '';
+ let attrs = '';
+
+ let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts);
+
+ if(uriAttrs) {
+ attrs = uriAttrs[2];
+ link = Link.parseParams(link, uriAttrs[1]);
+ }
+
+ while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
+ attr = match[1].toLowerCase();
+ value = match[4] || match[3] || match[2];
+
+ if( /\*$/.test(attr)) {
+ Link.setAttr(link, attr, Link.parseExtendedValue(value));
+ } else if(/%/.test(value)) {
+ Link.setAttr(link, attr, querystring.decode(value));
+ } else {
+ Link.setAttr(link, attr, value);
+ }
+ }
+
+ return link;
+};
+
+export default Link;
diff --git a/app/javascript/themes/glitch/util/load_polyfills.js b/app/javascript/themes/glitch/util/load_polyfills.js
new file mode 100644
index 000000000..8927b7358
--- /dev/null
+++ b/app/javascript/themes/glitch/util/load_polyfills.js
@@ -0,0 +1,39 @@
+// Convenience function to load polyfills and return a promise when it's done.
+// If there are no polyfills, then this is just Promise.resolve() which means
+// it will execute in the same tick of the event loop (i.e. near-instant).
+
+function importBasePolyfills() {
+ return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
+}
+
+function importExtraPolyfills() {
+ return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
+}
+
+function loadPolyfills() {
+ const needsBasePolyfills = !(
+ window.Intl &&
+ Object.assign &&
+ Number.isNaN &&
+ window.Symbol &&
+ Array.prototype.includes
+ );
+
+ // Latest version of Firefox and Safari do not have IntersectionObserver.
+ // Edge does not have requestIdleCallback and object-fit CSS property.
+ // This avoids shipping them all the polyfills.
+ const needsExtraPolyfills = !(
+ window.IntersectionObserver &&
+ window.IntersectionObserverEntry &&
+ 'isIntersecting' in IntersectionObserverEntry.prototype &&
+ window.requestIdleCallback &&
+ 'object-fit' in (new Image()).style
+ );
+
+ return Promise.all([
+ needsBasePolyfills && importBasePolyfills(),
+ needsExtraPolyfills && importExtraPolyfills(),
+ ]);
+}
+
+export default loadPolyfills;
diff --git a/app/javascript/themes/glitch/util/main.js b/app/javascript/themes/glitch/util/main.js
new file mode 100644
index 000000000..c10a64ded
--- /dev/null
+++ b/app/javascript/themes/glitch/util/main.js
@@ -0,0 +1,39 @@
+import * as WebPushSubscription from './web_push_subscription';
+import Mastodon from 'themes/glitch/containers/mastodon';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ready from './ready';
+
+const perf = require('./performance');
+
+function main() {
+ perf.start('main()');
+
+ if (window.history && history.replaceState) {
+ const { pathname, search, hash } = window.location;
+ const path = pathname + search + hash;
+ if (!(/^\/web[$/]/).test(path)) {
+ history.replaceState(null, document.title, `/web${path}`);
+ }
+ }
+
+ ready(() => {
+ const mountNode = document.getElementById('mastodon');
+ const props = JSON.parse(mountNode.getAttribute('data-props'));
+
+ ReactDOM.render( , mountNode);
+ if (process.env.NODE_ENV === 'production') {
+ // avoid offline in dev mode because it's harder to debug
+ require('offline-plugin/runtime').install();
+ WebPushSubscription.register();
+ }
+ perf.stop('main()');
+
+ // remember the initial URL
+ if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') {
+ window._mastoInitialHistoryLen = window.history.length;
+ }
+ });
+}
+
+export default main;
diff --git a/app/javascript/themes/glitch/util/optional_motion.js b/app/javascript/themes/glitch/util/optional_motion.js
new file mode 100644
index 000000000..b8a57b22f
--- /dev/null
+++ b/app/javascript/themes/glitch/util/optional_motion.js
@@ -0,0 +1,5 @@
+import { reduceMotion } from 'themes/glitch/util/initial_state';
+import ReducedMotion from './reduced_motion';
+import Motion from 'react-motion/lib/Motion';
+
+export default reduceMotion ? ReducedMotion : Motion;
diff --git a/app/javascript/themes/glitch/util/performance.js b/app/javascript/themes/glitch/util/performance.js
new file mode 100644
index 000000000..450a90626
--- /dev/null
+++ b/app/javascript/themes/glitch/util/performance.js
@@ -0,0 +1,31 @@
+//
+// Tools for performance debugging, only enabled in development mode.
+// Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
+// Also see config/webpack/loaders/mark.js for the webpack loader marks.
+//
+
+let marky;
+
+if (process.env.NODE_ENV === 'development') {
+ if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
+ // Increase Firefox's performance entry limit; otherwise it's capped to 150.
+ // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
+ performance.setResourceTimingBufferSize(Infinity);
+ }
+ marky = require('marky');
+ // allows us to easily do e.g. ReactPerf.printWasted() while debugging
+ //window.ReactPerf = require('react-addons-perf');
+ //window.ReactPerf.start();
+}
+
+export function start(name) {
+ if (process.env.NODE_ENV === 'development') {
+ marky.mark(name);
+ }
+}
+
+export function stop(name) {
+ if (process.env.NODE_ENV === 'development') {
+ marky.stop(name);
+ }
+}
diff --git a/app/javascript/themes/glitch/util/react_router_helpers.js b/app/javascript/themes/glitch/util/react_router_helpers.js
new file mode 100644
index 000000000..c02fb5247
--- /dev/null
+++ b/app/javascript/themes/glitch/util/react_router_helpers.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+
+import ColumnLoading from 'themes/glitch/features/ui/components/column_loading';
+import BundleColumnError from 'themes/glitch/features/ui/components/bundle_column_error';
+import BundleContainer from 'themes/glitch/features/ui/containers/bundle_container';
+
+// Small wrapper to pass multiColumn to the route components
+export class WrappedSwitch extends React.PureComponent {
+
+ render () {
+ const { multiColumn, children } = this.props;
+
+ return (
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
+
+ );
+ }
+
+}
+
+WrappedSwitch.propTypes = {
+ multiColumn: PropTypes.bool,
+ children: PropTypes.node,
+};
+
+// Small Wraper to extract the params from the route and pass
+// them to the rendered component, together with the content to
+// be rendered inside (the children)
+export class WrappedRoute extends React.Component {
+
+ static propTypes = {
+ component: PropTypes.func.isRequired,
+ content: PropTypes.node,
+ multiColumn: PropTypes.bool,
+ }
+
+ renderComponent = ({ match }) => {
+ const { component, content, multiColumn } = this.props;
+
+ return (
+
+ {Component => {content} }
+
+ );
+ }
+
+ renderLoading = () => {
+ return ;
+ }
+
+ renderError = (props) => {
+ return ;
+ }
+
+ render () {
+ const { component: Component, content, ...rest } = this.props;
+
+ return ;
+ }
+
+}
diff --git a/app/javascript/themes/glitch/util/ready.js b/app/javascript/themes/glitch/util/ready.js
new file mode 100644
index 000000000..dd543910b
--- /dev/null
+++ b/app/javascript/themes/glitch/util/ready.js
@@ -0,0 +1,7 @@
+export default function ready(loaded) {
+ if (['interactive', 'complete'].includes(document.readyState)) {
+ loaded();
+ } else {
+ document.addEventListener('DOMContentLoaded', loaded);
+ }
+}
diff --git a/app/javascript/themes/glitch/util/reduced_motion.js b/app/javascript/themes/glitch/util/reduced_motion.js
new file mode 100644
index 000000000..95519042b
--- /dev/null
+++ b/app/javascript/themes/glitch/util/reduced_motion.js
@@ -0,0 +1,44 @@
+// Like react-motion's Motion, but reduces all animations to cross-fades
+// for the benefit of users with motion sickness.
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import PropTypes from 'prop-types';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+ // This is either an object with a "val" property or it's a number
+ return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+class ReducedMotion extends React.Component {
+
+ static propTypes = {
+ defaultStyle: PropTypes.object,
+ style: PropTypes.object,
+ children: PropTypes.func,
+ }
+
+ render() {
+
+ const { style, defaultStyle, children } = this.props;
+
+ Object.keys(style).forEach(key => {
+ if (stylesToKeep.includes(key)) {
+ return;
+ }
+ // If it's setting an x or height or scale or some other value, we need
+ // to preserve the end-state value without actually animating it
+ style[key] = defaultStyle[key] = extractValue(style[key]);
+ });
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+export default ReducedMotion;
diff --git a/app/javascript/themes/glitch/util/rtl.js b/app/javascript/themes/glitch/util/rtl.js
new file mode 100644
index 000000000..00870a15d
--- /dev/null
+++ b/app/javascript/themes/glitch/util/rtl.js
@@ -0,0 +1,31 @@
+// U+0590 to U+05FF - Hebrew
+// U+0600 to U+06FF - Arabic
+// U+0700 to U+074F - Syriac
+// U+0750 to U+077F - Arabic Supplement
+// U+0780 to U+07BF - Thaana
+// U+07C0 to U+07FF - N'Ko
+// U+0800 to U+083F - Samaritan
+// U+08A0 to U+08FF - Arabic Extended-A
+// U+FB1D to U+FB4F - Hebrew presentation forms
+// U+FB50 to U+FDFF - Arabic presentation forms A
+// U+FE70 to U+FEFF - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+ if (text.length === 0) {
+ return false;
+ }
+
+ text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
+ text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
+ text = text.replace(/\s+/g, '');
+
+ const matches = text.match(rtlChars);
+
+ if (!matches) {
+ return false;
+ }
+
+ return matches.length / text.length > 0.3;
+};
diff --git a/app/javascript/themes/glitch/util/schedule_idle_task.js b/app/javascript/themes/glitch/util/schedule_idle_task.js
new file mode 100644
index 000000000..b04d4a8ee
--- /dev/null
+++ b/app/javascript/themes/glitch/util/schedule_idle_task.js
@@ -0,0 +1,29 @@
+// Wrapper to call requestIdleCallback() to schedule low-priority work.
+// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
+// for a good breakdown of the concepts behind this.
+
+import Queue from 'tiny-queue';
+
+const taskQueue = new Queue();
+let runningRequestIdleCallback = false;
+
+function runTasks(deadline) {
+ while (taskQueue.length && deadline.timeRemaining() > 0) {
+ taskQueue.shift()();
+ }
+ if (taskQueue.length) {
+ requestIdleCallback(runTasks);
+ } else {
+ runningRequestIdleCallback = false;
+ }
+}
+
+function scheduleIdleTask(task) {
+ taskQueue.push(task);
+ if (!runningRequestIdleCallback) {
+ runningRequestIdleCallback = true;
+ requestIdleCallback(runTasks);
+ }
+}
+
+export default scheduleIdleTask;
diff --git a/app/javascript/themes/glitch/util/scroll.js b/app/javascript/themes/glitch/util/scroll.js
new file mode 100644
index 000000000..2af07e0fb
--- /dev/null
+++ b/app/javascript/themes/glitch/util/scroll.js
@@ -0,0 +1,30 @@
+const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
+
+const scroll = (node, key, target) => {
+ const startTime = Date.now();
+ const offset = node[key];
+ const gap = target - offset;
+ const duration = 1000;
+ let interrupt = false;
+
+ const step = () => {
+ const elapsed = Date.now() - startTime;
+ const percentage = elapsed / duration;
+
+ if (percentage > 1 || interrupt) {
+ return;
+ }
+
+ node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
+ requestAnimationFrame(step);
+ };
+
+ step();
+
+ return () => {
+ interrupt = true;
+ };
+};
+
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/themes/glitch/util/stream.js b/app/javascript/themes/glitch/util/stream.js
new file mode 100644
index 000000000..36c68ffc5
--- /dev/null
+++ b/app/javascript/themes/glitch/util/stream.js
@@ -0,0 +1,73 @@
+import WebSocketClient from 'websocket.js';
+
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+ return (dispatch, getState) => {
+ const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+ const accessToken = getState().getIn(['meta', 'access_token']);
+ const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+ let polling = null;
+
+ const setupPolling = () => {
+ polling = setInterval(() => {
+ pollingRefresh(dispatch);
+ }, 20000);
+ };
+
+ const clearPolling = () => {
+ if (polling) {
+ clearInterval(polling);
+ polling = null;
+ }
+ };
+
+ const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
+ connected () {
+ if (pollingRefresh) {
+ clearPolling();
+ }
+ onConnect();
+ },
+
+ disconnected () {
+ if (pollingRefresh) {
+ setupPolling();
+ }
+ onDisconnect();
+ },
+
+ received (data) {
+ onReceive(data);
+ },
+
+ reconnected () {
+ if (pollingRefresh) {
+ clearPolling();
+ pollingRefresh(dispatch);
+ }
+ onConnect();
+ },
+
+ });
+
+ const disconnect = () => {
+ if (subscription) {
+ subscription.close();
+ }
+ clearPolling();
+ };
+
+ return disconnect;
+ };
+}
+
+
+export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
+ const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+ ws.onopen = connected;
+ ws.onmessage = e => received(JSON.parse(e.data));
+ ws.onclose = disconnected;
+ ws.onreconnect = reconnected;
+
+ return ws;
+};
diff --git a/app/javascript/themes/glitch/util/url_regex.js b/app/javascript/themes/glitch/util/url_regex.js
new file mode 100644
index 000000000..e676d1879
--- /dev/null
+++ b/app/javascript/themes/glitch/util/url_regex.js
@@ -0,0 +1,196 @@
+const regexen = {};
+
+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';
+ }
+
+ 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;
+}());
diff --git a/app/javascript/themes/glitch/util/uuid.js b/app/javascript/themes/glitch/util/uuid.js
new file mode 100644
index 000000000..be1899305
--- /dev/null
+++ b/app/javascript/themes/glitch/util/uuid.js
@@ -0,0 +1,3 @@
+export default function uuid(a) {
+ return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
+};
diff --git a/app/javascript/themes/glitch/util/web_push_subscription.js b/app/javascript/themes/glitch/util/web_push_subscription.js
new file mode 100644
index 000000000..70b26105b
--- /dev/null
+++ b/app/javascript/themes/glitch/util/web_push_subscription.js
@@ -0,0 +1,105 @@
+import axios from 'axios';
+import { store } from 'themes/glitch/containers/mastodon';
+import { setBrowserSupport, setSubscription, clearSubscription } from 'themes/glitch/actions/push_notifications';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/\-/g, '+')
+ .replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+ registration.pushManager.getSubscription()
+ .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+ });
+
+const unsubscribe = ({ registration, subscription }) =>
+ subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (subscription) =>
+ axios.post('/api/web/push_subscriptions', {
+ subscription,
+ }).then(response => response.data);
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+ store.dispatch(setBrowserSupport(supportsPushNotifications));
+
+ if (supportsPushNotifications) {
+ if (!getApplicationServerKey()) {
+ console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+ return;
+ }
+
+ getRegistration()
+ .then(getPushSubscription)
+ .then(({ registration, subscription }) => {
+ if (subscription !== null) {
+ // We have a subscription, check if it is still valid
+ const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+ const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+ const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+ // If the VAPID public key did not change and the endpoint corresponds
+ // to the endpoint saved in the backend, the subscription is valid
+ if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+ return subscription;
+ } else {
+ // Something went wrong, try to subscribe again
+ return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
+ }
+ }
+
+ // No subscription, try to subscribe
+ return subscribe(registration).then(sendSubscriptionToBackend);
+ })
+ .then(subscription => {
+ // If we got a PushSubscription (and not a subscription object from the backend)
+ // it means that the backend subscription is valid (and was set during hydration)
+ if (!(subscription instanceof PushSubscription)) {
+ store.dispatch(setSubscription(subscription));
+ }
+ })
+ .catch(error => {
+ if (error.code === 20 && error.name === 'AbortError') {
+ console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+ } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+ console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+ }
+
+ // Clear alerts and hide UI settings
+ store.dispatch(clearSubscription());
+
+ try {
+ getRegistration()
+ .then(getPushSubscription)
+ .then(unsubscribe);
+ } catch (e) {
+
+ }
+ });
+ } else {
+ console.warn('Your browser does not support Web Push Notifications.');
+ }
+}
diff --git a/app/javascript/themes/vanilla/theme.yml b/app/javascript/themes/vanilla/theme.yml
new file mode 100644
index 000000000..120c4b669
--- /dev/null
+++ b/app/javascript/themes/vanilla/theme.yml
@@ -0,0 +1,18 @@
+# (REQUIRED) The location of the pack file inside `pack_directory`.
+#pack: application.js
+
+# (OPTIONAL) The directory which contains the pack file.
+# Defaults to the theme directory (`app/javascript/themes/[theme]`),
+# but in the case of the vanilla Mastodon theme the pack file is
+# somewhere else.
+pack_directory: app/javascript/packs
+
+# (OPTIONAL) Additional javascript resources to preload, for use with
+# lazy-loaded components. It is **STRONGLY RECOMMENDED** that you
+# derive these pathnames from `themes/[your-theme]` to ensure that
+# they stay unique. (Of course, vanilla doesn't do this ^^;;)
+preload:
+- features/getting_started
+- features/compose
+- features/home_timeline
+- features/notifications
--
cgit
From e19fc6a9f81e3756e0198006d2eafbc2f3acadb5 Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Fri, 17 Nov 2017 19:16:35 -0800
Subject: Restore vanilla components
---
app/javascript/mastodon/actions/accounts.js | 659 +++
app/javascript/mastodon/actions/alerts.js | 24 +
app/javascript/mastodon/actions/blocks.js | 82 +
app/javascript/mastodon/actions/bundles.js | 25 +
app/javascript/mastodon/actions/cards.js | 52 +
app/javascript/mastodon/actions/columns.js | 40 +
app/javascript/mastodon/actions/compose.js | 376 ++
app/javascript/mastodon/actions/domain_blocks.js | 117 +
app/javascript/mastodon/actions/emojis.js | 14 +
app/javascript/mastodon/actions/favourites.js | 83 +
app/javascript/mastodon/actions/height_cache.js | 17 +
app/javascript/mastodon/actions/interactions.js | 313 ++
app/javascript/mastodon/actions/modal.js | 16 +
app/javascript/mastodon/actions/mutes.js | 103 +
app/javascript/mastodon/actions/notifications.js | 190 +
app/javascript/mastodon/actions/onboarding.js | 14 +
app/javascript/mastodon/actions/pin_statuses.js | 40 +
.../mastodon/actions/push_notifications.js | 52 +
app/javascript/mastodon/actions/reports.js | 80 +
app/javascript/mastodon/actions/search.js | 73 +
app/javascript/mastodon/actions/settings.js | 31 +
app/javascript/mastodon/actions/statuses.js | 217 +
app/javascript/mastodon/actions/store.js | 17 +
app/javascript/mastodon/actions/streaming.js | 53 +
app/javascript/mastodon/actions/timelines.js | 206 +
app/javascript/mastodon/api.js | 26 +
app/javascript/mastodon/base_polyfills.js | 18 +
.../__tests__/__snapshots__/avatar-test.js.snap | 33 +
.../__snapshots__/avatar_overlay-test.js.snap | 24 +
.../__tests__/__snapshots__/button-test.js.snap | 114 +
.../__snapshots__/display_name-test.js.snap | 23 +
.../mastodon/components/__tests__/avatar-test.js | 36 +
.../components/__tests__/avatar_overlay-test.js | 29 +
.../mastodon/components/__tests__/button-test.js | 75 +
.../components/__tests__/display_name-test.js | 18 +
app/javascript/mastodon/components/account.js | 116 +
.../mastodon/components/attachment_list.js | 33 +
.../mastodon/components/autosuggest_emoji.js | 42 +
.../mastodon/components/autosuggest_textarea.js | 222 +
app/javascript/mastodon/components/avatar.js | 71 +
.../mastodon/components/avatar_overlay.js | 30 +
app/javascript/mastodon/components/button.js | 63 +
app/javascript/mastodon/components/collapsable.js | 22 +
app/javascript/mastodon/components/column.js | 52 +
.../mastodon/components/column_back_button.js | 28 +
.../mastodon/components/column_back_button_slim.js | 27 +
.../mastodon/components/column_header.js | 159 +
app/javascript/mastodon/components/display_name.js | 20 +
.../mastodon/components/dropdown_menu.js | 211 +
.../mastodon/components/extended_video_player.js | 54 +
app/javascript/mastodon/components/icon_button.js | 114 +
.../components/intersection_observer_article.js | 130 +
app/javascript/mastodon/components/load_more.js | 26 +
.../mastodon/components/loading_indicator.js | 11 +
.../mastodon/components/media_gallery.js | 278 ++
.../mastodon/components/missing_indicator.js | 12 +
app/javascript/mastodon/components/permalink.js | 34 +
.../mastodon/components/relative_timestamp.js | 147 +
.../mastodon/components/scrollable_list.js | 198 +
app/javascript/mastodon/components/setting_text.js | 34 +
app/javascript/mastodon/components/status.js | 246 ++
.../mastodon/components/status_action_bar.js | 188 +
.../mastodon/components/status_content.js | 185 +
app/javascript/mastodon/components/status_list.js | 72 +
.../mastodon/containers/account_container.js | 72 +
.../mastodon/containers/card_container.js | 18 +
.../mastodon/containers/compose_container.js | 38 +
.../mastodon/containers/dropdown_menu_container.js | 16 +
.../intersection_observer_article_container.js | 17 +
app/javascript/mastodon/containers/mastodon.js | 70 +
.../mastodon/containers/media_gallery_container.js | 34 +
.../mastodon/containers/status_container.js | 133 +
.../mastodon/containers/timeline_container.js | 48 +
.../mastodon/containers/video_container.js | 26 +
app/javascript/mastodon/extra_polyfills.js | 5 +
.../features/account/components/action_bar.js | 133 +
.../mastodon/features/account/components/header.js | 128 +
.../account_gallery/components/media_item.js | 39 +
.../mastodon/features/account_gallery/index.js | 111 +
.../features/account_timeline/components/header.js | 89 +
.../containers/header_container.js | 96 +
.../mastodon/features/account_timeline/index.js | 77 +
app/javascript/mastodon/features/blocks/index.js | 70 +
.../components/column_settings.js | 35 +
.../containers/column_settings_container.js | 17 +
.../mastodon/features/community_timeline/index.js | 107 +
.../compose/components/autosuggest_account.js | 24 +
.../compose/components/character_counter.js | 25 +
.../features/compose/components/compose_form.js | 212 +
.../compose/components/emoji_picker_dropdown.js | 376 ++
.../features/compose/components/navigation_bar.js | 38 +
.../compose/components/privacy_dropdown.js | 200 +
.../features/compose/components/reply_indicator.js | 63 +
.../mastodon/features/compose/components/search.js | 129 +
.../features/compose/components/search_results.js | 65 +
.../compose/components/text_icon_button.js | 29 +
.../mastodon/features/compose/components/upload.js | 96 +
.../features/compose/components/upload_button.js | 77 +
.../features/compose/components/upload_form.js | 29 +
.../features/compose/components/upload_progress.js | 42 +
.../features/compose/components/warning.js | 26 +
.../containers/autosuggest_account_container.js | 15 +
.../compose/containers/compose_form_container.js | 64 +
.../containers/emoji_picker_dropdown_container.js | 82 +
.../compose/containers/navigation_container.js | 11 +
.../containers/privacy_dropdown_container.js | 24 +
.../containers/reply_indicator_container.js | 24 +
.../compose/containers/search_container.js | 35 +
.../compose/containers/search_results_container.js | 8 +
.../containers/sensitive_button_container.js | 71 +
.../compose/containers/spoiler_button_container.js | 25 +
.../compose/containers/upload_button_container.js | 18 +
.../compose/containers/upload_container.js | 21 +
.../compose/containers/upload_form_container.js | 8 +
.../containers/upload_progress_container.js | 9 +
.../compose/containers/warning_container.js | 24 +
app/javascript/mastodon/features/compose/index.js | 111 +
.../mastodon/features/compose/util/counter.js | 9 +
.../mastodon/features/compose/util/url_regex.js | 196 +
.../features/emoji/__tests__/emoji-test.js | 77 +
.../features/emoji/__tests__/emoji_index-test.js | 130 +
app/javascript/mastodon/features/emoji/emoji.js | 95 +
.../mastodon/features/emoji/emoji_compressed.js | 93 +
.../mastodon/features/emoji/emoji_map.json | 1 +
.../features/emoji/emoji_mart_data_light.js | 41 +
.../features/emoji/emoji_mart_search_light.js | 157 +
.../mastodon/features/emoji/emoji_picker.js | 7 +
.../features/emoji/emoji_unicode_mapping_light.js | 35 +
.../mastodon/features/emoji/emoji_utils.js | 258 ++
.../mastodon/features/emoji/unicode_to_filename.js | 26 +
.../features/emoji/unicode_to_unified_name.js | 17 +
.../mastodon/features/favourited_statuses/index.js | 94 +
.../mastodon/features/favourites/index.js | 60 +
.../components/account_authorize.js | 49 +
.../containers/account_authorize_container.js | 26 +
.../mastodon/features/follow_requests/index.js | 71 +
.../mastodon/features/followers/index.js | 93 +
.../mastodon/features/following/index.js | 93 +
.../mastodon/features/generic_not_found/index.js | 11 +
.../mastodon/features/getting_started/index.js | 112 +
.../mastodon/features/hashtag_timeline/index.js | 118 +
.../home_timeline/components/column_settings.js | 46 +
.../containers/column_settings_container.js | 21 +
.../mastodon/features/home_timeline/index.js | 90 +
app/javascript/mastodon/features/mutes/index.js | 70 +
.../components/clear_column_button.js | 17 +
.../notifications/components/column_settings.js | 86 +
.../notifications/components/notification.js | 152 +
.../notifications/components/setting_toggle.js | 34 +
.../containers/column_settings_container.js | 44 +
.../containers/notification_container.js | 22 +
.../mastodon/features/notifications/index.js | 168 +
.../mastodon/features/pinned_statuses/index.js | 59 +
.../containers/column_settings_container.js | 17 +
.../mastodon/features/public_timeline/index.js | 107 +
app/javascript/mastodon/features/reblogs/index.js | 60 +
.../features/report/components/status_check_box.js | 37 +
.../containers/status_check_box_container.js | 19 +
.../mastodon/features/standalone/compose/index.js | 20 +
.../features/standalone/hashtag_timeline/index.js | 70 +
.../features/standalone/public_timeline/index.js | 76 +
.../features/status/components/action_bar.js | 129 +
.../mastodon/features/status/components/card.js | 125 +
.../features/status/components/detailed_status.js | 125 +
.../features/status/containers/card_container.js | 8 +
app/javascript/mastodon/features/status/index.js | 338 ++
.../ui/components/__tests__/column-test.js | 34 +
.../features/ui/components/actions_modal.js | 74 +
.../mastodon/features/ui/components/boost_modal.js | 84 +
.../mastodon/features/ui/components/bundle.js | 102 +
.../features/ui/components/bundle_column_error.js | 44 +
.../features/ui/components/bundle_modal_error.js | 53 +
.../mastodon/features/ui/components/column.js | 72 +
.../features/ui/components/column_header.js | 35 +
.../mastodon/features/ui/components/column_link.js | 32 +
.../features/ui/components/column_loading.js | 30 +
.../features/ui/components/column_subheading.js | 16 +
.../features/ui/components/columns_area.js | 173 +
.../features/ui/components/confirmation_modal.js | 53 +
.../features/ui/components/drawer_loading.js | 11 +
.../mastodon/features/ui/components/embed_modal.js | 84 +
.../features/ui/components/image_loader.js | 152 +
.../mastodon/features/ui/components/media_modal.js | 126 +
.../features/ui/components/modal_loading.js | 20 +
.../mastodon/features/ui/components/modal_root.js | 127 +
.../mastodon/features/ui/components/mute_modal.js | 105 +
.../features/ui/components/onboarding_modal.js | 318 ++
.../features/ui/components/report_modal.js | 105 +
.../mastodon/features/ui/components/tabs_bar.js | 84 +
.../mastodon/features/ui/components/upload_area.js | 52 +
.../mastodon/features/ui/components/video_modal.js | 33 +
.../features/ui/containers/bundle_container.js | 19 +
.../ui/containers/columns_area_container.js | 8 +
.../ui/containers/loading_bar_container.js | 8 +
.../features/ui/containers/modal_container.js | 16 +
.../ui/containers/notifications_container.js | 18 +
.../ui/containers/status_list_container.js | 73 +
app/javascript/mastodon/features/ui/index.js | 407 ++
.../mastodon/features/ui/util/async-components.js | 107 +
.../mastodon/features/ui/util/fullscreen.js | 46 +
.../features/ui/util/get_rect_from_entry.js | 21 +
.../ui/util/intersection_observer_wrapper.js | 57 +
.../mastodon/features/ui/util/optional_motion.js | 5 +
.../features/ui/util/react_router_helpers.js | 64 +
.../mastodon/features/ui/util/reduced_motion.js | 44 +
.../features/ui/util/schedule_idle_task.js | 29 +
app/javascript/mastodon/features/video/index.js | 286 ++
app/javascript/mastodon/initial_state.js | 13 +
app/javascript/mastodon/is_mobile.js | 27 +
app/javascript/mastodon/link_header.js | 33 +
app/javascript/mastodon/load_polyfills.js | 39 +
.../mastodon/locales/defaultMessages.json | 17 -
app/javascript/mastodon/locales/en.json | 3 -
app/javascript/mastodon/main.js | 34 +
app/javascript/mastodon/middleware/errors.js | 31 +
app/javascript/mastodon/middleware/loading_bar.js | 25 +
app/javascript/mastodon/middleware/sounds.js | 46 +
app/javascript/mastodon/performance.js | 31 +
app/javascript/mastodon/ready.js | 7 +
app/javascript/mastodon/reducers/accounts.js | 135 +
.../mastodon/reducers/accounts_counters.js | 135 +
app/javascript/mastodon/reducers/alerts.js | 25 +
app/javascript/mastodon/reducers/cards.js | 14 +
app/javascript/mastodon/reducers/compose.js | 276 ++
app/javascript/mastodon/reducers/contexts.js | 61 +
app/javascript/mastodon/reducers/custom_emojis.js | 16 +
app/javascript/mastodon/reducers/height_cache.js | 23 +
app/javascript/mastodon/reducers/index.js | 52 +
.../mastodon/reducers/media_attachments.js | 15 +
app/javascript/mastodon/reducers/meta.js | 16 +
app/javascript/mastodon/reducers/modal.js | 17 +
app/javascript/mastodon/reducers/mutes.js | 29 +
app/javascript/mastodon/reducers/notifications.js | 124 +
.../mastodon/reducers/push_notifications.js | 51 +
app/javascript/mastodon/reducers/relationships.js | 46 +
app/javascript/mastodon/reducers/reports.js | 60 +
app/javascript/mastodon/reducers/search.js | 42 +
app/javascript/mastodon/reducers/settings.js | 112 +
app/javascript/mastodon/reducers/status_lists.js | 75 +
app/javascript/mastodon/reducers/statuses.js | 148 +
app/javascript/mastodon/reducers/timelines.js | 149 +
app/javascript/mastodon/reducers/user_lists.js | 80 +
app/javascript/mastodon/rtl.js | 31 +
app/javascript/mastodon/scroll.js | 30 +
app/javascript/mastodon/selectors/index.js | 87 +
app/javascript/mastodon/service_worker/entry.js | 10 +
.../service_worker/web_push_notifications.js | 159 +
app/javascript/mastodon/store/configureStore.js | 15 +
app/javascript/mastodon/stream.js | 73 +
app/javascript/mastodon/test_setup.js | 5 +
app/javascript/mastodon/uuid.js | 3 +
app/javascript/mastodon/web_push_subscription.js | 105 +
app/javascript/styles/application.scss | 22 +
app/javascript/styles/mastodon/_mixins.scss | 12 +
app/javascript/styles/mastodon/about.scss | 824 ++++
app/javascript/styles/mastodon/accounts.scss | 549 +++
app/javascript/styles/mastodon/admin.scss | 349 ++
app/javascript/styles/mastodon/basics.scss | 122 +
app/javascript/styles/mastodon/boost.scss | 18 +
app/javascript/styles/mastodon/compact_header.scss | 34 +
app/javascript/styles/mastodon/components.scss | 4377 ++++++++++++++++++++
app/javascript/styles/mastodon/containers.scss | 116 +
app/javascript/styles/mastodon/emoji_picker.scss | 199 +
app/javascript/styles/mastodon/footer.scss | 30 +
app/javascript/styles/mastodon/forms.scss | 540 +++
app/javascript/styles/mastodon/landing_strip.scss | 36 +
app/javascript/styles/mastodon/lists.scss | 19 +
app/javascript/styles/mastodon/reset.scss | 91 +
app/javascript/styles/mastodon/rtl.scss | 254 ++
app/javascript/styles/mastodon/stream_entries.scss | 339 ++
app/javascript/styles/mastodon/tables.scss | 76 +
app/javascript/styles/mastodon/variables.scss | 29 +
272 files changed, 27052 insertions(+), 20 deletions(-)
create mode 100644 app/javascript/mastodon/actions/accounts.js
create mode 100644 app/javascript/mastodon/actions/alerts.js
create mode 100644 app/javascript/mastodon/actions/blocks.js
create mode 100644 app/javascript/mastodon/actions/bundles.js
create mode 100644 app/javascript/mastodon/actions/cards.js
create mode 100644 app/javascript/mastodon/actions/columns.js
create mode 100644 app/javascript/mastodon/actions/compose.js
create mode 100644 app/javascript/mastodon/actions/domain_blocks.js
create mode 100644 app/javascript/mastodon/actions/emojis.js
create mode 100644 app/javascript/mastodon/actions/favourites.js
create mode 100644 app/javascript/mastodon/actions/height_cache.js
create mode 100644 app/javascript/mastodon/actions/interactions.js
create mode 100644 app/javascript/mastodon/actions/modal.js
create mode 100644 app/javascript/mastodon/actions/mutes.js
create mode 100644 app/javascript/mastodon/actions/notifications.js
create mode 100644 app/javascript/mastodon/actions/onboarding.js
create mode 100644 app/javascript/mastodon/actions/pin_statuses.js
create mode 100644 app/javascript/mastodon/actions/push_notifications.js
create mode 100644 app/javascript/mastodon/actions/reports.js
create mode 100644 app/javascript/mastodon/actions/search.js
create mode 100644 app/javascript/mastodon/actions/settings.js
create mode 100644 app/javascript/mastodon/actions/statuses.js
create mode 100644 app/javascript/mastodon/actions/store.js
create mode 100644 app/javascript/mastodon/actions/streaming.js
create mode 100644 app/javascript/mastodon/actions/timelines.js
create mode 100644 app/javascript/mastodon/api.js
create mode 100644 app/javascript/mastodon/base_polyfills.js
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
create mode 100644 app/javascript/mastodon/components/__tests__/avatar-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/button-test.js
create mode 100644 app/javascript/mastodon/components/__tests__/display_name-test.js
create mode 100644 app/javascript/mastodon/components/account.js
create mode 100644 app/javascript/mastodon/components/attachment_list.js
create mode 100644 app/javascript/mastodon/components/autosuggest_emoji.js
create mode 100644 app/javascript/mastodon/components/autosuggest_textarea.js
create mode 100644 app/javascript/mastodon/components/avatar.js
create mode 100644 app/javascript/mastodon/components/avatar_overlay.js
create mode 100644 app/javascript/mastodon/components/button.js
create mode 100644 app/javascript/mastodon/components/collapsable.js
create mode 100644 app/javascript/mastodon/components/column.js
create mode 100644 app/javascript/mastodon/components/column_back_button.js
create mode 100644 app/javascript/mastodon/components/column_back_button_slim.js
create mode 100644 app/javascript/mastodon/components/column_header.js
create mode 100644 app/javascript/mastodon/components/display_name.js
create mode 100644 app/javascript/mastodon/components/dropdown_menu.js
create mode 100644 app/javascript/mastodon/components/extended_video_player.js
create mode 100644 app/javascript/mastodon/components/icon_button.js
create mode 100644 app/javascript/mastodon/components/intersection_observer_article.js
create mode 100644 app/javascript/mastodon/components/load_more.js
create mode 100644 app/javascript/mastodon/components/loading_indicator.js
create mode 100644 app/javascript/mastodon/components/media_gallery.js
create mode 100644 app/javascript/mastodon/components/missing_indicator.js
create mode 100644 app/javascript/mastodon/components/permalink.js
create mode 100644 app/javascript/mastodon/components/relative_timestamp.js
create mode 100644 app/javascript/mastodon/components/scrollable_list.js
create mode 100644 app/javascript/mastodon/components/setting_text.js
create mode 100644 app/javascript/mastodon/components/status.js
create mode 100644 app/javascript/mastodon/components/status_action_bar.js
create mode 100644 app/javascript/mastodon/components/status_content.js
create mode 100644 app/javascript/mastodon/components/status_list.js
create mode 100644 app/javascript/mastodon/containers/account_container.js
create mode 100644 app/javascript/mastodon/containers/card_container.js
create mode 100644 app/javascript/mastodon/containers/compose_container.js
create mode 100644 app/javascript/mastodon/containers/dropdown_menu_container.js
create mode 100644 app/javascript/mastodon/containers/intersection_observer_article_container.js
create mode 100644 app/javascript/mastodon/containers/mastodon.js
create mode 100644 app/javascript/mastodon/containers/media_gallery_container.js
create mode 100644 app/javascript/mastodon/containers/status_container.js
create mode 100644 app/javascript/mastodon/containers/timeline_container.js
create mode 100644 app/javascript/mastodon/containers/video_container.js
create mode 100644 app/javascript/mastodon/extra_polyfills.js
create mode 100644 app/javascript/mastodon/features/account/components/action_bar.js
create mode 100644 app/javascript/mastodon/features/account/components/header.js
create mode 100644 app/javascript/mastodon/features/account_gallery/components/media_item.js
create mode 100644 app/javascript/mastodon/features/account_gallery/index.js
create mode 100644 app/javascript/mastodon/features/account_timeline/components/header.js
create mode 100644 app/javascript/mastodon/features/account_timeline/containers/header_container.js
create mode 100644 app/javascript/mastodon/features/account_timeline/index.js
create mode 100644 app/javascript/mastodon/features/blocks/index.js
create mode 100644 app/javascript/mastodon/features/community_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js
create mode 100644 app/javascript/mastodon/features/community_timeline/index.js
create mode 100644 app/javascript/mastodon/features/compose/components/autosuggest_account.js
create mode 100644 app/javascript/mastodon/features/compose/components/character_counter.js
create mode 100644 app/javascript/mastodon/features/compose/components/compose_form.js
create mode 100644 app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
create mode 100644 app/javascript/mastodon/features/compose/components/navigation_bar.js
create mode 100644 app/javascript/mastodon/features/compose/components/privacy_dropdown.js
create mode 100644 app/javascript/mastodon/features/compose/components/reply_indicator.js
create mode 100644 app/javascript/mastodon/features/compose/components/search.js
create mode 100644 app/javascript/mastodon/features/compose/components/search_results.js
create mode 100644 app/javascript/mastodon/features/compose/components/text_icon_button.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_button.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_form.js
create mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.js
create mode 100644 app/javascript/mastodon/features/compose/components/warning.js
create mode 100644 app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/compose_form_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/navigation_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/search_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/search_results_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/upload_button_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/upload_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/upload_form_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/upload_progress_container.js
create mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.js
create mode 100644 app/javascript/mastodon/features/compose/index.js
create mode 100644 app/javascript/mastodon/features/compose/util/counter.js
create mode 100644 app/javascript/mastodon/features/compose/util/url_regex.js
create mode 100644 app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
create mode 100644 app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_compressed.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_map.json
create mode 100644 app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_picker.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
create mode 100644 app/javascript/mastodon/features/emoji/emoji_utils.js
create mode 100644 app/javascript/mastodon/features/emoji/unicode_to_filename.js
create mode 100644 app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
create mode 100644 app/javascript/mastodon/features/favourited_statuses/index.js
create mode 100644 app/javascript/mastodon/features/favourites/index.js
create mode 100644 app/javascript/mastodon/features/follow_requests/components/account_authorize.js
create mode 100644 app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
create mode 100644 app/javascript/mastodon/features/follow_requests/index.js
create mode 100644 app/javascript/mastodon/features/followers/index.js
create mode 100644 app/javascript/mastodon/features/following/index.js
create mode 100644 app/javascript/mastodon/features/generic_not_found/index.js
create mode 100644 app/javascript/mastodon/features/getting_started/index.js
create mode 100644 app/javascript/mastodon/features/hashtag_timeline/index.js
create mode 100644 app/javascript/mastodon/features/home_timeline/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
create mode 100644 app/javascript/mastodon/features/home_timeline/index.js
create mode 100644 app/javascript/mastodon/features/mutes/index.js
create mode 100644 app/javascript/mastodon/features/notifications/components/clear_column_button.js
create mode 100644 app/javascript/mastodon/features/notifications/components/column_settings.js
create mode 100644 app/javascript/mastodon/features/notifications/components/notification.js
create mode 100644 app/javascript/mastodon/features/notifications/components/setting_toggle.js
create mode 100644 app/javascript/mastodon/features/notifications/containers/column_settings_container.js
create mode 100644 app/javascript/mastodon/features/notifications/containers/notification_container.js
create mode 100644 app/javascript/mastodon/features/notifications/index.js
create mode 100644 app/javascript/mastodon/features/pinned_statuses/index.js
create mode 100644 app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js
create mode 100644 app/javascript/mastodon/features/public_timeline/index.js
create mode 100644 app/javascript/mastodon/features/reblogs/index.js
create mode 100644 app/javascript/mastodon/features/report/components/status_check_box.js
create mode 100644 app/javascript/mastodon/features/report/containers/status_check_box_container.js
create mode 100644 app/javascript/mastodon/features/standalone/compose/index.js
create mode 100644 app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
create mode 100644 app/javascript/mastodon/features/standalone/public_timeline/index.js
create mode 100644 app/javascript/mastodon/features/status/components/action_bar.js
create mode 100644 app/javascript/mastodon/features/status/components/card.js
create mode 100644 app/javascript/mastodon/features/status/components/detailed_status.js
create mode 100644 app/javascript/mastodon/features/status/containers/card_container.js
create mode 100644 app/javascript/mastodon/features/status/index.js
create mode 100644 app/javascript/mastodon/features/ui/components/__tests__/column-test.js
create mode 100644 app/javascript/mastodon/features/ui/components/actions_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/boost_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle_column_error.js
create mode 100644 app/javascript/mastodon/features/ui/components/bundle_modal_error.js
create mode 100644 app/javascript/mastodon/features/ui/components/column.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_header.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_link.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/column_subheading.js
create mode 100644 app/javascript/mastodon/features/ui/components/columns_area.js
create mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/drawer_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/embed_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/image_loader.js
create mode 100644 app/javascript/mastodon/features/ui/components/media_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/modal_loading.js
create mode 100644 app/javascript/mastodon/features/ui/components/modal_root.js
create mode 100644 app/javascript/mastodon/features/ui/components/mute_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/onboarding_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/report_modal.js
create mode 100644 app/javascript/mastodon/features/ui/components/tabs_bar.js
create mode 100644 app/javascript/mastodon/features/ui/components/upload_area.js
create mode 100644 app/javascript/mastodon/features/ui/components/video_modal.js
create mode 100644 app/javascript/mastodon/features/ui/containers/bundle_container.js
create mode 100644 app/javascript/mastodon/features/ui/containers/columns_area_container.js
create mode 100644 app/javascript/mastodon/features/ui/containers/loading_bar_container.js
create mode 100644 app/javascript/mastodon/features/ui/containers/modal_container.js
create mode 100644 app/javascript/mastodon/features/ui/containers/notifications_container.js
create mode 100644 app/javascript/mastodon/features/ui/containers/status_list_container.js
create mode 100644 app/javascript/mastodon/features/ui/index.js
create mode 100644 app/javascript/mastodon/features/ui/util/async-components.js
create mode 100644 app/javascript/mastodon/features/ui/util/fullscreen.js
create mode 100644 app/javascript/mastodon/features/ui/util/get_rect_from_entry.js
create mode 100644 app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
create mode 100644 app/javascript/mastodon/features/ui/util/optional_motion.js
create mode 100644 app/javascript/mastodon/features/ui/util/react_router_helpers.js
create mode 100644 app/javascript/mastodon/features/ui/util/reduced_motion.js
create mode 100644 app/javascript/mastodon/features/ui/util/schedule_idle_task.js
create mode 100644 app/javascript/mastodon/features/video/index.js
create mode 100644 app/javascript/mastodon/initial_state.js
create mode 100644 app/javascript/mastodon/is_mobile.js
create mode 100644 app/javascript/mastodon/link_header.js
create mode 100644 app/javascript/mastodon/load_polyfills.js
create mode 100644 app/javascript/mastodon/main.js
create mode 100644 app/javascript/mastodon/middleware/errors.js
create mode 100644 app/javascript/mastodon/middleware/loading_bar.js
create mode 100644 app/javascript/mastodon/middleware/sounds.js
create mode 100644 app/javascript/mastodon/performance.js
create mode 100644 app/javascript/mastodon/ready.js
create mode 100644 app/javascript/mastodon/reducers/accounts.js
create mode 100644 app/javascript/mastodon/reducers/accounts_counters.js
create mode 100644 app/javascript/mastodon/reducers/alerts.js
create mode 100644 app/javascript/mastodon/reducers/cards.js
create mode 100644 app/javascript/mastodon/reducers/compose.js
create mode 100644 app/javascript/mastodon/reducers/contexts.js
create mode 100644 app/javascript/mastodon/reducers/custom_emojis.js
create mode 100644 app/javascript/mastodon/reducers/height_cache.js
create mode 100644 app/javascript/mastodon/reducers/index.js
create mode 100644 app/javascript/mastodon/reducers/media_attachments.js
create mode 100644 app/javascript/mastodon/reducers/meta.js
create mode 100644 app/javascript/mastodon/reducers/modal.js
create mode 100644 app/javascript/mastodon/reducers/mutes.js
create mode 100644 app/javascript/mastodon/reducers/notifications.js
create mode 100644 app/javascript/mastodon/reducers/push_notifications.js
create mode 100644 app/javascript/mastodon/reducers/relationships.js
create mode 100644 app/javascript/mastodon/reducers/reports.js
create mode 100644 app/javascript/mastodon/reducers/search.js
create mode 100644 app/javascript/mastodon/reducers/settings.js
create mode 100644 app/javascript/mastodon/reducers/status_lists.js
create mode 100644 app/javascript/mastodon/reducers/statuses.js
create mode 100644 app/javascript/mastodon/reducers/timelines.js
create mode 100644 app/javascript/mastodon/reducers/user_lists.js
create mode 100644 app/javascript/mastodon/rtl.js
create mode 100644 app/javascript/mastodon/scroll.js
create mode 100644 app/javascript/mastodon/selectors/index.js
create mode 100644 app/javascript/mastodon/service_worker/entry.js
create mode 100644 app/javascript/mastodon/service_worker/web_push_notifications.js
create mode 100644 app/javascript/mastodon/store/configureStore.js
create mode 100644 app/javascript/mastodon/stream.js
create mode 100644 app/javascript/mastodon/test_setup.js
create mode 100644 app/javascript/mastodon/uuid.js
create mode 100644 app/javascript/mastodon/web_push_subscription.js
create mode 100644 app/javascript/styles/application.scss
create mode 100644 app/javascript/styles/mastodon/_mixins.scss
create mode 100644 app/javascript/styles/mastodon/about.scss
create mode 100644 app/javascript/styles/mastodon/accounts.scss
create mode 100644 app/javascript/styles/mastodon/admin.scss
create mode 100644 app/javascript/styles/mastodon/basics.scss
create mode 100644 app/javascript/styles/mastodon/boost.scss
create mode 100644 app/javascript/styles/mastodon/compact_header.scss
create mode 100644 app/javascript/styles/mastodon/components.scss
create mode 100644 app/javascript/styles/mastodon/containers.scss
create mode 100644 app/javascript/styles/mastodon/emoji_picker.scss
create mode 100644 app/javascript/styles/mastodon/footer.scss
create mode 100644 app/javascript/styles/mastodon/forms.scss
create mode 100644 app/javascript/styles/mastodon/landing_strip.scss
create mode 100644 app/javascript/styles/mastodon/lists.scss
create mode 100644 app/javascript/styles/mastodon/reset.scss
create mode 100644 app/javascript/styles/mastodon/rtl.scss
create mode 100644 app/javascript/styles/mastodon/stream_entries.scss
create mode 100644 app/javascript/styles/mastodon/tables.scss
create mode 100644 app/javascript/styles/mastodon/variables.scss
(limited to 'app')
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
new file mode 100644
index 000000000..fbaebf786
--- /dev/null
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -0,0 +1,659 @@
+import api, { getLinks } from '../api';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchRelationships([id]));
+
+ if (getState().getIn(['accounts', id], null) !== null) {
+ return;
+ }
+
+ dispatch(fetchAccountRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+ dispatch(fetchAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchAccountFail(id, error));
+ });
+ };
+};
+
+export function fetchAccountRequest(id) {
+ return {
+ type: ACCOUNT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchAccountSuccess(account) {
+ return {
+ type: ACCOUNT_FETCH_SUCCESS,
+ account,
+ };
+};
+
+export function fetchAccountFail(id, error) {
+ return {
+ type: ACCOUNT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function followAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(followAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
+ dispatch(followAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(followAccountFail(error));
+ });
+ };
+};
+
+export function unfollowAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unfollowAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+ dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(unfollowAccountFail(error));
+ });
+ };
+};
+
+export function followAccountRequest(id) {
+ return {
+ type: ACCOUNT_FOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function followAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_FOLLOW_SUCCESS,
+ relationship,
+ };
+};
+
+export function followAccountFail(error) {
+ return {
+ type: ACCOUNT_FOLLOW_FAIL,
+ error,
+ };
+};
+
+export function unfollowAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNFOLLOW_REQUEST,
+ id,
+ };
+};
+
+export function unfollowAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_UNFOLLOW_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function unfollowAccountFail(error) {
+ return {
+ type: ACCOUNT_UNFOLLOW_FAIL,
+ error,
+ };
+};
+
+export function blockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(blockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(blockAccountFail(id, error));
+ });
+ };
+};
+
+export function unblockAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unblockAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+ dispatch(unblockAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unblockAccountFail(id, error));
+ });
+ };
+};
+
+export function blockAccountRequest(id) {
+ return {
+ type: ACCOUNT_BLOCK_REQUEST,
+ id,
+ };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_BLOCK_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function blockAccountFail(error) {
+ return {
+ type: ACCOUNT_BLOCK_FAIL,
+ error,
+ };
+};
+
+export function unblockAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNBLOCK_REQUEST,
+ id,
+ };
+};
+
+export function unblockAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNBLOCK_SUCCESS,
+ relationship,
+ };
+};
+
+export function unblockAccountFail(error) {
+ return {
+ type: ACCOUNT_UNBLOCK_FAIL,
+ error,
+ };
+};
+
+
+export function muteAccount(id, notifications) {
+ return (dispatch, getState) => {
+ dispatch(muteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+ // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+ dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+ }).catch(error => {
+ dispatch(muteAccountFail(id, error));
+ });
+ };
+};
+
+export function unmuteAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+ dispatch(unmuteAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unmuteAccountFail(id, error));
+ });
+ };
+};
+
+export function muteAccountRequest(id) {
+ return {
+ type: ACCOUNT_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+ return {
+ type: ACCOUNT_MUTE_SUCCESS,
+ relationship,
+ statuses,
+ };
+};
+
+export function muteAccountFail(error) {
+ return {
+ type: ACCOUNT_MUTE_FAIL,
+ error,
+ };
+};
+
+export function unmuteAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNMUTE_SUCCESS,
+ relationship,
+ };
+};
+
+export function unmuteAccountFail(error) {
+ return {
+ type: ACCOUNT_UNMUTE_FAIL,
+ error,
+ };
+};
+
+
+export function fetchFollowers(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowersRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowersFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowers(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowersRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowersFail(id, error));
+ });
+ };
+};
+
+export function expandFollowersRequest(id) {
+ return {
+ type: FOLLOWERS_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWERS_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowersFail(id, error) {
+ return {
+ type: FOLLOWERS_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchFollowing(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowingRequest(id));
+
+ api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchFollowingFail(id, error));
+ });
+ };
+};
+
+export function fetchFollowingRequest(id) {
+ return {
+ type: FOLLOWING_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandFollowing(id) {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowingRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandFollowingFail(id, error));
+ });
+ };
+};
+
+export function expandFollowingRequest(id) {
+ return {
+ type: FOLLOWING_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+ return {
+ type: FOLLOWING_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowingFail(id, error) {
+ return {
+ type: FOLLOWING_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchRelationships(accountIds) {
+ return (dispatch, getState) => {
+ const loadedRelationships = getState().get('relationships');
+ const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+ if (newAccountIds.length === 0) {
+ return;
+ }
+
+ dispatch(fetchRelationshipsRequest(newAccountIds));
+
+ api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+ dispatch(fetchRelationshipsSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchRelationshipsFail(error));
+ });
+ };
+};
+
+export function fetchRelationshipsRequest(ids) {
+ return {
+ type: RELATIONSHIPS_FETCH_REQUEST,
+ ids,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+ return {
+ type: RELATIONSHIPS_FETCH_SUCCESS,
+ relationships,
+ skipLoading: true,
+ };
+};
+
+export function fetchRelationshipsFail(error) {
+ return {
+ type: RELATIONSHIPS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ };
+};
+
+export function fetchFollowRequests() {
+ return (dispatch, getState) => {
+ dispatch(fetchFollowRequestsRequest());
+
+ api(getState).get('/api/v1/follow_requests').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+ };
+};
+
+export function fetchFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_REQUEST,
+ };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandFollowRequests() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFollowRequestsRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
+ }).catch(error => dispatch(expandFollowRequestsFail(error)));
+ };
+};
+
+export function expandFollowRequestsRequest() {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_REQUEST,
+ };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandFollowRequestsFail(error) {
+ return {
+ type: FOLLOW_REQUESTS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function authorizeFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(authorizeFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/authorize`)
+ .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+ .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+ };
+};
+
+export function authorizeFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+ id,
+ };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+ id,
+ };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+ id,
+ error,
+ };
+};
+
+
+export function rejectFollowRequest(id) {
+ return (dispatch, getState) => {
+ dispatch(rejectFollowRequestRequest(id));
+
+ api(getState)
+ .post(`/api/v1/follow_requests/${id}/reject`)
+ .then(() => dispatch(rejectFollowRequestSuccess(id)))
+ .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+ };
+};
+
+export function rejectFollowRequestRequest(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_REQUEST,
+ id,
+ };
+};
+
+export function rejectFollowRequestSuccess(id) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_SUCCESS,
+ id,
+ };
+};
+
+export function rejectFollowRequestFail(id, error) {
+ return {
+ type: FOLLOW_REQUEST_REJECT_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js
new file mode 100644
index 000000000..f37fdeeb6
--- /dev/null
+++ b/app/javascript/mastodon/actions/alerts.js
@@ -0,0 +1,24 @@
+export const ALERT_SHOW = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR = 'ALERT_CLEAR';
+
+export function dismissAlert(alert) {
+ return {
+ type: ALERT_DISMISS,
+ alert,
+ };
+};
+
+export function clearAlert() {
+ return {
+ type: ALERT_CLEAR,
+ };
+};
+
+export function showAlert(title, message) {
+ return {
+ type: ALERT_SHOW,
+ title,
+ message,
+ };
+};
diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js
new file mode 100644
index 000000000..553283a71
--- /dev/null
+++ b/app/javascript/mastodon/actions/blocks.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+ return (dispatch, getState) => {
+ dispatch(fetchBlocksRequest());
+
+ api(getState).get('/api/v1/blocks').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchBlocksFail(error)));
+ };
+};
+
+export function fetchBlocksRequest() {
+ return {
+ type: BLOCKS_FETCH_REQUEST,
+ };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+ return {
+ type: BLOCKS_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchBlocksFail(error) {
+ return {
+ type: BLOCKS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandBlocks() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandBlocksRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandBlocksFail(error)));
+ };
+};
+
+export function expandBlocksRequest() {
+ return {
+ type: BLOCKS_EXPAND_REQUEST,
+ };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+ return {
+ type: BLOCKS_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandBlocksFail(error) {
+ return {
+ type: BLOCKS_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/bundles.js b/app/javascript/mastodon/actions/bundles.js
new file mode 100644
index 000000000..ecc9c8f7d
--- /dev/null
+++ b/app/javascript/mastodon/actions/bundles.js
@@ -0,0 +1,25 @@
+export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
+export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
+export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
+
+export function fetchBundleRequest(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_REQUEST,
+ skipLoading,
+ };
+}
+
+export function fetchBundleSuccess(skipLoading) {
+ return {
+ type: BUNDLE_FETCH_SUCCESS,
+ skipLoading,
+ };
+}
+
+export function fetchBundleFail(error, skipLoading) {
+ return {
+ type: BUNDLE_FETCH_FAIL,
+ error,
+ skipLoading,
+ };
+}
diff --git a/app/javascript/mastodon/actions/cards.js b/app/javascript/mastodon/actions/cards.js
new file mode 100644
index 000000000..baf04833a
--- /dev/null
+++ b/app/javascript/mastodon/actions/cards.js
@@ -0,0 +1,52 @@
+import api from '../api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+ return (dispatch, getState) => {
+ if (getState().getIn(['cards', id], null) !== null) {
+ return;
+ }
+
+ dispatch(fetchStatusCardRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+ if (!response.data.url) {
+ return;
+ }
+
+ dispatch(fetchStatusCardSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchStatusCardFail(id, error));
+ });
+ };
+};
+
+export function fetchStatusCardRequest(id) {
+ return {
+ type: STATUS_CARD_FETCH_REQUEST,
+ id,
+ skipLoading: true,
+ };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+ return {
+ type: STATUS_CARD_FETCH_SUCCESS,
+ id,
+ card,
+ skipLoading: true,
+ };
+};
+
+export function fetchStatusCardFail(id, error) {
+ return {
+ type: STATUS_CARD_FETCH_FAIL,
+ id,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+ };
+};
diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js
new file mode 100644
index 000000000..bcb0cdf98
--- /dev/null
+++ b/app/javascript/mastodon/actions/columns.js
@@ -0,0 +1,40 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD = 'COLUMN_ADD';
+export const COLUMN_REMOVE = 'COLUMN_REMOVE';
+export const COLUMN_MOVE = 'COLUMN_MOVE';
+
+export function addColumn(id, params) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_ADD,
+ id,
+ params,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function removeColumn(uuid) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_REMOVE,
+ uuid,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+export function moveColumn(uuid, direction) {
+ return dispatch => {
+ dispatch({
+ type: COLUMN_MOVE,
+ uuid,
+ direction,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
new file mode 100644
index 000000000..8a35049b3
--- /dev/null
+++ b/app/javascript/mastodon/actions/compose.js
@@ -0,0 +1,376 @@
+import api from '../api';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
+
+import {
+ updateTimeline,
+ refreshHomeTimeline,
+ refreshCommunityTimeline,
+ refreshPublicTimeline,
+} from './timelines';
+
+export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
+export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION = 'COMPOSE_MENTION';
+export const COMPOSE_RESET = 'COMPOSE_RESET';
+export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
+export function changeCompose(text) {
+ return {
+ type: COMPOSE_CHANGE,
+ text: text,
+ };
+};
+
+export function replyCompose(status, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_REPLY,
+ status: status,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function cancelReplyCompose() {
+ return {
+ type: COMPOSE_REPLY_CANCEL,
+ };
+};
+
+export function resetCompose() {
+ return {
+ type: COMPOSE_RESET,
+ };
+};
+
+export function mentionCompose(account, router) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_MENTION,
+ account: account,
+ });
+
+ if (!getState().getIn(['compose', 'mounted'])) {
+ router.push('/statuses/new');
+ }
+ };
+};
+
+export function submitCompose() {
+ return function (dispatch, getState) {
+ const status = getState().getIn(['compose', 'text'], '');
+
+ if (!status || !status.length) {
+ return;
+ }
+
+ dispatch(submitComposeRequest());
+
+ api(getState).post('/api/v1/statuses', {
+ status,
+ in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+ media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+ sensitive: getState().getIn(['compose', 'sensitive']),
+ spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+ visibility: getState().getIn(['compose', 'privacy']),
+ }, {
+ headers: {
+ 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+ },
+ }).then(function (response) {
+ dispatch(submitComposeSuccess({ ...response.data }));
+
+ // To make the app more responsive, immediately get the status into the columns
+
+ const insertOrRefresh = (timelineId, refreshAction) => {
+ if (getState().getIn(['timelines', timelineId, 'online'])) {
+ dispatch(updateTimeline(timelineId, { ...response.data }));
+ } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+ dispatch(refreshAction());
+ }
+ };
+
+ insertOrRefresh('home', refreshHomeTimeline);
+
+ if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+ insertOrRefresh('community', refreshCommunityTimeline);
+ insertOrRefresh('public', refreshPublicTimeline);
+ }
+ }).catch(function (error) {
+ dispatch(submitComposeFail(error));
+ });
+ };
+};
+
+export function submitComposeRequest() {
+ return {
+ type: COMPOSE_SUBMIT_REQUEST,
+ };
+};
+
+export function submitComposeSuccess(status) {
+ return {
+ type: COMPOSE_SUBMIT_SUCCESS,
+ status: status,
+ };
+};
+
+export function submitComposeFail(error) {
+ return {
+ type: COMPOSE_SUBMIT_FAIL,
+ error: error,
+ };
+};
+
+export function uploadCompose(files) {
+ return function (dispatch, getState) {
+ if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+ return;
+ }
+
+ dispatch(uploadComposeRequest());
+
+ let data = new FormData();
+ data.append('file', files[0]);
+
+ api(getState).post('/api/v1/media', data, {
+ onUploadProgress: function (e) {
+ dispatch(uploadComposeProgress(e.loaded, e.total));
+ },
+ }).then(function (response) {
+ dispatch(uploadComposeSuccess(response.data));
+ }).catch(function (error) {
+ dispatch(uploadComposeFail(error));
+ });
+ };
+};
+
+export function changeUploadCompose(id, description) {
+ return (dispatch, getState) => {
+ dispatch(changeUploadComposeRequest());
+
+ api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+ dispatch(changeUploadComposeSuccess(response.data));
+ }).catch(error => {
+ dispatch(changeUploadComposeFail(id, error));
+ });
+ };
+};
+
+export function changeUploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+ skipLoading: true,
+ };
+};
+export function changeUploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function changeUploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_CHANGE_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeRequest() {
+ return {
+ type: COMPOSE_UPLOAD_REQUEST,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeProgress(loaded, total) {
+ return {
+ type: COMPOSE_UPLOAD_PROGRESS,
+ loaded: loaded,
+ total: total,
+ };
+};
+
+export function uploadComposeSuccess(media) {
+ return {
+ type: COMPOSE_UPLOAD_SUCCESS,
+ media: media,
+ skipLoading: true,
+ };
+};
+
+export function uploadComposeFail(error) {
+ return {
+ type: COMPOSE_UPLOAD_FAIL,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function undoUploadCompose(media_id) {
+ return {
+ type: COMPOSE_UPLOAD_UNDO,
+ media_id: media_id,
+ };
+};
+
+export function clearComposeSuggestions() {
+ return {
+ type: COMPOSE_SUGGESTIONS_CLEAR,
+ };
+};
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+ api(getState).get('/api/v1/accounts/search', {
+ params: {
+ q: token.slice(1),
+ resolve: false,
+ limit: 4,
+ },
+ }).then(response => {
+ dispatch(readyComposeSuggestionsAccounts(token, response.data));
+ });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+ const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+ dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+export function fetchComposeSuggestions(token) {
+ return (dispatch, getState) => {
+ if (token[0] === ':') {
+ fetchComposeSuggestionsEmojis(dispatch, getState, token);
+ } else {
+ fetchComposeSuggestionsAccounts(dispatch, getState, token);
+ }
+ };
+};
+
+export function readyComposeSuggestionsEmojis(token, emojis) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ emojis,
+ };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
+ return {
+ type: COMPOSE_SUGGESTIONS_READY,
+ token,
+ accounts,
+ };
+};
+
+export function selectComposeSuggestion(position, token, suggestion) {
+ return (dispatch, getState) => {
+ let completion, startPosition;
+
+ if (typeof suggestion === 'object' && suggestion.id) {
+ completion = suggestion.native || suggestion.colons;
+ startPosition = position - 1;
+
+ dispatch(useEmoji(suggestion));
+ } else {
+ completion = getState().getIn(['accounts', suggestion, 'acct']);
+ startPosition = position;
+ }
+
+ dispatch({
+ type: COMPOSE_SUGGESTION_SELECT,
+ position: startPosition,
+ token,
+ completion,
+ });
+ };
+};
+
+export function mountCompose() {
+ return {
+ type: COMPOSE_MOUNT,
+ };
+};
+
+export function unmountCompose() {
+ return {
+ type: COMPOSE_UNMOUNT,
+ };
+};
+
+export function changeComposeSensitivity() {
+ return {
+ type: COMPOSE_SENSITIVITY_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerness() {
+ return {
+ type: COMPOSE_SPOILERNESS_CHANGE,
+ };
+};
+
+export function changeComposeSpoilerText(text) {
+ return {
+ type: COMPOSE_SPOILER_TEXT_CHANGE,
+ text,
+ };
+};
+
+export function changeComposeVisibility(value) {
+ return {
+ type: COMPOSE_VISIBILITY_CHANGE,
+ value,
+ };
+};
+
+export function insertEmojiCompose(position, emoji) {
+ return {
+ type: COMPOSE_EMOJI_INSERT,
+ position,
+ emoji,
+ };
+};
+
+export function changeComposing(value) {
+ return {
+ type: COMPOSE_COMPOSING_CHANGE,
+ value,
+ };
+}
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
new file mode 100644
index 000000000..44363697a
--- /dev/null
+++ b/app/javascript/mastodon/actions/domain_blocks.js
@@ -0,0 +1,117 @@
+import api, { getLinks } from '../api';
+
+export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
+export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
+export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
+
+export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
+export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
+export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
+
+export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
+export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
+
+export function blockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(blockDomainRequest(domain));
+
+ api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+ dispatch(blockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(blockDomainFail(domain, err));
+ });
+ };
+};
+
+export function blockDomainRequest(domain) {
+ return {
+ type: DOMAIN_BLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function blockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_BLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function blockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_BLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function unblockDomain(domain, accountId) {
+ return (dispatch, getState) => {
+ dispatch(unblockDomainRequest(domain));
+
+ api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+ dispatch(unblockDomainSuccess(domain, accountId));
+ }).catch(err => {
+ dispatch(unblockDomainFail(domain, err));
+ });
+ };
+};
+
+export function unblockDomainRequest(domain) {
+ return {
+ type: DOMAIN_UNBLOCK_REQUEST,
+ domain,
+ };
+};
+
+export function unblockDomainSuccess(domain, accountId) {
+ return {
+ type: DOMAIN_UNBLOCK_SUCCESS,
+ domain,
+ accountId,
+ };
+};
+
+export function unblockDomainFail(domain, error) {
+ return {
+ type: DOMAIN_UNBLOCK_FAIL,
+ domain,
+ error,
+ };
+};
+
+export function fetchDomainBlocks() {
+ return (dispatch, getState) => {
+ dispatch(fetchDomainBlocksRequest());
+
+ api(getState).get().then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
+ }).catch(err => {
+ dispatch(fetchDomainBlocksFail(err));
+ });
+ };
+};
+
+export function fetchDomainBlocksRequest() {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_REQUEST,
+ };
+};
+
+export function fetchDomainBlocksSuccess(domains, next) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_SUCCESS,
+ domains,
+ next,
+ };
+};
+
+export function fetchDomainBlocksFail(error) {
+ return {
+ type: DOMAIN_BLOCKS_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/mastodon/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+ return dispatch => {
+ dispatch({
+ type: EMOJI_USE,
+ emoji,
+ });
+
+ dispatch(saveSettings());
+ };
+};
diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js
new file mode 100644
index 000000000..09ce51fce
--- /dev/null
+++ b/app/javascript/mastodon/actions/favourites.js
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api';
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritedStatusesRequest());
+
+ api(getState).get('/api/v1/favourites').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(fetchFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandFavouritedStatuses() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandFavouritedStatusesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandFavouritedStatusesFail(error));
+ });
+ };
+};
+
+export function expandFavouritedStatusesRequest() {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_REQUEST,
+ };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function expandFavouritedStatusesFail(error) {
+ return {
+ type: FAVOURITED_STATUSES_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/height_cache.js b/app/javascript/mastodon/actions/height_cache.js
new file mode 100644
index 000000000..4c752993f
--- /dev/null
+++ b/app/javascript/mastodon/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+ return {
+ type: HEIGHT_CACHE_SET,
+ key,
+ id,
+ height,
+ };
+};
+
+export function clearHeight () {
+ return {
+ type: HEIGHT_CACHE_CLEAR,
+ };
+};
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
new file mode 100644
index 000000000..7b5f4bd9c
--- /dev/null
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -0,0 +1,313 @@
+import api from '../api';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL = 'UNPIN_FAIL';
+
+export function reblog(status) {
+ return function (dispatch, getState) {
+ dispatch(reblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+ // The reblog API method returns a new status wrapped around the original. In this case we are only
+ // interested in how the original is modified, hence passing it skipping the wrapper
+ dispatch(reblogSuccess(status, response.data.reblog));
+ }).catch(function (error) {
+ dispatch(reblogFail(status, error));
+ });
+ };
+};
+
+export function unreblog(status) {
+ return (dispatch, getState) => {
+ dispatch(unreblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+ dispatch(unreblogSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unreblogFail(status, error));
+ });
+ };
+};
+
+export function reblogRequest(status) {
+ return {
+ type: REBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function reblogSuccess(status, response) {
+ return {
+ type: REBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function reblogFail(status, error) {
+ return {
+ type: REBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unreblogRequest(status) {
+ return {
+ type: UNREBLOG_REQUEST,
+ status: status,
+ };
+};
+
+export function unreblogSuccess(status, response) {
+ return {
+ type: UNREBLOG_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unreblogFail(status, error) {
+ return {
+ type: UNREBLOG_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function favourite(status) {
+ return function (dispatch, getState) {
+ dispatch(favouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+ dispatch(favouriteSuccess(status, response.data));
+ }).catch(function (error) {
+ dispatch(favouriteFail(status, error));
+ });
+ };
+};
+
+export function unfavourite(status) {
+ return (dispatch, getState) => {
+ dispatch(unfavouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+ dispatch(unfavouriteSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unfavouriteFail(status, error));
+ });
+ };
+};
+
+export function favouriteRequest(status) {
+ return {
+ type: FAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function favouriteSuccess(status, response) {
+ return {
+ type: FAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function favouriteFail(status, error) {
+ return {
+ type: FAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unfavouriteRequest(status) {
+ return {
+ type: UNFAVOURITE_REQUEST,
+ status: status,
+ };
+};
+
+export function unfavouriteSuccess(status, response) {
+ return {
+ type: UNFAVOURITE_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unfavouriteFail(status, error) {
+ return {
+ type: UNFAVOURITE_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function fetchReblogs(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchReblogsRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+ dispatch(fetchReblogsSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchReblogsFail(id, error));
+ });
+ };
+};
+
+export function fetchReblogsRequest(id) {
+ return {
+ type: REBLOGS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+ return {
+ type: REBLOGS_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchReblogsFail(id, error) {
+ return {
+ type: REBLOGS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function fetchFavourites(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchFavouritesRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+ dispatch(fetchFavouritesSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchFavouritesFail(id, error));
+ });
+ };
+};
+
+export function fetchFavouritesRequest(id) {
+ return {
+ type: FAVOURITES_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+ return {
+ type: FAVOURITES_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchFavouritesFail(id, error) {
+ return {
+ type: FAVOURITES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function pin(status) {
+ return (dispatch, getState) => {
+ dispatch(pinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+ dispatch(pinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(pinFail(status, error));
+ });
+ };
+};
+
+export function pinRequest(status) {
+ return {
+ type: PIN_REQUEST,
+ status,
+ };
+};
+
+export function pinSuccess(status, response) {
+ return {
+ type: PIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function pinFail(status, error) {
+ return {
+ type: PIN_FAIL,
+ status,
+ error,
+ };
+};
+
+export function unpin (status) {
+ return (dispatch, getState) => {
+ dispatch(unpinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+ dispatch(unpinSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unpinFail(status, error));
+ });
+ };
+};
+
+export function unpinRequest(status) {
+ return {
+ type: UNPIN_REQUEST,
+ status,
+ };
+};
+
+export function unpinSuccess(status, response) {
+ return {
+ type: UNPIN_SUCCESS,
+ status,
+ response,
+ };
+};
+
+export function unpinFail(status, error) {
+ return {
+ type: UNPIN_FAIL,
+ status,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
new file mode 100644
index 000000000..80e15c28e
--- /dev/null
+++ b/app/javascript/mastodon/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+ return {
+ type: MODAL_OPEN,
+ modalType: type,
+ modalProps: props,
+ };
+};
+
+export function closeModal() {
+ return {
+ type: MODAL_CLOSE,
+ };
+};
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
new file mode 100644
index 000000000..3474250fe
--- /dev/null
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -0,0 +1,103 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+import { openModal } from '../../mastodon/actions/modal';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+ return (dispatch, getState) => {
+ dispatch(fetchMutesRequest());
+
+ api(getState).get('/api/v1/mutes').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchMutesFail(error)));
+ };
+};
+
+export function fetchMutesRequest() {
+ return {
+ type: MUTES_FETCH_REQUEST,
+ };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchMutesFail(error) {
+ return {
+ type: MUTES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandMutes() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandMutesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandMutesFail(error)));
+ };
+};
+
+export function expandMutesRequest() {
+ return {
+ type: MUTES_EXPAND_REQUEST,
+ };
+};
+
+export function expandMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandMutesFail(error) {
+ return {
+ type: MUTES_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function initMuteModal(account) {
+ return dispatch => {
+ dispatch({
+ type: MUTES_INIT_MODAL,
+ account,
+ });
+
+ dispatch(openModal('MUTE'));
+ };
+}
+
+export function toggleHideNotifications() {
+ return dispatch => {
+ dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+ };
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
new file mode 100644
index 000000000..b24ac8b73
--- /dev/null
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -0,0 +1,190 @@
+import api, { getLinks } from '../api';
+import { List as ImmutableList } from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchRelationships } from './accounts';
+import { defineMessages } from 'react-intl';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+defineMessages({
+ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+ const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+ if (accountIds > 0) {
+ dispatch(fetchRelationships(accountIds));
+ }
+};
+
+const unescapeHTML = (html) => {
+ const wrapper = document.createElement('div');
+ html = html.replace(/ | |\n/, ' ');
+ wrapper.innerHTML = html;
+ return wrapper.textContent;
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+ return (dispatch, getState) => {
+ const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+ const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+ dispatch({
+ type: NOTIFICATIONS_UPDATE,
+ notification,
+ account: notification.account,
+ status: notification.status,
+ meta: playSound ? { sound: 'boop' } : undefined,
+ });
+
+ fetchRelatedRelationships(dispatch, [notification]);
+
+ // Desktop notifications
+ if (typeof window.Notification !== 'undefined' && showAlert) {
+ const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+ const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+ const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+ notify.addEventListener('click', () => {
+ window.focus();
+ notify.close();
+ });
+ }
+ };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+ return (dispatch, getState) => {
+ const params = {};
+ const ids = getState().getIn(['notifications', 'items']);
+
+ let skipLoading = false;
+
+ if (ids.size > 0) {
+ params.since_id = ids.first().get('id');
+ }
+
+ if (getState().getIn(['notifications', 'loaded'])) {
+ skipLoading = true;
+ }
+
+ params.exclude_types = excludeTypesFromSettings(getState());
+
+ dispatch(refreshNotificationsRequest(skipLoading));
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(refreshNotificationsFail(error, skipLoading));
+ });
+ };
+};
+
+export function refreshNotificationsRequest(skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_REQUEST,
+ skipLoading,
+ };
+};
+
+export function refreshNotificationsSuccess(notifications, skipLoading, next) {
+ return {
+ type: NOTIFICATIONS_REFRESH_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ skipLoading,
+ next,
+ };
+};
+
+export function refreshNotificationsFail(error, skipLoading) {
+ return {
+ type: NOTIFICATIONS_REFRESH_FAIL,
+ error,
+ skipLoading,
+ };
+};
+
+export function expandNotifications() {
+ return (dispatch, getState) => {
+ const items = getState().getIn(['notifications', 'items'], ImmutableList());
+
+ if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+ return;
+ }
+
+ const params = {
+ max_id: items.last().get('id'),
+ limit: 20,
+ exclude_types: excludeTypesFromSettings(getState()),
+ };
+
+ dispatch(expandNotificationsRequest());
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+ fetchRelatedRelationships(dispatch, response.data);
+ }).catch(error => {
+ dispatch(expandNotificationsFail(error));
+ });
+ };
+};
+
+export function expandNotificationsRequest() {
+ return {
+ type: NOTIFICATIONS_EXPAND_REQUEST,
+ };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+ return {
+ type: NOTIFICATIONS_EXPAND_SUCCESS,
+ notifications,
+ accounts: notifications.map(item => item.account),
+ statuses: notifications.map(item => item.status).filter(status => !!status),
+ next,
+ };
+};
+
+export function expandNotificationsFail(error) {
+ return {
+ type: NOTIFICATIONS_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function clearNotifications() {
+ return (dispatch, getState) => {
+ dispatch({
+ type: NOTIFICATIONS_CLEAR,
+ });
+
+ api(getState).post('/api/v1/notifications/clear');
+ };
+};
+
+export function scrollTopNotifications(top) {
+ return {
+ type: NOTIFICATIONS_SCROLL_TOP,
+ top,
+ };
+};
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
new file mode 100644
index 000000000..a161c50ef
--- /dev/null
+++ b/app/javascript/mastodon/actions/onboarding.js
@@ -0,0 +1,14 @@
+import { openModal } from './modal';
+import { changeSetting, saveSettings } from './settings';
+
+export function showOnboardingOnce() {
+ return (dispatch, getState) => {
+ const alreadySeen = getState().getIn(['settings', 'onboarded']);
+
+ if (!alreadySeen) {
+ dispatch(openModal('ONBOARDING'));
+ dispatch(changeSetting(['onboarded'], true));
+ dispatch(saveSettings());
+ }
+ };
+};
diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js
new file mode 100644
index 000000000..3f40f6c2d
--- /dev/null
+++ b/app/javascript/mastodon/actions/pin_statuses.js
@@ -0,0 +1,40 @@
+import api from '../api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+import { me } from '../initial_state';
+
+export function fetchPinnedStatuses() {
+ return (dispatch, getState) => {
+ dispatch(fetchPinnedStatusesRequest());
+
+ api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+ dispatch(fetchPinnedStatusesSuccess(response.data, null));
+ }).catch(error => {
+ dispatch(fetchPinnedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchPinnedStatusesRequest() {
+ return {
+ type: PINNED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+ return {
+ type: PINNED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchPinnedStatusesFail(error) {
+ return {
+ type: PINNED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js
new file mode 100644
index 000000000..55661d2b0
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications.js
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
+
+export function setBrowserSupport (value) {
+ return {
+ type: SET_BROWSER_SUPPORT,
+ value,
+ };
+}
+
+export function setSubscription (subscription) {
+ return {
+ type: SET_SUBSCRIPTION,
+ subscription,
+ };
+}
+
+export function clearSubscription () {
+ return {
+ type: CLEAR_SUBSCRIPTION,
+ };
+}
+
+export function changeAlerts(key, value) {
+ return dispatch => {
+ dispatch({
+ type: ALERTS_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+}
+
+export function saveSettings() {
+ return (_, getState) => {
+ const state = getState().get('push_notifications');
+ const subscription = state.get('subscription');
+ const alerts = state.get('alerts');
+
+ axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+ data: {
+ alerts,
+ },
+ });
+ };
+}
diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js
new file mode 100644
index 000000000..b19a07285
--- /dev/null
+++ b/app/javascript/mastodon/actions/reports.js
@@ -0,0 +1,80 @@
+import api from '../api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+
+export function initReport(account, status) {
+ return dispatch => {
+ dispatch({
+ type: REPORT_INIT,
+ account,
+ status,
+ });
+
+ dispatch(openModal('REPORT'));
+ };
+};
+
+export function cancelReport() {
+ return {
+ type: REPORT_CANCEL,
+ };
+};
+
+export function toggleStatusReport(statusId, checked) {
+ return {
+ type: REPORT_STATUS_TOGGLE,
+ statusId,
+ checked,
+ };
+};
+
+export function submitReport() {
+ return (dispatch, getState) => {
+ dispatch(submitReportRequest());
+
+ api(getState).post('/api/v1/reports', {
+ account_id: getState().getIn(['reports', 'new', 'account_id']),
+ status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+ comment: getState().getIn(['reports', 'new', 'comment']),
+ }).then(response => {
+ dispatch(closeModal());
+ dispatch(submitReportSuccess(response.data));
+ }).catch(error => dispatch(submitReportFail(error)));
+ };
+};
+
+export function submitReportRequest() {
+ return {
+ type: REPORT_SUBMIT_REQUEST,
+ };
+};
+
+export function submitReportSuccess(report) {
+ return {
+ type: REPORT_SUBMIT_SUCCESS,
+ report,
+ };
+};
+
+export function submitReportFail(error) {
+ return {
+ type: REPORT_SUBMIT_FAIL,
+ error,
+ };
+};
+
+export function changeReportComment(comment) {
+ return {
+ type: REPORT_COMMENT_CHANGE,
+ comment,
+ };
+};
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
new file mode 100644
index 000000000..78c6109f7
--- /dev/null
+++ b/app/javascript/mastodon/actions/search.js
@@ -0,0 +1,73 @@
+import api from '../api';
+
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR = 'SEARCH_CLEAR';
+export const SEARCH_SHOW = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
+
+export function changeSearch(value) {
+ return {
+ type: SEARCH_CHANGE,
+ value,
+ };
+};
+
+export function clearSearch() {
+ return {
+ type: SEARCH_CLEAR,
+ };
+};
+
+export function submitSearch() {
+ return (dispatch, getState) => {
+ const value = getState().getIn(['search', 'value']);
+
+ if (value.length === 0) {
+ return;
+ }
+
+ dispatch(fetchSearchRequest());
+
+ api(getState).get('/api/v1/search', {
+ params: {
+ q: value,
+ resolve: true,
+ },
+ }).then(response => {
+ dispatch(fetchSearchSuccess(response.data));
+ }).catch(error => {
+ dispatch(fetchSearchFail(error));
+ });
+ };
+};
+
+export function fetchSearchRequest() {
+ return {
+ type: SEARCH_FETCH_REQUEST,
+ };
+};
+
+export function fetchSearchSuccess(results) {
+ return {
+ type: SEARCH_FETCH_SUCCESS,
+ results,
+ accounts: results.accounts,
+ statuses: results.statuses,
+ };
+};
+
+export function fetchSearchFail(error) {
+ return {
+ type: SEARCH_FETCH_FAIL,
+ error,
+ };
+};
+
+export function showSearch() {
+ return {
+ type: SEARCH_SHOW,
+ };
+};
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
new file mode 100644
index 000000000..79adca18c
--- /dev/null
+++ b/app/javascript/mastodon/actions/settings.js
@@ -0,0 +1,31 @@
+import axios from 'axios';
+import { debounce } from 'lodash';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE = 'SETTING_SAVE';
+
+export function changeSetting(key, value) {
+ return dispatch => {
+ dispatch({
+ type: SETTING_CHANGE,
+ key,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+ if (getState().getIn(['settings', 'saved'])) {
+ return;
+ }
+
+ const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+
+ axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+ return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
new file mode 100644
index 000000000..2204e0b14
--- /dev/null
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -0,0 +1,217 @@
+import api from '../api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+ return {
+ type: STATUS_FETCH_REQUEST,
+ id,
+ skipLoading,
+ };
+};
+
+export function fetchStatus(id) {
+ return (dispatch, getState) => {
+ const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+ dispatch(fetchContext(id));
+ dispatch(fetchStatusCard(id));
+
+ if (skipLoading) {
+ return;
+ }
+
+ dispatch(fetchStatusRequest(id, skipLoading));
+
+ api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+ dispatch(fetchStatusSuccess(response.data, skipLoading));
+ }).catch(error => {
+ dispatch(fetchStatusFail(id, error, skipLoading));
+ });
+ };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+ return {
+ type: STATUS_FETCH_SUCCESS,
+ status,
+ skipLoading,
+ };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+ return {
+ type: STATUS_FETCH_FAIL,
+ id,
+ error,
+ skipLoading,
+ skipAlert: true,
+ };
+};
+
+export function deleteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(deleteStatusRequest(id));
+
+ api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+ dispatch(deleteStatusSuccess(id));
+ dispatch(deleteFromTimelines(id));
+ }).catch(error => {
+ dispatch(deleteStatusFail(id, error));
+ });
+ };
+};
+
+export function deleteStatusRequest(id) {
+ return {
+ type: STATUS_DELETE_REQUEST,
+ id: id,
+ };
+};
+
+export function deleteStatusSuccess(id) {
+ return {
+ type: STATUS_DELETE_SUCCESS,
+ id: id,
+ };
+};
+
+export function deleteStatusFail(id, error) {
+ return {
+ type: STATUS_DELETE_FAIL,
+ id: id,
+ error: error,
+ };
+};
+
+export function fetchContext(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchContextRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+ dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+ }).catch(error => {
+ if (error.response && error.response.status === 404) {
+ dispatch(deleteFromTimelines(id));
+ }
+
+ dispatch(fetchContextFail(id, error));
+ });
+ };
+};
+
+export function fetchContextRequest(id) {
+ return {
+ type: CONTEXT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+ return {
+ type: CONTEXT_FETCH_SUCCESS,
+ id,
+ ancestors,
+ descendants,
+ statuses: ancestors.concat(descendants),
+ };
+};
+
+export function fetchContextFail(id, error) {
+ return {
+ type: CONTEXT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function muteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(muteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+ dispatch(muteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(muteStatusFail(id, error));
+ });
+ };
+};
+
+export function muteStatusRequest(id) {
+ return {
+ type: STATUS_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteStatusSuccess(id) {
+ return {
+ type: STATUS_MUTE_SUCCESS,
+ id,
+ };
+};
+
+export function muteStatusFail(id, error) {
+ return {
+ type: STATUS_MUTE_FAIL,
+ id,
+ error,
+ };
+};
+
+export function unmuteStatus(id) {
+ return (dispatch, getState) => {
+ dispatch(unmuteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+ dispatch(unmuteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(unmuteStatusFail(id, error));
+ });
+ };
+};
+
+export function unmuteStatusRequest(id) {
+ return {
+ type: STATUS_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteStatusSuccess(id) {
+ return {
+ type: STATUS_UNMUTE_SUCCESS,
+ id,
+ };
+};
+
+export function unmuteStatusFail(id, error) {
+ return {
+ type: STATUS_UNMUTE_FAIL,
+ id,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
new file mode 100644
index 000000000..a1db0fdd5
--- /dev/null
+++ b/app/javascript/mastodon/actions/store.js
@@ -0,0 +1,17 @@
+import { Iterable, fromJS } from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+ fromJS(rawState, (k, v) =>
+ Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+ const state = convertState(rawState);
+
+ return {
+ type: STORE_HYDRATE,
+ state,
+ };
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
new file mode 100644
index 000000000..dcce048ca
--- /dev/null
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -0,0 +1,53 @@
+import { connectStream } from '../stream';
+import {
+ updateTimeline,
+ deleteFromTimelines,
+ refreshHomeTimeline,
+ connectTimeline,
+ disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from '../locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+
+ return connectStream (path, pollingRefresh, (dispatch, getState) => {
+ const locale = getState().getIn(['meta', 'locale']);
+ return {
+ onConnect() {
+ dispatch(connectTimeline(timelineId));
+ },
+
+ onDisconnect() {
+ dispatch(disconnectTimeline(timelineId));
+ },
+
+ onReceive (data) {
+ switch(data.event) {
+ case 'update':
+ dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+ break;
+ case 'delete':
+ dispatch(deleteFromTimelines(data.payload));
+ break;
+ case 'notification':
+ dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+ break;
+ }
+ },
+ };
+ });
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+ dispatch(refreshHomeTimeline());
+ dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
new file mode 100644
index 000000000..09abe2702
--- /dev/null
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -0,0 +1,206 @@
+import api, { getLinks } from '../api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
+
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
+ return {
+ type: TIMELINE_REFRESH_SUCCESS,
+ timeline,
+ statuses,
+ skipLoading,
+ next,
+ };
+};
+
+export function updateTimeline(timeline, status) {
+ return (dispatch, getState) => {
+ const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+ const parents = [];
+
+ if (status.in_reply_to_id) {
+ let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+ while (parent && parent.get('in_reply_to_id')) {
+ parents.push(parent.get('id'));
+ parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+ }
+ }
+
+ dispatch({
+ type: TIMELINE_UPDATE,
+ timeline,
+ status,
+ references,
+ });
+
+ if (parents.length > 0) {
+ dispatch({
+ type: TIMELINE_CONTEXT_UPDATE,
+ status,
+ references: parents,
+ });
+ }
+ };
+};
+
+export function deleteFromTimelines(id) {
+ return (dispatch, getState) => {
+ const accountId = getState().getIn(['statuses', id, 'account']);
+ const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+ const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
+
+ dispatch({
+ type: TIMELINE_DELETE,
+ id,
+ accountId,
+ references,
+ reblogOf,
+ });
+ };
+};
+
+export function refreshTimelineRequest(timeline, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_REQUEST,
+ timeline,
+ skipLoading,
+ };
+};
+
+export function refreshTimeline(timelineId, path, params = {}) {
+ return function (dispatch, getState) {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+
+ if (timeline.get('isLoading') || timeline.get('online')) {
+ return;
+ }
+
+ const ids = timeline.get('items', ImmutableList());
+ const newestId = ids.size > 0 ? ids.first() : null;
+
+ let skipLoading = timeline.get('loaded');
+
+ if (newestId !== null) {
+ params.since_id = newestId;
+ }
+
+ dispatch(refreshTimelineRequest(timelineId, skipLoading));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(refreshTimelineFail(timelineId, error, skipLoading));
+ });
+ };
+};
+
+export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
+export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
+export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+ return {
+ type: TIMELINE_REFRESH_FAIL,
+ timeline,
+ error,
+ skipLoading,
+ skipAlert: error.response && error.response.status === 404,
+ };
+};
+
+export function expandTimeline(timelineId, path, params = {}) {
+ return (dispatch, getState) => {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+ const ids = timeline.get('items', ImmutableList());
+
+ if (timeline.get('isLoading') || ids.size === 0) {
+ return;
+ }
+
+ params.max_id = ids.last();
+ params.limit = 10;
+
+ dispatch(expandTimelineRequest(timelineId));
+
+ api(getState).get(path, { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandTimelineFail(timelineId, error));
+ });
+ };
+};
+
+export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
+export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
+export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
+export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+
+export function expandTimelineRequest(timeline) {
+ return {
+ type: TIMELINE_EXPAND_REQUEST,
+ timeline,
+ };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+ return {
+ type: TIMELINE_EXPAND_SUCCESS,
+ timeline,
+ statuses,
+ next,
+ };
+};
+
+export function expandTimelineFail(timeline, error) {
+ return {
+ type: TIMELINE_EXPAND_FAIL,
+ timeline,
+ error,
+ };
+};
+
+export function scrollTopTimeline(timeline, top) {
+ return {
+ type: TIMELINE_SCROLL_TOP,
+ timeline,
+ top,
+ };
+};
+
+export function connectTimeline(timeline) {
+ return {
+ type: TIMELINE_CONNECT,
+ timeline,
+ };
+};
+
+export function disconnectTimeline(timeline) {
+ return {
+ type: TIMELINE_DISCONNECT,
+ timeline,
+ };
+};
diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js
new file mode 100644
index 000000000..ecc703c0a
--- /dev/null
+++ b/app/javascript/mastodon/api.js
@@ -0,0 +1,26 @@
+import axios from 'axios';
+import LinkHeader from './link_header';
+
+export const getLinks = response => {
+ const value = response.headers.link;
+
+ if (!value) {
+ return { refs: [] };
+ }
+
+ return LinkHeader.parse(value);
+};
+
+export default getState => axios.create({
+ headers: {
+ 'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
+ },
+
+ transformResponse: [function (data) {
+ try {
+ return JSON.parse(data);
+ } catch(Exception) {
+ return data;
+ }
+ }],
+});
diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
new file mode 100644
index 000000000..7856b26f9
--- /dev/null
+++ b/app/javascript/mastodon/base_polyfills.js
@@ -0,0 +1,18 @@
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import isNaN from 'is-nan';
+
+if (!Array.prototype.includes) {
+ includes.shim();
+}
+
+if (!Object.assign) {
+ Object.assign = assign;
+}
+
+if (!Number.isNaN) {
+ Number.isNaN = isNaN;
+}
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
new file mode 100644
index 000000000..76ab3374a
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Autoplay renders a animated avatar 1`] = `
+
+`;
+
+exports[` Still renders a still avatar 1`] = `
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
new file mode 100644
index 000000000..d59fee42f
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
+
+
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
new file mode 100644
index 000000000..c3f018d90
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
@@ -0,0 +1,114 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` adds class "button-secondary" if props.secondary given 1`] = `
+
+`;
+
+exports[` renders a button element 1`] = `
+
+`;
+
+exports[` renders a disabled attribute if props.disabled given 1`] = `
+
+`;
+
+exports[` renders class="button--block" if props.block given 1`] = `
+
+`;
+
+exports[` renders the children 1`] = `
+
+
+ children
+
+
+`;
+
+exports[` renders the given text 1`] = `
+
+ foo
+
+`;
+
+exports[` renders the props.text instead of children 1`] = `
+
+ foo
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
new file mode 100644
index 000000000..533359ffe
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders display name + account name 1`] = `
+
+ Foo",
+ }
+ }
+ />
+
+
+ @
+ bar@baz
+
+
+`;
diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.js b/app/javascript/mastodon/components/__tests__/avatar-test.js
new file mode 100644
index 000000000..dd3f7b7d2
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar-test.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import Avatar from '../avatar';
+
+describe(' ', () => {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const size = 100;
+
+ describe('Autoplay', () => {
+ it('renders a animated avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ describe('Still', () => {
+ it('renders a still avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ // TODO add autoplay test if possible
+});
diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
new file mode 100644
index 000000000..44addea83
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import AvatarOverlay from '../avatar_overlay';
+
+describe(' {
+ const account = fromJS({
+ username: 'alice',
+ acct: 'alice',
+ display_name: 'Alice',
+ avatar: '/animated/alice.gif',
+ avatar_static: '/static/alice.jpg',
+ });
+
+ const friend = fromJS({
+ username: 'eve',
+ acct: 'eve@blackhat.lair',
+ display_name: 'Evelyn',
+ avatar: '/animated/eve.gif',
+ avatar_static: '/static/eve.jpg',
+ });
+
+ it('renders a overlay avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js
new file mode 100644
index 000000000..160cd3cbc
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/button-test.js
@@ -0,0 +1,75 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Button from '../button';
+
+describe(' ', () => {
+ it('renders a button element', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the given text', () => {
+ const text = 'foo';
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('handles click events using the given handler', () => {
+ const handler = jest.fn();
+ const button = shallow( );
+ button.find('button').simulate('click');
+
+ expect(handler.mock.calls.length).toEqual(1);
+ });
+
+ it('does not handle click events if props.disabled given', () => {
+ const handler = jest.fn();
+ const button = shallow( );
+ button.find('button').simulate('click');
+
+ expect(handler.mock.calls.length).toEqual(0);
+ });
+
+ it('renders a disabled attribute if props.disabled given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the children', () => {
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the props.text instead of children', () => {
+ const text = 'foo';
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders class="button--block" if props.block given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('adds class "button-secondary" if props.secondary given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.js b/app/javascript/mastodon/components/__tests__/display_name-test.js
new file mode 100644
index 000000000..0d040c4cd
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/display_name-test.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import DisplayName from '../display_name';
+
+describe(' ', () => {
+ it('renders display name + account name', () => {
+ const account = fromJS({
+ username: 'bar',
+ acct: 'bar@baz',
+ display_name_html: 'Foo
',
+ });
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
new file mode 100644
index 000000000..724b10980
--- /dev/null
+++ b/app/javascript/mastodon/components/account.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from '../initial_state';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
+ unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
+});
+
+@injectIntl
+export default class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hidden: PropTypes.bool,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ handleMuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, true);
+ }
+
+ handleUnmuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, false);
+ }
+
+ render () {
+ const { account, intl, hidden } = this.props;
+
+ if (!account) {
+ return
;
+ }
+
+ if (hidden) {
+ return (
+
+ {account.get('display_name')}
+ {account.get('username')}
+
+ );
+ }
+
+ let buttons;
+
+ if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ const following = account.getIn(['relationship', 'following']);
+ const requested = account.getIn(['relationship', 'requested']);
+ const blocking = account.getIn(['relationship', 'blocking']);
+ const muting = account.getIn(['relationship', 'muting']);
+
+ if (requested) {
+ buttons = ;
+ } else if (blocking) {
+ buttons = ;
+ } else if (muting) {
+ let hidingNotificationsButton;
+ if (muting.get('notifications')) {
+ hidingNotificationsButton = ;
+ } else {
+ hidingNotificationsButton = ;
+ }
+ buttons = (
+
+
+ {hidingNotificationsButton}
+
+ );
+ } else {
+ buttons = ;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {buttons}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
new file mode 100644
index 000000000..b3d00b335
--- /dev/null
+++ b/app/javascript/mastodon/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { media } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
new file mode 100644
index 000000000..ce4383a60
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { emoji } = this.props;
+ let url;
+
+ if (emoji.custom) {
+ url = emoji.imageUrl;
+ } else {
+ const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+ if (!mapping) {
+ return null;
+ }
+
+ url = `${assetHost}/emoji/${mapping.filename}.svg`;
+ }
+
+ return (
+
+
+
+ {emoji.colons}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
new file mode 100644
index 000000000..14a8d4c38
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -0,0 +1,222 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from '../rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/\S+$/);
+ let right = str.slice(caretPosition).search(/\s/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
+ return [null, null];
+ }
+
+ word = word.trim().toLowerCase();
+
+ if (word.length > 0) {
+ return [left + 1, word];
+ } else {
+ return [null, null];
+ }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ autoFocus: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ };
+
+ state = {
+ suggestionsHidden: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+ if (token !== null && this.state.lastToken !== token) {
+ this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+ this.props.onSuggestionsFetchRequested(token);
+ } else if (token === null) {
+ this.setState({ lastToken: null });
+ this.props.onSuggestionsClearRequested();
+ }
+
+ this.props.onChange(e);
+ }
+
+ onKeyDown = (e) => {
+ const { suggestions, disabled } = this.props;
+ const { selectedSuggestion, suggestionsHidden } = this.state;
+
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (!suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ suggestionsHidden: true });
+ }
+
+ break;
+ case 'ArrowDown':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+
+ break;
+ case 'ArrowUp':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+
+ break;
+ case 'Enter':
+ case 'Tab':
+ // Select suggestion
+ if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ }
+
+ break;
+ }
+
+ if (e.defaultPrevented || !this.props.onKeyDown) {
+ return;
+ }
+
+ this.props.onKeyDown(e);
+ }
+
+ onKeyUp = e => {
+ if (e.key === 'Escape' && this.state.suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ }
+
+ if (this.props.onKeyUp) {
+ this.props.onKeyUp(e);
+ }
+ }
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true });
+ }
+
+ onSuggestionClick = (e) => {
+ const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+ this.textarea.focus();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setTextarea = (c) => {
+ this.textarea = c;
+ }
+
+ onPaste = (e) => {
+ if (e.clipboardData && e.clipboardData.files.length === 1) {
+ this.props.onPaste(e.clipboardData.files);
+ e.preventDefault();
+ }
+ }
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (typeof suggestion === 'object') {
+ inner = ;
+ key = suggestion.id;
+ } else {
+ inner = ;
+ key = suggestion;
+ }
+
+ return (
+
+ {inner}
+
+ );
+ }
+
+ render () {
+ const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
+ const { suggestionsHidden } = this.state;
+ const style = { direction: 'ltr' };
+
+ if (isRtl(value)) {
+ style.direction = 'rtl';
+ }
+
+ return (
+
+
+ {placeholder}
+
+
+
+
+
+ {suggestions.map(this.renderSuggestion)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
new file mode 100644
index 000000000..f7c484ee3
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class Avatar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ size: PropTypes.number.isRequired,
+ style: PropTypes.object,
+ animate: PropTypes.bool,
+ inline: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ animate: false,
+ size: 20,
+ inline: false,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: true });
+ }
+
+ handleMouseLeave = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: false });
+ }
+
+ render () {
+ const { account, size, animate, inline } = this.props;
+ const { hovering } = this.state;
+
+ const src = account.get('avatar');
+ const staticSrc = account.get('avatar_static');
+
+ let className = 'account__avatar';
+
+ if (inline) {
+ className = className + ' account__avatar-inline';
+ }
+
+ const style = {
+ ...this.props.style,
+ width: `${size}px`,
+ height: `${size}px`,
+ backgroundSize: `${size}px ${size}px`,
+ };
+
+ if (hovering || animate) {
+ style.backgroundImage = `url(${src})`;
+ } else {
+ style.backgroundImage = `url(${staticSrc})`;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js
new file mode 100644
index 000000000..f5d67b34e
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar_overlay.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class AvatarOverlay extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map.isRequired,
+ };
+
+ render() {
+ const { account, friend } = this.props;
+
+ const baseStyle = {
+ backgroundImage: `url(${account.get('avatar_static')})`,
+ };
+
+ const overlayStyle = {
+ backgroundImage: `url(${friend.get('avatar_static')})`,
+ };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js
new file mode 100644
index 000000000..51e2e6a7a
--- /dev/null
+++ b/app/javascript/mastodon/components/button.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Button extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.node,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ block: PropTypes.bool,
+ secondary: PropTypes.bool,
+ size: PropTypes.number,
+ className: PropTypes.string,
+ style: PropTypes.object,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ size: 36,
+ };
+
+ handleClick = (e) => {
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ focus() {
+ this.node.focus();
+ }
+
+ render () {
+ const style = {
+ padding: `0 ${this.props.size / 2.25}px`,
+ height: `${this.props.size}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ };
+
+ const className = classNames('button', this.props.className, {
+ 'button-secondary': this.props.secondary,
+ 'button--block': this.props.block,
+ });
+
+ return (
+
+ {this.props.text || this.props.children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js
new file mode 100644
index 000000000..42ea37ec2
--- /dev/null
+++ b/app/javascript/mastodon/components/collapsable.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import Motion from '../features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+
+ {({ opacity, height }) =>
+
+ {children}
+
+ }
+
+);
+
+Collapsable.propTypes = {
+ fullHeight: PropTypes.number.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+export default Collapsable;
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
new file mode 100644
index 000000000..e81236d26
--- /dev/null
+++ b/app/javascript/mastodon/components/column.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollTop } from '../scroll';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node,
+ };
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidMount () {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+
+ componentWillUnmount () {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+
+ render () {
+ const { children } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
new file mode 100644
index 000000000..8a60c4192
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js
new file mode 100644
index 000000000..3b4f46d99
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button_slim.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButtonSlim extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ if (window.history && window.history.length === 1) this.context.router.history.push('/');
+ else this.context.router.history.goBack();
+ }
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
new file mode 100644
index 000000000..80a8fbdb3
--- /dev/null
+++ b/app/javascript/mastodon/components/column_header.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+ hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+ moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+ moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
+});
+
+@injectIntl
+export default class ColumnHeader extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ title: PropTypes.node.isRequired,
+ icon: PropTypes.string.isRequired,
+ active: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ focusable: PropTypes.bool,
+ showBackButton: PropTypes.bool,
+ children: PropTypes.node,
+ pinned: PropTypes.bool,
+ onPin: PropTypes.func,
+ onMove: PropTypes.func,
+ onClick: PropTypes.func,
+ };
+
+ static defaultProps = {
+ focusable: true,
+ }
+
+ state = {
+ collapsed: true,
+ animating: false,
+ };
+
+ handleToggleClick = (e) => {
+ e.stopPropagation();
+ this.setState({ collapsed: !this.state.collapsed, animating: true });
+ }
+
+ handleTitleClick = () => {
+ this.props.onClick();
+ }
+
+ handleMoveLeft = () => {
+ this.props.onMove(-1);
+ }
+
+ handleMoveRight = () => {
+ this.props.onMove(1);
+ }
+
+ handleBackClick = () => {
+ if (window.history && window.history.length === 1) this.context.router.history.push('/');
+ else this.context.router.history.goBack();
+ }
+
+ handleTransitionEnd = () => {
+ this.setState({ animating: false });
+ }
+
+ render () {
+ const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props;
+ const { collapsed, animating } = this.state;
+
+ const wrapperClassName = classNames('column-header__wrapper', {
+ 'active': active,
+ });
+
+ const buttonClassName = classNames('column-header', {
+ 'active': active,
+ });
+
+ const collapsibleClassName = classNames('column-header__collapsible', {
+ 'collapsed': collapsed,
+ 'animating': animating,
+ });
+
+ const collapsibleButtonClassName = classNames('column-header__button', {
+ 'active': !collapsed,
+ });
+
+ let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+ if (children) {
+ extraContent = (
+
+ {children}
+
+ );
+ }
+
+ if (multiColumn && pinned) {
+ pinButton = ;
+
+ moveButtons = (
+
+
+
+
+ );
+ } else if (multiColumn) {
+ pinButton = ;
+ }
+
+ if (!pinned && (multiColumn || showBackButton)) {
+ backButton = (
+
+
+
+
+ );
+ }
+
+ const collapsedContent = [
+ extraContent,
+ ];
+
+ if (multiColumn) {
+ collapsedContent.push(moveButtons);
+ collapsedContent.push(pinButton);
+ }
+
+ if (children || multiColumn) {
+ collapseButton = ;
+ }
+
+ return (
+
+
+
+
+ {title}
+
+
+
+ {backButton}
+ {collapseButton}
+
+
+
+
+
+ {(!collapsed || animating) && collapsedContent}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
new file mode 100644
index 000000000..2cf84f8f4
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class DisplayName extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+
+ return (
+
+ @{this.props.account.get('acct')}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
new file mode 100644
index 000000000..3a3ebf487
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -0,0 +1,211 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from './icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from '../features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class DropdownMenu extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ items: PropTypes.array.isRequired,
+ onClose: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ };
+
+ static defaultProps = {
+ style: {},
+ placement: 'bottom',
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ handleClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.props.onClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ renderItem (option, i) {
+ if (option === null) {
+ return ;
+ }
+
+ const { text, href = '#' } = option;
+
+ return (
+
+
+ {text}
+
+
+ );
+ }
+
+ render () {
+ const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
+
+
+ {items.map((option, i) => this.renderItem(option, i))}
+
+
+ )}
+
+ );
+ }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ icon: PropTypes.string.isRequired,
+ items: PropTypes.array.isRequired,
+ size: PropTypes.number.isRequired,
+ ariaLabel: PropTypes.string,
+ disabled: PropTypes.bool,
+ status: ImmutablePropTypes.map,
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ };
+
+ static defaultProps = {
+ ariaLabel: 'Menu',
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ handleClick = () => {
+ if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
+ const { status, items } = this.props;
+
+ this.props.onModalOpen({
+ status,
+ actions: items,
+ onClick: this.handleItemClick,
+ });
+
+ return;
+ }
+
+ this.setState({ expanded: !this.state.expanded });
+ }
+
+ handleClose = () => {
+ if (this.props.onModalClose) {
+ this.props.onModalClose();
+ }
+
+ this.setState({ expanded: false });
+ }
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Enter':
+ this.handleClick();
+ break;
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ }
+
+ handleItemClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.handleClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { icon, items, size, ariaLabel, disabled } = this.props;
+ const { expanded } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
new file mode 100644
index 000000000..f8bd067e8
--- /dev/null
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ExtendedVideoPlayer extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ time: PropTypes.number,
+ controls: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+ };
+
+ handleLoadedData = () => {
+ if (this.props.time) {
+ this.video.currentTime = this.props.time;
+ }
+ }
+
+ componentDidMount () {
+ this.video.addEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ componentWillUnmount () {
+ this.video.removeEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ setRef = (c) => {
+ this.video = c;
+ }
+
+ render () {
+ const { src, muted, controls, alt } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
new file mode 100644
index 000000000..06f53841d
--- /dev/null
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -0,0 +1,114 @@
+import React from 'react';
+import Motion from '../features/ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class IconButton extends React.PureComponent {
+
+ static propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ size: PropTypes.number,
+ active: PropTypes.bool,
+ pressed: PropTypes.bool,
+ expanded: PropTypes.bool,
+ style: PropTypes.object,
+ activeStyle: PropTypes.object,
+ disabled: PropTypes.bool,
+ inverted: PropTypes.bool,
+ animate: PropTypes.bool,
+ overlay: PropTypes.bool,
+ tabIndex: PropTypes.string,
+ };
+
+ static defaultProps = {
+ size: 18,
+ active: false,
+ disabled: false,
+ animate: false,
+ overlay: false,
+ tabIndex: '0',
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ render () {
+ const style = {
+ fontSize: `${this.props.size}px`,
+ width: `${this.props.size * 1.28571429}px`,
+ height: `${this.props.size * 1.28571429}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ ...(this.props.active ? this.props.activeStyle : {}),
+ };
+
+ const {
+ active,
+ animate,
+ className,
+ disabled,
+ expanded,
+ icon,
+ inverted,
+ overlay,
+ pressed,
+ tabIndex,
+ title,
+ } = this.props;
+
+ const classes = classNames(className, 'icon-button', {
+ active,
+ disabled,
+ inverted,
+ overlayed: overlay,
+ });
+
+ if (!animate) {
+ // Perf optimization: avoid unnecessary components unless
+ // we actually need to animate.
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {({ rotate }) =>
+
+
+
+ }
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
new file mode 100644
index 000000000..e2ce9ec96
--- /dev/null
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+import { is } from 'immutable';
+
+// Diff these props in the "rendered" state
+const updateOnPropsForRendered = ['id', 'index', 'listLength'];
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+ static propTypes = {
+ intersectionObserverWrapper: PropTypes.object.isRequired,
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ saveHeightKey: PropTypes.string,
+ cachedHeight: PropTypes.number,
+ onHeightChange: PropTypes.func,
+ children: PropTypes.node,
+ };
+
+ state = {
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+ const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+ if (!!isUnrendered !== !!willBeUnrendered) {
+ // If we're going from rendered to unrendered (or vice versa) then update
+ return true;
+ }
+ // Otherwise, diff based on props
+ const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
+ return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+ }
+
+ componentDidMount () {
+ const { intersectionObserverWrapper, id } = this.props;
+
+ intersectionObserverWrapper.observe(
+ id,
+ this.node,
+ this.handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ const { intersectionObserverWrapper, id } = this.props;
+ intersectionObserverWrapper.unobserve(id, this.node);
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ this.entry = entry;
+
+ scheduleIdleTask(this.calculateHeight);
+ this.setState(this.updateStateAfterIntersection);
+ }
+
+ updateStateAfterIntersection = (prevState) => {
+ if (prevState.isIntersecting && !this.entry.isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: this.entry.isIntersecting,
+ isHidden: false,
+ };
+ }
+
+ calculateHeight = () => {
+ const { onHeightChange, saveHeightKey, id } = this.props;
+ // save the height of the fully-rendered element (this is expensive
+ // on Chrome, where we need to fall back to getBoundingClientRect)
+ this.height = getRectFromEntry(this.entry).height;
+
+ if (onHeightChange && saveHeightKey) {
+ onHeightChange(saveHeightKey, id, this.height);
+ }
+ }
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/tootsuite/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ }
+
+ handleRef = (node) => {
+ this.node = node;
+ }
+
+ render () {
+ const { children, id, index, listLength, cachedHeight } = this.props;
+ const { isIntersecting, isHidden } = this.state;
+
+ if (!isIntersecting && (isHidden || cachedHeight)) {
+ return (
+
+ {children && React.cloneElement(children, { hidden: true })}
+
+ );
+ }
+
+ return (
+
+ {children && React.cloneElement(children, { hidden: false })}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
new file mode 100644
index 000000000..c4c8c94a2
--- /dev/null
+++ b/app/javascript/mastodon/components/load_more.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadMore extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func,
+ visible: PropTypes.bool,
+ }
+
+ static defaultProps = {
+ visible: true,
+ }
+
+ render() {
+ const { visible } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
new file mode 100644
index 000000000..d6a5adb6f
--- /dev/null
+++ b/app/javascript/mastodon/components/loading_indicator.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
new file mode 100644
index 000000000..20febdb16
--- /dev/null
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -0,0 +1,278 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { is } from 'immutable';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
+import classNames from 'classnames';
+import { autoPlayGif } from '../initial_state';
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+});
+
+class Item extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ standalone: PropTypes.bool,
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ onClick: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ index: 0,
+ size: 1,
+ };
+
+ handleMouseEnter = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ }
+
+ handleMouseLeave = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ }
+
+ hoverToPlay () {
+ const { attachment } = this.props;
+ return !autoPlayGif && attachment.get('type') === 'gifv';
+ }
+
+ handleClick = (e) => {
+ const { index, onClick } = this.props;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ }
+
+ render () {
+ const { attachment, index, size, standalone } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'image') {
+ const previewUrl = attachment.get('preview_url');
+ const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+ const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+ const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+ const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ const autoPlay = !isIOS() && autoPlayGif;
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ }
+
+ return (
+
+ {thumbnail}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class MediaGallery extends React.PureComponent {
+
+ static propTypes = {
+ sensitive: PropTypes.bool,
+ standalone: PropTypes.bool,
+ media: ImmutablePropTypes.list.isRequired,
+ size: PropTypes.object,
+ height: PropTypes.number.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ };
+
+ state = {
+ visible: !this.props.sensitive,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.media, this.props.media)) {
+ this.setState({ visible: !nextProps.sensitive });
+ }
+ }
+
+ handleOpen = () => {
+ this.setState({ visible: !this.state.visible });
+ }
+
+ handleClick = (index) => {
+ this.props.onOpenMedia(this.props.media, index);
+ }
+
+ handleRef = (node) => {
+ if (node && this.isStandaloneEligible()) {
+ // offsetWidth triggers a layout, so only calculate when we need to
+ this.setState({
+ width: node.offsetWidth,
+ });
+ }
+ }
+
+ isStandaloneEligible() {
+ const { media, standalone } = this.props;
+ return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+ }
+
+ render () {
+ const { media, intl, sensitive, height } = this.props;
+ const { width, visible } = this.state;
+
+ let children;
+
+ const style = {};
+
+ if (this.isStandaloneEligible()) {
+ if (!visible && width) {
+ // only need to forcibly set the height in "sensitive" mode
+ style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+ } else {
+ // layout automatically, using image's natural aspect ratio
+ style.height = '';
+ }
+ } else {
+ // crop the image
+ style.height = height;
+ }
+
+ if (!visible) {
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ children = (
+
+ {warning}
+
+
+ );
+ } else {
+ const size = media.take(4).size;
+
+ if (this.isStandaloneEligible()) {
+ children = ;
+ } else {
+ children = media.take(4).map((attachment, i) => );
+ }
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/mastodon/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js
new file mode 100644
index 000000000..d726d37a2
--- /dev/null
+++ b/app/javascript/mastodon/components/permalink.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Permalink extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ className: PropTypes.string,
+ href: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ };
+
+ handleClick = (e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(this.props.to);
+ }
+ }
+
+ render () {
+ const { href, children, className, ...other } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
new file mode 100644
index 000000000..51588e78c
--- /dev/null
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -0,0 +1,147 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+ just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+ seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+ minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+ hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+ days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
+const dateFormatOptions = {
+ hour12: false,
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+ month: 'numeric',
+ day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR = 1000 * 60 * 60;
+const DAY = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+ const absDelta = Math.abs(delta);
+
+ if (absDelta < MINUTE) {
+ return 'second';
+ } else if (absDelta < HOUR) {
+ return 'minute';
+ } else if (absDelta < DAY) {
+ return 'hour';
+ }
+
+ return 'day';
+};
+
+const getUnitDelay = units => {
+ switch (units) {
+ case 'second':
+ return SECOND;
+ case 'minute':
+ return MINUTE;
+ case 'hour':
+ return HOUR;
+ case 'day':
+ return DAY;
+ default:
+ return MAX_DELAY;
+ }
+};
+
+@injectIntl
+export default class RelativeTimestamp extends React.Component {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ timestamp: PropTypes.string.isRequired,
+ };
+
+ state = {
+ now: this.props.intl.now(),
+ };
+
+ shouldComponentUpdate (nextProps, nextState) {
+ // As of right now the locale doesn't change without a new page load,
+ // but we might as well check in case that ever changes.
+ return this.props.timestamp !== nextProps.timestamp ||
+ this.props.intl.locale !== nextProps.intl.locale ||
+ this.state.now !== nextState.now;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.timestamp !== nextProps.timestamp) {
+ this.setState({ now: this.props.intl.now() });
+ }
+ }
+
+ componentDidMount () {
+ this._scheduleNextUpdate(this.props, this.state);
+ }
+
+ componentWillUpdate (nextProps, nextState) {
+ this._scheduleNextUpdate(nextProps, nextState);
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this._timer);
+ }
+
+ _scheduleNextUpdate (props, state) {
+ clearTimeout(this._timer);
+
+ const { timestamp } = props;
+ const delta = (new Date(timestamp)).getTime() - state.now;
+ const unitDelay = getUnitDelay(selectUnits(delta));
+ const unitRemainder = Math.abs(delta % unitDelay);
+ const updateInterval = 1000 * 10;
+ const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+ this._timer = setTimeout(() => {
+ this.setState({ now: this.props.intl.now() });
+ }, delay);
+ }
+
+ render () {
+ const { timestamp, intl } = this.props;
+
+ const date = new Date(timestamp);
+ const delta = this.state.now - date.getTime();
+
+ let relativeTime;
+
+ if (delta < 10 * SECOND) {
+ relativeTime = intl.formatMessage(messages.just_now);
+ } else if (delta < 3 * DAY) {
+ if (delta < MINUTE) {
+ relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+ } else if (delta < HOUR) {
+ relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+ } else if (delta < DAY) {
+ relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+ } else {
+ relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+ }
+ } else {
+ relativeTime = intl.formatDate(date, shortDateFormatOptions);
+ }
+
+ return (
+
+ {relativeTime}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
new file mode 100644
index 000000000..71228ca6c
--- /dev/null
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -0,0 +1,198 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll-4';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
+
+export default class ScrollableList extends PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ state = {
+ lastMouseMove: null,
+ };
+
+ intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+ handleScroll = throttle(() => {
+ if (this.node) {
+ const { scrollTop, scrollHeight, clientHeight } = this.node;
+ const offset = scrollHeight - scrollTop - clientHeight;
+ this._oldScrollPosition = scrollHeight - scrollTop;
+
+ if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+ this.props.onScrollToBottom();
+ } else if (scrollTop < 100 && this.props.onScrollToTop) {
+ this.props.onScrollToTop();
+ } else if (this.props.onScroll) {
+ this.props.onScroll();
+ }
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ handleMouseMove = throttle(() => {
+ this._lastMouseMove = new Date();
+ }, 300);
+
+ handleMouseLeave = () => {
+ this._lastMouseMove = null;
+ }
+
+ componentDidMount () {
+ this.attachScrollListener();
+ this.attachIntersectionObserver();
+ attachFullscreenListener(this.onFullScreenChange);
+
+ // Handle initial scroll posiiton
+ this.handleScroll();
+ }
+
+ componentDidUpdate (prevProps) {
+ const someItemInserted = React.Children.count(prevProps.children) > 0 &&
+ React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
+ this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+
+ // Reset the scroll position when a new child comes in in order not to
+ // jerk the scrollbar around if you're already scrolled down the page.
+ if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
+ const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+
+ if (this.node.scrollTop !== newScrollTop) {
+ this.node.scrollTop = newScrollTop;
+ }
+ } else {
+ this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+ }
+ }
+
+ componentWillUnmount () {
+ this.detachScrollListener();
+ this.detachIntersectionObserver();
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ attachIntersectionObserver () {
+ this.intersectionObserverWrapper.connect({
+ root: this.node,
+ rootMargin: '300% 0px',
+ });
+ }
+
+ detachIntersectionObserver () {
+ this.intersectionObserverWrapper.disconnect();
+ }
+
+ attachScrollListener () {
+ this.node.addEventListener('scroll', this.handleScroll);
+ }
+
+ detachScrollListener () {
+ this.node.removeEventListener('scroll', this.handleScroll);
+ }
+
+ getFirstChildKey (props) {
+ const { children } = props;
+ let firstChild = children;
+ if (children instanceof ImmutableList) {
+ firstChild = children.get(0);
+ } else if (Array.isArray(children)) {
+ firstChild = children[0];
+ }
+ return firstChild && firstChild.key;
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.onScrollToBottom();
+ }
+
+ _recentlyMoved () {
+ return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
+ }
+
+ render () {
+ const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+ const { fullscreen } = this.state;
+ const childrenCount = React.Children.count(children);
+
+ const loadMore = (hasMore && childrenCount > 0) ? : null;
+ let scrollableArea = null;
+
+ if (isLoading || childrenCount > 0 || !emptyMessage) {
+ scrollableArea = (
+
+
+ {prepend}
+
+ {React.Children.map(this.props.children, (child, index) => (
+
+ {child}
+
+ ))}
+
+ {loadMore}
+
+
+ );
+ } else {
+ scrollableArea = (
+
+ {emptyMessage}
+
+ );
+ }
+
+ if (trackScroll) {
+ return (
+
+ {scrollableArea}
+
+ );
+ } else {
+ return scrollableArea;
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/setting_text.js b/app/javascript/mastodon/components/setting_text.js
new file mode 100644
index 000000000..a6dde4c0f
--- /dev/null
+++ b/app/javascript/mastodon/components/setting_text.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class SettingText extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ settingKey: PropTypes.array.isRequired,
+ label: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(this.props.settingKey, e.target.value);
+ }
+
+ render () {
+ const { settings, settingKey, label } = this.props;
+
+ return (
+
+ {label}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
new file mode 100644
index 000000000..d23ff87fa
--- /dev/null
+++ b/app/javascript/mastodon/components/status.js
@@ -0,0 +1,246 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import RelativeTimestamp from './relative_timestamp';
+import DisplayName from './display_name';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video } from '../features/ui/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.map,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onPin: PropTypes.func,
+ onOpenMedia: PropTypes.func,
+ onOpenVideo: PropTypes.func,
+ onBlock: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onHeightChange: PropTypes.func,
+ muted: PropTypes.bool,
+ hidden: PropTypes.bool,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+ };
+
+ state = {
+ isExpanded: false,
+ }
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'muted',
+ 'hidden',
+ ]
+
+ updateOnStates = ['isExpanded']
+
+ handleClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+ }
+
+ handleAccountClick = (e) => {
+ if (this.context.router && e.button === 0) {
+ const id = e.currentTarget.getAttribute('data-id');
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${id}`);
+ }
+ }
+
+ handleExpandedToggle = () => {
+ this.setState({ isExpanded: !this.state.isExpanded });
+ };
+
+ renderLoadingMediaGallery () {
+ return
;
+ }
+
+ renderLoadingVideoPlayer () {
+ return
;
+ }
+
+ handleOpenVideo = startTime => {
+ this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
+ }
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.props.onReply(this._properStatus(), this.context.router.history);
+ }
+
+ handleHotkeyFavourite = () => {
+ this.props.onFavourite(this._properStatus());
+ }
+
+ handleHotkeyBoost = e => {
+ this.props.onReblog(this._properStatus(), e);
+ }
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.props.onMention(this._properStatus().get('account'), this.context.router.history);
+ }
+
+ handleHotkeyOpen = () => {
+ this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
+ }
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
+ }
+
+ handleHotkeyMoveUp = () => {
+ this.props.onMoveUp(this.props.status.get('id'));
+ }
+
+ handleHotkeyMoveDown = () => {
+ this.props.onMoveDown(this.props.status.get('id'));
+ }
+
+ _properStatus () {
+ const { status } = this.props;
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ return status.get('reblog');
+ } else {
+ return status;
+ }
+ }
+
+ render () {
+ let media = null;
+ let statusAvatar, prepend;
+
+ const { hidden } = this.props;
+ const { isExpanded } = this.state;
+
+ let { status, account, ...other } = this.props;
+
+ if (status === null) {
+ return null;
+ }
+
+ if (hidden) {
+ return (
+
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {status.get('content')}
+
+ );
+ }
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
+
+ prepend = (
+
+ );
+
+ account = status.get('account');
+ status = status.get('reblog');
+ }
+
+ if (status.get('media_attachments').size > 0 && !this.props.muted) {
+ if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const video = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ {Component => }
+
+ );
+ } else {
+ media = (
+
+ {Component => }
+
+ );
+ }
+ }
+
+ if (account === undefined || account === null) {
+ statusAvatar = ;
+ }else{
+ statusAvatar = ;
+ }
+
+ const handlers = this.props.muted ? {} : {
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ open: this.handleHotkeyOpen,
+ openProfile: this.handleHotkeyOpenProfile,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ };
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
new file mode 100644
index 000000000..7021c198e
--- /dev/null
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -0,0 +1,188 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenuContainer from '../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from '../initial_state';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ more: { id: 'status.more', defaultMessage: 'More' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onReport: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ onPin: PropTypes.func,
+ withDismiss: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'withDismiss',
+ ]
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleShareClick = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleMuteClick = () => {
+ this.props.onMute(this.props.status.get('account'));
+ }
+
+ handleBlockClick = () => {
+ this.props.onBlock(this.props.status.get('account'));
+ }
+
+ handleOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ }
+
+ render () {
+ const { status, intl, withDismiss } = this.props;
+
+ const mutingConversation = status.get('muted');
+ const anonymousAccess = !me;
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+ let reblogIcon = 'retweet';
+ let replyIcon;
+ let replyTitle;
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ menu.push(null);
+
+ if (status.getIn(['account', 'id']) === me || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (status.getIn(['account', 'id']) === me) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ if (status.get('visibility') === 'direct') {
+ reblogIcon = 'envelope';
+ } else if (status.get('visibility') === 'private') {
+ reblogIcon = 'lock';
+ }
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+
+ );
+
+ return (
+
+
+
+
+ {shareButton}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
new file mode 100644
index 000000000..3b8155632
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.js
@@ -0,0 +1,185 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from '../rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+import classnames from 'classnames';
+
+export default class StatusContent extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ expanded: PropTypes.bool,
+ onExpandedToggle: PropTypes.func,
+ onClick: PropTypes.func,
+ };
+
+ state = {
+ hidden: true,
+ };
+
+ _updateStatusLinks () {
+ const node = this.node;
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+ link.classList.add('status-link');
+
+ let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.setAttribute('title', link.href);
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener');
+ }
+ }
+
+ componentDidMount () {
+ this._updateStatusLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateStatusLinks();
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleMouseDown = (e) => {
+ this.startXY = [e.clientX, e.clientY];
+ }
+
+ handleMouseUp = (e) => {
+ if (!this.startXY) {
+ return;
+ }
+
+ const [ startX, startY ] = this.startXY;
+ const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+ if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+ return;
+ }
+
+ if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
+ this.props.onClick();
+ }
+
+ this.startXY = null;
+ }
+
+ handleSpoilerClick = (e) => {
+ e.preventDefault();
+
+ if (this.props.onExpandedToggle) {
+ // The parent manages the state
+ this.props.onExpandedToggle();
+ } else {
+ this.setState({ hidden: !this.state.hidden });
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const { status } = this.props;
+
+ const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
+
+ const content = { __html: status.get('contentHtml') };
+ const spoilerContent = { __html: status.get('spoilerHtml') };
+ const directionStyle = { direction: 'ltr' };
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': this.props.onClick && this.context.router,
+ 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+ });
+
+ if (isRtl(status.get('search_index'))) {
+ directionStyle.direction = 'rtl';
+ }
+
+ if (status.get('spoiler_text').length > 0) {
+ let mentionsPlaceholder = '';
+
+ const mentionLinks = status.get('mentions').map(item => (
+
+ @{item.get('username')}
+
+ )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+ const toggleText = hidden ? : ;
+
+ if (hidden) {
+ mentionsPlaceholder = {mentionLinks}
;
+ }
+
+ return (
+
+
+
+ {' '}
+ {toggleText}
+
+
+ {mentionsPlaceholder}
+
+
+
+ );
+ } else if (this.props.onClick) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
new file mode 100644
index 000000000..58a7b228a
--- /dev/null
+++ b/app/javascript/mastodon/components/status_list.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusContainer from '../containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from './scrollable_list';
+
+export default class StatusList extends ImmutablePureComponent {
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ handleMoveUp = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) - 1;
+ this._selectChild(elementIndex);
+ }
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) + 1;
+ this._selectChild(elementIndex);
+ }
+
+ _selectChild (index) {
+ const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { statusIds, ...other } = this.props;
+ const { isLoading } = other;
+
+ const scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map((statusId) => (
+
+ ))
+ ) : null;
+
+ return (
+
+ {scrollableContent}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
new file mode 100644
index 000000000..5a5136dd1
--- /dev/null
+++ b/app/javascript/mastodon/containers/account_container.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from '../selectors';
+import Account from '../components/account';
+import {
+ followAccount,
+ unfollowAccount,
+ blockAccount,
+ unblockAccount,
+ muteAccount,
+ unmuteAccount,
+} from '../actions/accounts';
+import { openModal } from '../actions/modal';
+import { initMuteModal } from '../actions/mutes';
+import { unfollowModal } from '../initial_state';
+
+const messages = defineMessages({
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.unfollowConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(blockAccount(account.get('id')));
+ }
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+
+ onMuteNotifications (account, notifications) {
+ dispatch(muteAccount(account.get('id'), notifications));
+ },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/mastodon/containers/card_container.js b/app/javascript/mastodon/containers/card_container.js
new file mode 100644
index 000000000..11b9f88d4
--- /dev/null
+++ b/app/javascript/mastodon/containers/card_container.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Card from '../features/status/components/card';
+import { fromJS } from 'immutable';
+
+export default class CardContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string,
+ card: PropTypes.array.isRequired,
+ };
+
+ render () {
+ const { card, ...props } = this.props;
+ return ;
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js
new file mode 100644
index 000000000..5ee1d2f14
--- /dev/null
+++ b/app/javascript/mastodon/containers/compose_container.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { hydrateStore } from '../actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Compose from '../features/standalone/compose';
+import initialState from '../initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+ store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..151f25390
--- /dev/null
+++ b/app/javascript/mastodon/containers/dropdown_menu_container.js
@@ -0,0 +1,16 @@
+import { openModal, closeModal } from '../actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from '../components/dropdown_menu';
+import { isUserTouching } from '../is_mobile';
+
+const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
+});
+
+const mapDispatchToProps = dispatch => ({
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/mastodon/containers/intersection_observer_article_container.js b/app/javascript/mastodon/containers/intersection_observer_article_container.js
new file mode 100644
index 000000000..b6f162199
--- /dev/null
+++ b/app/javascript/mastodon/containers/intersection_observer_article_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import IntersectionObserverArticle from '../components/intersection_observer_article';
+import { setHeight } from '../actions/height_cache';
+
+const makeMapStateToProps = (state, props) => ({
+ cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+ onHeightChange (key, id, height) {
+ dispatch(setHeight(key, id, height));
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
new file mode 100644
index 000000000..d1710445b
--- /dev/null
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { showOnboardingOnce } from '../actions/onboarding';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import UI from '../features/ui';
+import { hydrateStore } from '../actions/store';
+import { connectUserStream } from '../actions/streaming';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import initialState from '../initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+store.dispatch(hydrateAction);
+
+export default class Mastodon extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ componentDidMount() {
+ this.disconnect = store.dispatch(connectUserStream());
+
+ // Desktop notifications
+ // Ask after 1 minute
+ if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
+ window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
+ }
+
+ // Protocol handler
+ // Ask after 5 minutes
+ if (typeof navigator.registerProtocolHandler !== 'undefined') {
+ const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
+ window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
+ }
+
+ store.dispatch(showOnboardingOnce());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ render () {
+ const { locale } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/media_gallery_container.js b/app/javascript/mastodon/containers/media_gallery_container.js
new file mode 100644
index 000000000..812c3d4e5
--- /dev/null
+++ b/app/javascript/mastodon/containers/media_gallery_container.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import MediaGallery from '../components/media_gallery';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class MediaGalleryContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ media: PropTypes.array.isRequired,
+ };
+
+ handleOpenMedia = () => {}
+
+ render () {
+ const { locale, media, ...props } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
new file mode 100644
index 000000000..b22540204
--- /dev/null
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -0,0 +1,133 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from '../components/status';
+import { makeGetStatus } from '../selectors';
+import {
+ replyCompose,
+ mentionCompose,
+} from '../actions/compose';
+import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+ pin,
+ unpin,
+} from '../actions/interactions';
+import { blockAccount } from '../actions/accounts';
+import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
+import { initMuteModal } from '../actions/mutes';
+import { initReport } from '../actions/reports';
+import { openModal } from '../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from '../initial_state';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch(replyCompose(status, router));
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+ }
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onPin (status) {
+ if (status.get('pinned')) {
+ dispatch(unpin(status));
+ } else {
+ dispatch(pin(status));
+ }
+ },
+
+ onEmbed (status) {
+ dispatch(openModal('EMBED', { url: status.get('url') }));
+ },
+
+ onDelete (status) {
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideo (media, time) {
+ dispatch(openModal('VIDEO', { media, time }));
+ },
+
+ onBlock (account) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onMute (account) {
+ dispatch(initMuteModal(account));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
new file mode 100644
index 000000000..e84c921ee
--- /dev/null
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { hydrateStore } from '../actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import PublicTimeline from '../features/standalone/public_timeline';
+import HashtagTimeline from '../features/standalone/hashtag_timeline';
+import initialState from '../initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+ store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ hashtag: PropTypes.string,
+ };
+
+ render () {
+ const { locale, hashtag } = this.props;
+
+ let timeline;
+
+ if (hashtag) {
+ timeline = ;
+ } else {
+ timeline = ;
+ }
+
+ return (
+
+
+ {timeline}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/video_container.js b/app/javascript/mastodon/containers/video_container.js
new file mode 100644
index 000000000..2fd353096
--- /dev/null
+++ b/app/javascript/mastodon/containers/video_container.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Video from '../features/video';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class VideoContainer extends React.PureComponent {
+
+ static propTypes = {
+ locale: PropTypes.string.isRequired,
+ };
+
+ render () {
+ const { locale, ...props } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js
new file mode 100644
index 000000000..3acc55abd
--- /dev/null
+++ b/app/javascript/mastodon/extra_polyfills.js
@@ -0,0 +1,5 @@
+import 'intersection-observer';
+import 'requestidlecallback';
+import objectFitImages from 'object-fit-images';
+
+objectFitImages();
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
new file mode 100644
index 000000000..e375131d4
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -0,0 +1,133 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { Link } from 'react-router-dom';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me } from '../../../initial_state';
+
+const messages = defineMessages({
+ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+ edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+ share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
+ media: { id: 'account.media', defaultMessage: 'Media' },
+ blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
+ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onFollow: PropTypes.func,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleShare = () => {
+ navigator.share({
+ url: this.props.account.get('url'),
+ });
+ }
+
+ render () {
+ const { account, intl } = this.props;
+
+ let menu = [];
+ let extraInfo = '';
+
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+ if ('share' in navigator) {
+ menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+ }
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
+ menu.push(null);
+
+ if (account.get('id') === me) {
+ menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+ } else {
+ if (account.getIn(['relationship', 'muting'])) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+ }
+
+ if (account.getIn(['relationship', 'blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+ }
+
+ if (account.get('acct') !== account.get('username')) {
+ const domain = account.get('acct').split('@')[1];
+
+ extraInfo = (
+
+ );
+
+ menu.push(null);
+
+ if (account.getIn(['relationship', 'domain_blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
+ }
+ }
+
+ return (
+
+ {extraInfo}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
new file mode 100644
index 000000000..f0d2d481f
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -0,0 +1,128 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { autoPlayGif, me } from '../../../initial_state';
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+});
+
+class Avatar extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ isHovered: false,
+ };
+
+ handleMouseOver = () => {
+ if (this.state.isHovered) return;
+ this.setState({ isHovered: true });
+ }
+
+ handleMouseOut = () => {
+ if (!this.state.isHovered) return;
+ this.setState({ isHovered: false });
+ }
+
+ render () {
+ const { account } = this.props;
+ const { isHovered } = this.state;
+
+ return (
+
+ {({ radius }) =>
+
+ {account.get('acct')}
+
+ }
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { account, intl } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ let info = '';
+ let actionBtn = '';
+ let lockedIcon = '';
+
+ if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+ info = ;
+ }
+
+ if (me !== account.get('id')) {
+ if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = (
+
+
+
+ );
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ actionBtn = (
+
+
+
+ );
+ }
+ }
+
+ if (account.get('locked')) {
+ lockedIcon = ;
+ }
+
+ const content = { __html: account.get('note_emojified') };
+ const displayNameHtml = { __html: account.get('display_name_html') };
+
+ return (
+
+
+
+
+
+
@{account.get('acct')} {lockedIcon}
+
+
+ {info}
+ {actionBtn}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
new file mode 100644
index 000000000..dda3d4e37
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Permalink from '../../../components/permalink';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { media } = this.props;
+ const status = media.get('status');
+
+ let content, style;
+
+ if (media.get('type') === 'gifv') {
+ content = GIF ;
+ }
+
+ if (!status.get('sensitive')) {
+ style = { backgroundImage: `url(${media.get('preview_url')})` };
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
new file mode 100644
index 000000000..a40722417
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -0,0 +1,111 @@
+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 { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from '../../selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import { FormattedMessage } from 'react-intl';
+import { ScrollContainer } from 'react-router-scroll-4';
+import LoadMore from '../../components/load_more';
+
+const mapStateToProps = (state, props) => ({
+ medias: getAccountGallery(state, props.params.accountId),
+ isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+ hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountGallery extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ medias: ImmutablePropTypes.list.isRequired,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentDidMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+ }
+ }
+
+ handleScrollToBottom = () => {
+ if (this.props.hasMore) {
+ this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+ const offset = scrollHeight - scrollTop - clientHeight;
+
+ if (150 > offset && !this.props.isLoading) {
+ this.handleScrollToBottom();
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.handleScrollToBottom();
+ }
+
+ render () {
+ const { medias, isLoading, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!medias && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoading && medias.size > 0 && hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {medias.map(media =>
+
+ )}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
new file mode 100644
index 000000000..8cf7b92ca
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -0,0 +1,89 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from '../../account/components/header';
+import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ onBlockDomain: PropTypes.func.isRequired,
+ onUnblockDomain: PropTypes.func.isRequired,
+ };
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMention = () => {
+ this.props.onMention(this.props.account, this.context.router.history);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ handleBlockDomain = () => {
+ const domain = this.props.account.get('acct').split('@')[1];
+
+ if (!domain) return;
+
+ this.props.onBlockDomain(domain, this.props.account.get('id'));
+ }
+
+ handleUnblockDomain = () => {
+ const domain = this.props.account.get('acct').split('@')[1];
+
+ if (!domain) return;
+
+ this.props.onUnblockDomain(domain, this.props.account.get('id'));
+ }
+
+ render () {
+ const { account } = this.props;
+
+ if (account === null) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..8e50ec405
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import Header from '../components/header';
+import {
+ followAccount,
+ unfollowAccount,
+ blockAccount,
+ unblockAccount,
+ unmuteAccount,
+} from '../../../actions/accounts';
+import { mentionCompose } from '../../../actions/compose';
+import { initMuteModal } from '../../../actions/mutes';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from '../../../initial_state';
+
+const messages = defineMessages({
+ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+ blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { accountId }) => ({
+ account: getAccount(state, accountId),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onFollow (account) {
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ if (unfollowModal) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.unfollowConfirm),
+ onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+ }));
+ } else {
+ dispatch(unfollowAccount(account.get('id')));
+ }
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ },
+
+ onBlock (account) {
+ if (account.getIn(['relationship', 'blocking'])) {
+ dispatch(unblockAccount(account.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onReport (account) {
+ dispatch(initReport(account));
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ },
+
+ onBlockDomain (domain, accountId) {
+ dispatch(openModal('CONFIRM', {
+ message: {domain} }} />,
+ confirm: intl.formatMessage(messages.blockDomainConfirm),
+ onConfirm: () => dispatch(blockDomain(domain, accountId)),
+ }));
+ },
+
+ onUnblockDomain (domain, accountId) {
+ dispatch(unblockDomain(domain, accountId));
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
new file mode 100644
index 000000000..f8c85c296
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -0,0 +1,77 @@
+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 { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+ isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+ hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountTimeline extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
+ }
+ }
+
+ handleScrollToBottom = () => {
+ if (!this.props.isLoading && this.props.hasMore) {
+ this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
+ }
+ }
+
+ render () {
+ const { statusIds, isLoading, hasMore } = this.props;
+
+ if (!statusIds && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ scrollKey='account_timeline'
+ statusIds={statusIds}
+ isLoading={isLoading}
+ hasMore={hasMore}
+ onScrollToBottom={this.handleScrollToBottom}
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
new file mode 100644
index 000000000..14a512ae8
--- /dev/null
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchBlocks, expandBlocks } from '../../actions/blocks';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'blocks', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Blocks extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchBlocks());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandBlocks());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
new file mode 100644
index 000000000..a992b27bb
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from '../../../components/setting_text';
+
+const messages = defineMessages({
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+ settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { settings, onChange, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..f3489b409
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'community']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['community', ...key], checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
new file mode 100644
index 000000000..596a89412
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import {
+ refreshCommunityTimeline,
+ expandCommunityTimeline,
+} from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from '../../actions/streaming';
+
+const messages = defineMessages({
+ title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columnId: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('COMMUNITY', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshCommunityTimeline());
+ this.disconnect = dispatch(connectCommunityStream());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandCommunityTimeline());
+ }
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..e7de3716b
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { account } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/character_counter.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.string.isRequired,
+ max: PropTypes.number.isRequired,
+ };
+
+ checkRemainingText (diff) {
+ if (diff < 0) {
+ return {diff} ;
+ }
+
+ return {diff} ;
+ }
+
+ render () {
+ const diff = this.props.max - length(this.props.text);
+ return this.checkRemainingText(diff);
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
new file mode 100644
index 000000000..7890755f3
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -0,0 +1,212 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from '../../../components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import UploadButtonContainer from '../containers/upload_button_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import Collapsable from '../../../components/collapsable';
+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 UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from '../../../is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { length } from 'stringz';
+import { countableText } from '../util/counter';
+
+const messages = defineMessages({
+ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+ spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+ publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
+ publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+});
+
+@injectIntl
+export default class ComposeForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ text: PropTypes.string.isRequired,
+ suggestion_token: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ spoiler: PropTypes.bool,
+ privacy: PropTypes.string,
+ spoiler_text: PropTypes.string,
+ focusDate: PropTypes.instanceOf(Date),
+ preselectDate: PropTypes.instanceOf(Date),
+ is_submitting: PropTypes.bool,
+ is_uploading: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onChangeSpoilerText: PropTypes.func.isRequired,
+ onPaste: PropTypes.func.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ showSearch: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ showSearch: false,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ }
+
+ handleKeyDown = (e) => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ this.handleSubmit();
+ }
+ }
+
+ handleSubmit = () => {
+ if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+ // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+ // Update the state to match the current text
+ this.props.onChange(this.autosuggestTextarea.textarea.value);
+ }
+
+ this.props.onSubmit();
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.props.onClearSuggestions();
+ }
+
+ onSuggestionsFetchRequested = (token) => {
+ this.props.onFetchSuggestions(token);
+ }
+
+ onSuggestionSelected = (tokenStart, token, value) => {
+ this._restoreCaret = null;
+ this.props.onSuggestionSelected(tokenStart, token, value);
+ }
+
+ handleChangeSpoilerText = (e) => {
+ this.props.onChangeSpoilerText(e.target.value);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ // If this is the update where we've finished uploading,
+ // save the last caret position so we can restore it below!
+ if (!nextProps.is_uploading && this.props.is_uploading) {
+ this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ // This statement does several things:
+ // - If we're beginning a reply, and,
+ // - Replying to zero or one users, places the cursor at the end of the textbox.
+ // - Replying to more than one user, selects any usernames past the first;
+ // this provides a convenient shortcut to drop everyone else from the conversation.
+ // - If we've just finished uploading an image, and have a saved caret position,
+ // restores the cursor to that position after the text changes!
+ if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+ let selectionEnd, selectionStart;
+
+ if (this.props.preselectDate !== prevProps.preselectDate) {
+ selectionEnd = this.props.text.length;
+ selectionStart = this.props.text.search(/\s/) + 1;
+ } else if (typeof this._restoreCaret === 'number') {
+ selectionStart = this._restoreCaret;
+ selectionEnd = this._restoreCaret;
+ } else {
+ selectionEnd = this.props.text.length;
+ selectionStart = selectionEnd;
+ }
+
+ this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+ this.autosuggestTextarea.textarea.focus();
+ } else if(prevProps.is_submitting && !this.props.is_submitting) {
+ this.autosuggestTextarea.textarea.focus();
+ }
+ }
+
+ setAutosuggestTextarea = (c) => {
+ this.autosuggestTextarea = c;
+ }
+
+ handleEmojiPick = (data) => {
+ const position = this.autosuggestTextarea.textarea.selectionStart;
+ const emojiChar = data.native;
+ this._restoreCaret = position + emojiChar.length + 1;
+ this.props.onPickEmoji(position, data);
+ }
+
+ render () {
+ const { intl, onPaste, showSearch } = this.props;
+ const disabled = this.props.is_submitting;
+ const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
+
+ let publishText = '';
+
+ if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+ publishText = {intl.formatMessage(messages.publish)} ;
+ } else {
+ publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+ }
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.spoiler_placeholder)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
500 || (text.length !== 0 && text.trim().length === 0)} block />
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..dc8fc02ba
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,376 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from '../../emoji/emoji';
+
+const messages = defineMessages({
+ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+ emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+ emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+ custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+ recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+ search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+ people: { id: 'emoji_button.people', defaultMessage: 'People' },
+ nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+ food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+ activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+ travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+ objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+ symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+const categoriesSort = [
+ 'recent',
+ 'custom',
+ 'people',
+ 'nature',
+ 'foods',
+ 'activity',
+ 'places',
+ 'objects',
+ 'symbols',
+ 'flags',
+];
+
+class ModifierPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onSelect: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ handleClick = e => {
+ this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.active) {
+ this.attachListeners();
+ } else {
+ this.removeListeners();
+ }
+ }
+
+ componentWillUnmount () {
+ this.removeListeners();
+ }
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ attachListeners () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ removeListeners () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ modifier: PropTypes.number,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ onOpen: PropTypes.func,
+ };
+
+ handleClick = () => {
+ if (this.props.active) {
+ this.props.onClose();
+ } else {
+ this.props.onOpen();
+ }
+ }
+
+ handleSelect = modifier => {
+ this.props.onChange(modifier);
+ this.props.onClose();
+ }
+
+ render () {
+ const { active, modifier } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ loading: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ onPick: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ style: {},
+ loading: true,
+ placement: 'bottom',
+ frequentlyUsedEmojis: [],
+ };
+
+ state = {
+ modifierOpen: false,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ getI18n = () => {
+ const { intl } = this.props;
+
+ return {
+ search: intl.formatMessage(messages.emoji_search),
+ notfound: intl.formatMessage(messages.emoji_not_found),
+ categories: {
+ search: intl.formatMessage(messages.search_results),
+ recent: intl.formatMessage(messages.recent),
+ people: intl.formatMessage(messages.people),
+ nature: intl.formatMessage(messages.nature),
+ foods: intl.formatMessage(messages.food),
+ activity: intl.formatMessage(messages.activity),
+ places: intl.formatMessage(messages.travel),
+ objects: intl.formatMessage(messages.objects),
+ symbols: intl.formatMessage(messages.symbols),
+ flags: intl.formatMessage(messages.flags),
+ custom: intl.formatMessage(messages.custom),
+ },
+ };
+ }
+
+ handleClick = emoji => {
+ if (!emoji.native) {
+ emoji.native = emoji.colons;
+ }
+
+ this.props.onClose();
+ this.props.onPick(emoji);
+ }
+
+ handleModifierOpen = () => {
+ this.setState({ modifierOpen: true });
+ }
+
+ handleModifierClose = () => {
+ this.setState({ modifierOpen: false });
+ }
+
+ handleModifierChange = modifier => {
+ this.props.onSkinTone(modifier);
+ }
+
+ render () {
+ const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+ if (loading) {
+ return
;
+ }
+
+ const title = intl.formatMessage(messages.emoji);
+ const { modifierOpen } = this.state;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class EmojiPickerDropdown extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ intl: PropTypes.object.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ };
+
+ state = {
+ active: false,
+ loading: false,
+ };
+
+ setRef = (c) => {
+ this.dropdown = c;
+ }
+
+ onShowDropdown = () => {
+ this.setState({ active: true });
+
+ if (!EmojiPicker) {
+ this.setState({ loading: true });
+
+ EmojiPickerAsync().then(EmojiMart => {
+ EmojiPicker = EmojiMart.Picker;
+ Emoji = EmojiMart.Emoji;
+
+ this.setState({ loading: false });
+ }).catch(() => {
+ this.setState({ loading: false });
+ });
+ }
+ }
+
+ onHideDropdown = () => {
+ this.setState({ active: false });
+ }
+
+ onToggle = (e) => {
+ if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+ if (this.state.active) {
+ this.onHideDropdown();
+ } else {
+ this.onShowDropdown();
+ }
+ }
+ }
+
+ handleKeyDown = e => {
+ if (e.key === 'Escape') {
+ this.onHideDropdown();
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+ const title = intl.formatMessage(messages.emoji);
+ const { active, loading } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..7f346854c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import Permalink from '../../../components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+
+ {this.props.account.get('acct')}
+
+
+
+
+
+ @{this.props.account.get('acct')}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..c1e85aee3
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,200 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+ unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+ private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+ direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+ change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+ static propTypes = {
+ style: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ value: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ handleClick = e => {
+ if (e.key === 'Escape') {
+ this.props.onClose();
+ } else if (!e.key || e.key === 'Enter') {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onClose();
+ this.props.onChange(value);
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { style, items, value } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ {items.map(item =>
+
+
+
+
+
+
+ {item.text}
+ {item.meta}
+
+
+ )}
+
+ )}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class PrivacyDropdown extends React.PureComponent {
+
+ static propTypes = {
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: false,
+ };
+
+ handleToggle = () => {
+ if (this.props.isUserTouching()) {
+ if (this.state.open) {
+ this.props.onModalClose();
+ } else {
+ this.props.onModalOpen({
+ actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+ onClick: this.handleModalActionClick,
+ });
+ }
+ } else {
+ this.setState({ open: !this.state.open });
+ }
+ }
+
+ handleModalActionClick = (e) => {
+ e.preventDefault();
+
+ const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
+ this.props.onModalClose();
+ this.props.onChange(value);
+ }
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Enter':
+ this.handleToggle();
+ break;
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ }
+
+ handleClose = () => {
+ this.setState({ open: false });
+ }
+
+ handleChange = value => {
+ this.props.onChange(value);
+ }
+
+ componentWillMount () {
+ const { intl: { formatMessage } } = this.props;
+
+ this.options = [
+ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+ { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+ { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+ ];
+ }
+
+ render () {
+ const { value, intl } = this.props;
+ const { open } = this.state;
+
+ const valueOption = this.options.find(item => item.value === value);
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..7672440b4
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+@injectIntl
+export default class ReplyIndicator extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ onCancel: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onCancel();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
new file mode 100644
index 000000000..398fc44ce
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+ static propTypes = {
+ style: PropTypes.object,
+ };
+
+ render () {
+ const { style } = this.props;
+
+ return (
+
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
+
+
+ #example
+ @username@domain
+ URL
+ URL
+
+
+
+
+ )}
+
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class Search extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.string.isRequired,
+ submitted: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ onShow: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(e.target.value);
+ }
+
+ handleClear = (e) => {
+ e.preventDefault();
+
+ if (this.props.value.length > 0 || this.props.submitted) {
+ this.props.onClear();
+ }
+ }
+
+ handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ this.props.onSubmit();
+ } else if (e.key === 'Escape') {
+ document.querySelector('.ui').parentElement.focus();
+ }
+ }
+
+ noop () {
+
+ }
+
+ handleFocus = () => {
+ this.setState({ expanded: true });
+ this.props.onShow();
+ }
+
+ handleBlur = () => {
+ this.setState({ expanded: false });
+ }
+
+ render () {
+ const { intl, value, submitted } = this.props;
+ const { expanded } = this.state;
+ const hasValue = value.length > 0 || submitted;
+
+ return (
+
+
+ {intl.formatMessage(messages.placeholder)}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
new file mode 100644
index 000000000..8350d20a5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import StatusContainer from '../../../containers/status_container';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class SearchResults extends ImmutablePureComponent {
+
+ static propTypes = {
+ results: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const { results } = this.props;
+
+ let accounts, statuses, hashtags;
+ let count = 0;
+
+ if (results.get('accounts') && results.get('accounts').size > 0) {
+ count += results.get('accounts').size;
+ accounts = (
+
+ {results.get('accounts').map(accountId =>
)}
+
+ );
+ }
+
+ if (results.get('statuses') && results.get('statuses').size > 0) {
+ count += results.get('statuses').size;
+ statuses = (
+
+ {results.get('statuses').map(statusId => )}
+
+ );
+ }
+
+ if (results.get('hashtags') && results.get('hashtags').size > 0) {
+ count += results.get('hashtags').size;
+ hashtags = (
+
+ {results.get('hashtags').map(hashtag =>
+
+ #{hashtag}
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accounts}
+ {statuses}
+ {hashtags}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TextIconButton extends React.PureComponent {
+
+ static propTypes = {
+ label: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ active: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ ariaControls: PropTypes.string,
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+ this.props.onClick();
+ }
+
+ render () {
+ const { label, title, active, ariaControls } = this.props;
+
+ return (
+
+ {label}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
new file mode 100644
index 000000000..6ab76492a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+ description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ onUndo: PropTypes.func.isRequired,
+ onDescriptionChange: PropTypes.func.isRequired,
+ };
+
+ state = {
+ hovered: false,
+ focused: false,
+ dirtyDescription: null,
+ };
+
+ handleUndoClick = () => {
+ this.props.onUndo(this.props.media.get('id'));
+ }
+
+ handleInputChange = e => {
+ this.setState({ dirtyDescription: e.target.value });
+ }
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ }
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ }
+
+ handleInputFocus = () => {
+ this.setState({ focused: true });
+ }
+
+ handleInputBlur = () => {
+ const { dirtyDescription } = this.state;
+
+ this.setState({ focused: false, dirtyDescription: null });
+
+ if (dirtyDescription !== null) {
+ this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+ }
+ }
+
+ render () {
+ const { intl, media } = this.props;
+ const active = this.state.hovered || this.state.focused;
+ const description = this.state.dirtyDescription || media.get('description') || '';
+
+ return (
+
+
+ {({ scale }) => (
+
+
+
+
+
+ {intl.formatMessage(messages.description)}
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
new file mode 100644
index 000000000..70b28a2ba
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_button.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const messages = defineMessages({
+ upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
+});
+
+const makeMapStateToProps = () => {
+ const mapStateToProps = state => ({
+ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+ });
+
+ return mapStateToProps;
+};
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class UploadButton extends ImmutablePureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ onSelectFile: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ resetFileKey: PropTypes.number,
+ acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleChange = (e) => {
+ if (e.target.files.length > 0) {
+ this.props.onSelectFile(e.target.files);
+ }
+ }
+
+ handleClick = () => {
+ this.fileElement.click();
+ }
+
+ setRef = (c) => {
+ this.fileElement = c;
+ }
+
+ render () {
+
+ const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+ return (
+
+
+
+ {intl.formatMessage(messages.upload)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
new file mode 100644
index 000000000..b7f112205
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -0,0 +1,29 @@
+import React from 'react';
+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';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+ static propTypes = {
+ mediaIds: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { mediaIds } = this.props;
+
+ return (
+
+
+
+
+ {mediaIds.map(id => (
+
+ ))}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..d5e6f19cd
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ progress: PropTypes.number,
+ };
+
+ render () {
+ const { active, progress } = this.props;
+
+ if (!active) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {({ width }) =>
+
+ }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
new file mode 100644
index 000000000..803b7f86a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+ static propTypes = {
+ message: PropTypes.node.isRequired,
+ };
+
+ render () {
+ const { message } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ {message}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..4190e54ca
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, { id }) => ({
+ account: getAccount(state, id),
+ });
+
+ return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..5f5509dbe
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -0,0 +1,64 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
+import {
+ changeCompose,
+ submitCompose,
+ clearComposeSuggestions,
+ fetchComposeSuggestions,
+ selectComposeSuggestion,
+ changeComposeSpoilerText,
+ insertEmojiCompose,
+} from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+ text: state.getIn(['compose', 'text']),
+ suggestion_token: state.getIn(['compose', 'suggestion_token']),
+ suggestions: state.getIn(['compose', 'suggestions']),
+ spoiler: state.getIn(['compose', 'spoiler']),
+ spoiler_text: state.getIn(['compose', 'spoiler_text']),
+ privacy: state.getIn(['compose', 'privacy']),
+ focusDate: state.getIn(['compose', 'focusDate']),
+ preselectDate: state.getIn(['compose', 'preselectDate']),
+ is_submitting: state.getIn(['compose', 'is_submitting']),
+ is_uploading: state.getIn(['compose', 'is_uploading']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+ onChange (text) {
+ dispatch(changeCompose(text));
+ },
+
+ onSubmit () {
+ dispatch(submitCompose());
+ },
+
+ onClearSuggestions () {
+ dispatch(clearComposeSuggestions());
+ },
+
+ onFetchSuggestions (token) {
+ dispatch(fetchComposeSuggestions(token));
+ },
+
+ onSuggestionSelected (position, token, accountId) {
+ dispatch(selectComposeSuggestion(position, token, accountId));
+ },
+
+ onChangeSpoilerText (checked) {
+ dispatch(changeComposeSpoilerText(checked));
+ },
+
+ onPaste (files) {
+ dispatch(uploadCompose(files));
+ },
+
+ onPickEmoji (position, data) {
+ dispatch(insertEmojiCompose(position, data));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..e6a535a5d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,82 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from '../../../actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from '../../../actions/emojis';
+
+const perLine = 8;
+const lines = 2;
+
+const DEFAULTS = [
+ '+1',
+ 'grinning',
+ 'kissing_heart',
+ 'heart_eyes',
+ 'laughing',
+ 'stuck_out_tongue_winking_eye',
+ 'sweat_smile',
+ 'joy',
+ 'yum',
+ 'disappointed',
+ 'thinking_face',
+ 'weary',
+ 'sob',
+ 'sunglasses',
+ 'heart',
+ 'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+ state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+ let emojis = emojiCounters
+ .keySeq()
+ .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+ .reverse()
+ .slice(0, perLine * lines)
+ .toArray();
+
+ if (emojis.length < DEFAULTS.length) {
+ emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+ }
+
+ return emojis;
+});
+
+const getCustomEmojis = createSelector([
+ state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+ const aShort = a.get('shortcode').toLowerCase();
+ const bShort = b.get('shortcode').toLowerCase();
+
+ if (aShort < bShort) {
+ return -1;
+ } else if (aShort > bShort ) {
+ return 1;
+ } else {
+ return 0;
+ }
+}));
+
+const mapStateToProps = state => ({
+ custom_emojis: getCustomEmojis(state),
+ skinTone: state.getIn(['settings', 'skinTone']),
+ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+ onSkinTone: skinTone => {
+ dispatch(changeSetting(['skinTone'], skinTone));
+ },
+
+ onPickEmoji: emoji => {
+ dispatch(useEmoji(emoji));
+
+ if (onPickEmoji) {
+ onPickEmoji(emoji);
+ }
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..eb9f3ea45
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+import { me } from '../../../initial_state';
+
+const mapStateToProps = state => {
+ return {
+ account: state.getIn(['accounts', me]),
+ };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..0ddf531d3
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+import { openModal, closeModal } from '../../../actions/modal';
+import { isUserTouching } from '../../../is_mobile';
+
+const mapStateToProps = state => ({
+ isModalOpen: state.get('modal').modalType === 'ACTIONS',
+ value: state.getIn(['compose', 'privacy']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (value) {
+ dispatch(changeComposeVisibility(value));
+ },
+
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..73f394c1a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = state => ({
+ status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+ onCancel () {
+ dispatch(cancelReplyCompose());
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js
new file mode 100644
index 000000000..392bd0f56
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+ changeSearch,
+ clearSearch,
+ submitSearch,
+ showSearch,
+} from '../../../actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+ value: state.getIn(['search', 'value']),
+ submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (value) {
+ dispatch(changeSearch(value));
+ },
+
+ onClear () {
+ dispatch(clearSearch());
+ },
+
+ onSubmit () {
+ dispatch(submitSearch());
+ },
+
+ onShow () {
+ dispatch(showSearch());
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..16d95d417
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+
+const mapStateToProps = state => ({
+ results: state.getIn(['search', 'results']),
+});
+
+export default connect(mapStateToProps)(SearchResults);
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..c8e74f5a1
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,71 @@
+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';
+
+const messages = defineMessages({
+ title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
+});
+
+const mapStateToProps = state => ({
+ visible: state.getIn(['compose', 'media_attachments']).size > 0,
+ active: state.getIn(['compose', 'sensitive']),
+ disabled: state.getIn(['compose', 'spoiler']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick () {
+ dispatch(changeComposeSensitivity());
+ },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+ static propTypes = {
+ visible: PropTypes.bool,
+ active: PropTypes.bool,
+ disabled: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { visible, active, disabled, onClick, intl } = this.props;
+
+ return (
+
+ {({ scale }) => {
+ const icon = active ? 'eye-slash' : 'eye';
+ const className = classNames('compose-form__sensitive-button', {
+ 'compose-form__sensitive-button--visible': visible,
+ });
+ return (
+
+
+
+ );
+ }}
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
new file mode 100644
index 000000000..4179b9706
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
+});
+
+const mapStateToProps = (state, { intl }) => ({
+ label: 'CW',
+ title: intl.formatMessage(messages.title),
+ active: state.getIn(['compose', 'spoiler']),
+ ariaControls: 'cw-spoiler-input',
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onClick () {
+ dispatch(changeComposeSpoilerness());
+ },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
new file mode 100644
index 000000000..1f1d915bc
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import UploadButton from '../components/upload_button';
+import { uploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+ disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+ resetFileKey: state.getIn(['compose', 'resetFileKey']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onSelectFile (files) {
+ dispatch(uploadCompose(files));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..ca9c3b704
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onUndo: id => {
+ dispatch(undoUploadCompose(id));
+ },
+
+ onDescriptionChange: (id, description) => {
+ dispatch(changeUploadCompose(id, description));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+ mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..0cfee96da
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = state => ({
+ active: state.getIn(['compose', 'is_uploading']),
+ progress: state.getIn(['compose', 'progress']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..d34471a3e
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { me } from '../../../initial_state';
+
+const mapStateToProps = state => ({
+ needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+});
+
+const WarningWrapper = ({ needsLockWarning }) => {
+ if (needsLockWarning) {
+ return }} />} />;
+ }
+
+ return null;
+};
+
+WarningWrapper.propTypes = {
+ needsLockWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
new file mode 100644
index 000000000..0c66585c9
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { mountCompose, unmountCompose } from '../../actions/compose';
+import { Link } from 'react-router-dom';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { changeComposing } from '../../actions/compose';
+
+const messages = defineMessages({
+ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+ community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+});
+
+const mapStateToProps = state => ({
+ columns: state.getIn(['settings', 'columns']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Compose extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columns: ImmutablePropTypes.list.isRequired,
+ multiColumn: PropTypes.bool,
+ showSearch: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount () {
+ this.props.dispatch(mountCompose());
+ }
+
+ componentWillUnmount () {
+ this.props.dispatch(unmountCompose());
+ }
+
+ onFocus = () => {
+ this.props.dispatch(changeComposing(true));
+ }
+
+ onBlur = () => {
+ this.props.dispatch(changeComposing(false));
+ }
+
+ render () {
+ const { multiColumn, showSearch, intl } = this.props;
+
+ let header = '';
+
+ if (multiColumn) {
+ const { columns } = this.props;
+ header = (
+
+
+ {!columns.some(column => column.get('id') === 'HOME') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'COMMUNITY') && (
+
+ )}
+ {!columns.some(column => column.get('id') === 'PUBLIC') && (
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+ {header}
+
+
+
+
+
+
+
+
+
+
+ {({ x }) =>
+
+
+
+ }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js
new file mode 100644
index 000000000..700ba2163
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+ return inputText
+ .replace(urlRegex, urlPlaceholder)
+ .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
+};
diff --git a/app/javascript/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js
new file mode 100644
index 000000000..e676d1879
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/util/url_regex.js
@@ -0,0 +1,196 @@
+const regexen = {};
+
+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';
+ }
+
+ 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;
+}());
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
new file mode 100644
index 000000000..372459c78
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
@@ -0,0 +1,77 @@
+import emojify from '../emoji';
+
+describe('emoji', () => {
+ describe('.emojify', () => {
+ it('ignores unknown shortcodes', () => {
+ expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
+ });
+
+ it('ignores shortcodes inside of tags', () => {
+ expect(emojify('
')).toEqual('
');
+ });
+
+ it('works with unclosed tags', () => {
+ expect(emojify('hello>')).toEqual('hello>');
+ expect(emojify(' {
+ expect(emojify('smile:')).toEqual('smile:');
+ expect(emojify(':smile')).toEqual(':smile');
+ });
+
+ it('does unicode', () => {
+ expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
+ ' ');
+ expect(emojify('👨👩👧👧')).toEqual(
+ ' ');
+ expect(emojify('👩👩👦')).toEqual(' ');
+ expect(emojify('\u2757')).toEqual(
+ ' ');
+ });
+
+ it('does multiple unicode', () => {
+ expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
+ ' ');
+ expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
+ ' ');
+ expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
+ ' ');
+ expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
+ 'foo bar');
+ });
+
+ it('ignores unicode inside of tags', () => {
+ expect(emojify('
')).toEqual('
');
+ });
+
+ it('does multiple emoji properly (issue 5188)', () => {
+ expect(emojify('👌🌈💕')).toEqual(' ');
+ expect(emojify('👌 🌈 💕')).toEqual(' ');
+ });
+
+ it('does an emoji that has no shortcode', () => {
+ expect(emojify('🕉️')).toEqual(' ');
+ });
+
+ it('does an emoji whose filename is irregular', () => {
+ expect(emojify('↙️')).toEqual(' ');
+ });
+
+ it('avoid emojifying on invisible text', () => {
+ expect(emojify('http:// example.com/te st😄 '))
+ .toEqual('http:// example.com/te st😄 ');
+ expect(emojify(':luigi: ', { ':luigi:': { static_url: 'luigi.exe' } }))
+ .toEqual(':luigi: ');
+ });
+
+ it('avoid emojifying on invisible text with nested tags', () => {
+ expect(emojify('😄bar 😴 😇'))
+ .toEqual('😄bar 😴 ');
+ expect(emojify('😄😕 😴 😇'))
+ .toEqual('😄😕 😴 ');
+ expect(emojify('😄 😴 😇'))
+ .toEqual('😄 😴 ');
+ });
+ });
+});
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js
new file mode 100644
index 000000000..53efa5743
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js
@@ -0,0 +1,130 @@
+import { pick } from 'lodash';
+import { emojiIndex } from 'emoji-mart';
+import { search } from '../emoji_mart_search_light';
+
+const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
+
+describe('emoji_index', () => {
+ it('should give same result for emoji_index_light and emoji-mart', () => {
+ const expected = [
+ {
+ id: 'pineapple',
+ unified: '1f34d',
+ native: '🍍',
+ },
+ ];
+ expect(search('pineapple').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('orders search results correctly', () => {
+ const expected = [
+ {
+ id: 'apple',
+ unified: '1f34e',
+ native: '🍎',
+ },
+ {
+ id: 'pineapple',
+ unified: '1f34d',
+ native: '🍍',
+ },
+ {
+ id: 'green_apple',
+ unified: '1f34f',
+ native: '🍏',
+ },
+ {
+ id: 'iphone',
+ unified: '1f4f1',
+ native: '📱',
+ },
+ ];
+ expect(search('apple').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('handles custom emoji', () => {
+ const custom = [
+ {
+ id: 'mastodon',
+ name: 'mastodon',
+ short_names: ['mastodon'],
+ text: '',
+ emoticons: [],
+ keywords: ['mastodon'],
+ imageUrl: 'http://example.com',
+ custom: true,
+ },
+ ];
+ search('', { custom });
+ emojiIndex.search('', { custom });
+ const expected = [
+ {
+ id: 'mastodon',
+ custom: true,
+ },
+ ];
+ expect(search('masto').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('should filter only emojis we care about, exclude pineapple', () => {
+ const emojisToShowFilter = unified => unified !== '1F34D';
+ expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+ .not.toContain('pineapple');
+ expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+ .not.toContain('pineapple');
+ });
+
+ it('can include/exclude categories', () => {
+ expect(search('flag', { include: ['people'] })).toEqual([]);
+ expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
+ });
+
+ it('does an emoji whose unified name is irregular', () => {
+ const expected = [
+ {
+ 'id': 'water_polo',
+ 'unified': '1f93d',
+ 'native': '🤽',
+ },
+ {
+ 'id': 'man-playing-water-polo',
+ 'unified': '1f93d-200d-2642-fe0f',
+ 'native': '🤽♂️',
+ },
+ {
+ 'id': 'woman-playing-water-polo',
+ 'unified': '1f93d-200d-2640-fe0f',
+ 'native': '🤽♀️',
+ },
+ ];
+ expect(search('polo').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('can search for thinking_face', () => {
+ const expected = [
+ {
+ id: 'thinking_face',
+ unified: '1f914',
+ native: '🤔',
+ },
+ ];
+ expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
+ });
+
+ it('can search for woman-facepalming', () => {
+ const expected = [
+ {
+ id: 'woman-facepalming',
+ unified: '1f926-200d-2640-fe0f',
+ native: '🤦♀️',
+ },
+ ];
+ expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
+ expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
+ });
+});
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
new file mode 100644
index 000000000..0f005dd50
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -0,0 +1,95 @@
+import { autoPlayGif } from '../../initial_state';
+import unicodeMapping from './emoji_unicode_mapping_light';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+const assetHost = process.env.CDN_HOST || '';
+
+const emojify = (str, customEmojis = {}) => {
+ const tagCharsWithoutEmojis = '<&';
+ const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
+ let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
+ for (;;) {
+ let match, i = 0, tag;
+ while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
+ i += str.codePointAt(i) < 65536 ? 1 : 2;
+ }
+ let rend, replacement = '';
+ if (i === str.length) {
+ break;
+ } else if (str[i] === ':') {
+ if (!(() => {
+ rend = str.indexOf(':', i + 1) + 1;
+ if (!rend) return false; // no pair of ':'
+ const lt = str.indexOf('<', i + 1);
+ if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
+ const shortname = str.slice(i, rend);
+ // now got a replacee as ':shortname:'
+ // if you want additional emoji handler, add statements below which set replacement and return true.
+ if (shortname in customEmojis) {
+ const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
+ replacement = ` `;
+ return true;
+ }
+ return false;
+ })()) rend = ++i;
+ } else if (tag >= 0) { // <, &
+ rend = str.indexOf('>;'[tag], i + 1) + 1;
+ if (!rend) {
+ break;
+ }
+ if (tag === 0) {
+ if (invisible) {
+ if (str[i + 1] === '/') { // closing tag
+ if (!--invisible) {
+ tagChars = tagCharsWithEmojis;
+ }
+ } else if (str[rend - 2] !== '/') { // opening tag
+ invisible++;
+ }
+ } else {
+ if (str.startsWith('', i)) {
+ // avoid emojifying on invisible text
+ invisible = 1;
+ tagChars = tagCharsWithoutEmojis;
+ }
+ }
+ }
+ i = rend;
+ } else { // matched to unicode emoji
+ const { filename, shortCode } = unicodeMapping[match];
+ const title = shortCode ? `:${shortCode}:` : '';
+ replacement = ` `;
+ rend = i + match.length;
+ }
+ rtn += str.slice(0, i) + replacement;
+ str = str.slice(rend);
+ }
+ return rtn + str;
+};
+
+export default emojify;
+
+export const buildCustomEmojis = (customEmojis) => {
+ const emojis = [];
+
+ customEmojis.forEach(emoji => {
+ const shortcode = emoji.get('shortcode');
+ const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
+ const name = shortcode.replace(':', '');
+
+ emojis.push({
+ id: name,
+ name,
+ short_names: [name],
+ text: '',
+ emoticons: [],
+ keywords: [name],
+ imageUrl: url,
+ custom: true,
+ });
+ });
+
+ return emojis;
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
new file mode 100644
index 000000000..e5b834a74
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js
@@ -0,0 +1,93 @@
+// @preval
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
+// This file contains the compressed version of the emoji data from
+// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
+// It's designed to be emitted in an array format to take up less space
+// over the wire.
+
+const { unicodeToFilename } = require('./unicode_to_filename');
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const emojiMap = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const { default: emojiMartData } = require('emoji-mart/dist/data');
+
+const excluded = ['®', '©', '™'];
+const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+ shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
+
+const stripModifiers = unicode => {
+ skins.forEach(tone => {
+ unicode = unicode.replace(tone, '');
+ });
+
+ return unicode;
+};
+
+Object.keys(emojiMap).forEach(key => {
+ if (excluded.includes(key)) {
+ delete emojiMap[key];
+ return;
+ }
+
+ const normalizedKey = stripModifiers(key);
+ let shortcode = shortcodeMap[normalizedKey];
+
+ if (!shortcode) {
+ shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+ }
+
+ const filename = emojiMap[key];
+
+ const filenameData = [key];
+
+ if (unicodeToFilename(key) !== filename) {
+ // filename can't be derived using unicodeToFilename
+ filenameData.push(filename);
+ }
+
+ if (typeof shortcode === 'undefined') {
+ emojisWithoutShortCodes.push(filenameData);
+ } else {
+ if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
+ shortCodesToEmojiData[shortcode] = [[]];
+ }
+ shortCodesToEmojiData[shortcode][0].push(filenameData);
+ }
+});
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+ const { native } = emojiIndex.emojis[key];
+ let { short_names, search, unified } = emojiMartData.emojis[key];
+ if (short_names[0] !== key) {
+ throw new Error('The compresser expects the first short_code to be the ' +
+ 'key. It may need to be rewritten if the emoji change such that this ' +
+ 'is no longer the case.');
+ }
+
+ short_names = short_names.slice(1); // first short name can be inferred from the key
+
+ const searchData = [native, short_names, search];
+ if (unicodeToUnifiedName(native) !== unified) {
+ // unified name can't be derived from unicodeToUnifiedName
+ searchData.push(unified);
+ }
+
+ shortCodesToEmojiData[key].push(searchData);
+});
+
+// JSON.parse/stringify is to emulate what @preval is doing and avoid any
+// inconsistent behavior in dev mode
+module.exports = JSON.parse(JSON.stringify([
+ shortCodesToEmojiData,
+ emojiMartData.skins,
+ emojiMartData.categories,
+ emojiMartData.short_names,
+ emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/mastodon/features/emoji/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json
new file mode 100644
index 000000000..13753ba84
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_map.json
@@ -0,0 +1 @@
+{"😀":"1f600","😁":"1f601","😂":"1f602","🤣":"1f923","😃":"1f603","😄":"1f604","😅":"1f605","😆":"1f606","😉":"1f609","😊":"1f60a","😋":"1f60b","😎":"1f60e","😍":"1f60d","😘":"1f618","😗":"1f617","😙":"1f619","😚":"1f61a","☺":"263a","🙂":"1f642","🤗":"1f917","🤩":"1f929","🤔":"1f914","🤨":"1f928","😐":"1f610","😑":"1f611","😶":"1f636","🙄":"1f644","😏":"1f60f","😣":"1f623","😥":"1f625","😮":"1f62e","🤐":"1f910","😯":"1f62f","😪":"1f62a","😫":"1f62b","😴":"1f634","😌":"1f60c","😛":"1f61b","😜":"1f61c","😝":"1f61d","🤤":"1f924","😒":"1f612","😓":"1f613","😔":"1f614","😕":"1f615","🙃":"1f643","🤑":"1f911","😲":"1f632","☹":"2639","🙁":"1f641","😖":"1f616","😞":"1f61e","😟":"1f61f","😤":"1f624","😢":"1f622","😭":"1f62d","😦":"1f626","😧":"1f627","😨":"1f628","😩":"1f629","🤯":"1f92f","😬":"1f62c","😰":"1f630","😱":"1f631","😳":"1f633","🤪":"1f92a","😵":"1f635","😡":"1f621","😠":"1f620","🤬":"1f92c","😷":"1f637","🤒":"1f912","🤕":"1f915","🤢":"1f922","🤮":"1f92e","🤧":"1f927","😇":"1f607","🤠":"1f920","🤡":"1f921","🤥":"1f925","🤫":"1f92b","🤭":"1f92d","🧐":"1f9d0","🤓":"1f913","😈":"1f608","👿":"1f47f","👹":"1f479","👺":"1f47a","💀":"1f480","☠":"2620","👻":"1f47b","👽":"1f47d","👾":"1f47e","🤖":"1f916","💩":"1f4a9","😺":"1f63a","😸":"1f638","😹":"1f639","😻":"1f63b","😼":"1f63c","😽":"1f63d","🙀":"1f640","😿":"1f63f","😾":"1f63e","🙈":"1f648","🙉":"1f649","🙊":"1f64a","👶":"1f476","🧒":"1f9d2","👦":"1f466","👧":"1f467","🧑":"1f9d1","👨":"1f468","👩":"1f469","🧓":"1f9d3","👴":"1f474","👵":"1f475","👮":"1f46e","🕵":"1f575","💂":"1f482","👷":"1f477","🤴":"1f934","👸":"1f478","👳":"1f473","👲":"1f472","🧕":"1f9d5","🧔":"1f9d4","👱":"1f471","🤵":"1f935","👰":"1f470","🤰":"1f930","🤱":"1f931","👼":"1f47c","🎅":"1f385","🤶":"1f936","🧙":"1f9d9","🧚":"1f9da","🧛":"1f9db","🧜":"1f9dc","🧝":"1f9dd","🧞":"1f9de","🧟":"1f9df","🙍":"1f64d","🙎":"1f64e","🙅":"1f645","🙆":"1f646","💁":"1f481","🙋":"1f64b","🙇":"1f647","🤦":"1f926","🤷":"1f937","💆":"1f486","💇":"1f487","🚶":"1f6b6","🏃":"1f3c3","💃":"1f483","🕺":"1f57a","👯":"1f46f","🧖":"1f9d6","🧗":"1f9d7","🧘":"1f9d8","🛀":"1f6c0","🛌":"1f6cc","🕴":"1f574","🗣":"1f5e3","👤":"1f464","👥":"1f465","🤺":"1f93a","🏇":"1f3c7","⛷":"26f7","🏂":"1f3c2","🏌":"1f3cc","🏄":"1f3c4","🚣":"1f6a3","🏊":"1f3ca","⛹":"26f9","🏋":"1f3cb","🚴":"1f6b4","🚵":"1f6b5","🏎":"1f3ce","🏍":"1f3cd","🤸":"1f938","🤼":"1f93c","🤽":"1f93d","🤾":"1f93e","🤹":"1f939","👫":"1f46b","👬":"1f46c","👭":"1f46d","💏":"1f48f","💑":"1f491","👪":"1f46a","🤳":"1f933","💪":"1f4aa","👈":"1f448","👉":"1f449","☝":"261d","👆":"1f446","🖕":"1f595","👇":"1f447","✌":"270c","🤞":"1f91e","🖖":"1f596","🤘":"1f918","🤙":"1f919","🖐":"1f590","✋":"270b","👌":"1f44c","👍":"1f44d","👎":"1f44e","✊":"270a","👊":"1f44a","🤛":"1f91b","🤜":"1f91c","🤚":"1f91a","👋":"1f44b","🤟":"1f91f","✍":"270d","👏":"1f44f","👐":"1f450","🙌":"1f64c","🤲":"1f932","🙏":"1f64f","🤝":"1f91d","💅":"1f485","👂":"1f442","👃":"1f443","👣":"1f463","👀":"1f440","👁":"1f441","🧠":"1f9e0","👅":"1f445","👄":"1f444","💋":"1f48b","💘":"1f498","❤":"2764","💓":"1f493","💔":"1f494","💕":"1f495","💖":"1f496","💗":"1f497","💙":"1f499","💚":"1f49a","💛":"1f49b","🧡":"1f9e1","💜":"1f49c","🖤":"1f5a4","💝":"1f49d","💞":"1f49e","💟":"1f49f","❣":"2763","💌":"1f48c","💤":"1f4a4","💢":"1f4a2","💣":"1f4a3","💥":"1f4a5","💦":"1f4a6","💨":"1f4a8","💫":"1f4ab","💬":"1f4ac","🗨":"1f5e8","🗯":"1f5ef","💭":"1f4ad","🕳":"1f573","👓":"1f453","🕶":"1f576","👔":"1f454","👕":"1f455","👖":"1f456","🧣":"1f9e3","🧤":"1f9e4","🧥":"1f9e5","🧦":"1f9e6","👗":"1f457","👘":"1f458","👙":"1f459","👚":"1f45a","👛":"1f45b","👜":"1f45c","👝":"1f45d","🛍":"1f6cd","🎒":"1f392","👞":"1f45e","👟":"1f45f","👠":"1f460","👡":"1f461","👢":"1f462","👑":"1f451","👒":"1f452","🎩":"1f3a9","🎓":"1f393","🧢":"1f9e2","⛑":"26d1","📿":"1f4ff","💄":"1f484","💍":"1f48d","💎":"1f48e","🐵":"1f435","🐒":"1f412","🦍":"1f98d","🐶":"1f436","🐕":"1f415","🐩":"1f429","🐺":"1f43a","🦊":"1f98a","🐱":"1f431","🐈":"1f408","🦁":"1f981","🐯":"1f42f","🐅":"1f405","🐆":"1f406","🐴":"1f434","🐎":"1f40e","🦄":"1f984","🦓":"1f993","🦌":"1f98c","🐮":"1f42e","🐂":"1f402","🐃":"1f403","🐄":"1f404","🐷":"1f437","🐖":"1f416","🐗":"1f417","🐽":"1f43d","🐏":"1f40f","🐑":"1f411","🐐":"1f410","🐪":"1f42a","🐫":"1f42b","🦒":"1f992","🐘":"1f418","🦏":"1f98f","🐭":"1f42d","🐁":"1f401","🐀":"1f400","🐹":"1f439","🐰":"1f430","🐇":"1f407","🐿":"1f43f","🦔":"1f994","🦇":"1f987","🐻":"1f43b","🐨":"1f428","🐼":"1f43c","🐾":"1f43e","🦃":"1f983","🐔":"1f414","🐓":"1f413","🐣":"1f423","🐤":"1f424","🐥":"1f425","🐦":"1f426","🐧":"1f427","🕊":"1f54a","🦅":"1f985","🦆":"1f986","🦉":"1f989","🐸":"1f438","🐊":"1f40a","🐢":"1f422","🦎":"1f98e","🐍":"1f40d","🐲":"1f432","🐉":"1f409","🦕":"1f995","🦖":"1f996","🐳":"1f433","🐋":"1f40b","🐬":"1f42c","🐟":"1f41f","🐠":"1f420","🐡":"1f421","🦈":"1f988","🐙":"1f419","🐚":"1f41a","🦀":"1f980","🦐":"1f990","🦑":"1f991","🐌":"1f40c","🦋":"1f98b","🐛":"1f41b","🐜":"1f41c","🐝":"1f41d","🐞":"1f41e","🦗":"1f997","🕷":"1f577","🕸":"1f578","🦂":"1f982","💐":"1f490","🌸":"1f338","💮":"1f4ae","🏵":"1f3f5","🌹":"1f339","🥀":"1f940","🌺":"1f33a","🌻":"1f33b","🌼":"1f33c","🌷":"1f337","🌱":"1f331","🌲":"1f332","🌳":"1f333","🌴":"1f334","🌵":"1f335","🌾":"1f33e","🌿":"1f33f","☘":"2618","🍀":"1f340","🍁":"1f341","🍂":"1f342","🍃":"1f343","🍇":"1f347","🍈":"1f348","🍉":"1f349","🍊":"1f34a","🍋":"1f34b","🍌":"1f34c","🍍":"1f34d","🍎":"1f34e","🍏":"1f34f","🍐":"1f350","🍑":"1f351","🍒":"1f352","🍓":"1f353","🥝":"1f95d","🍅":"1f345","🥥":"1f965","🥑":"1f951","🍆":"1f346","🥔":"1f954","🥕":"1f955","🌽":"1f33d","🌶":"1f336","🥒":"1f952","🥦":"1f966","🍄":"1f344","🥜":"1f95c","🌰":"1f330","🍞":"1f35e","🥐":"1f950","🥖":"1f956","🥨":"1f968","🥞":"1f95e","🧀":"1f9c0","🍖":"1f356","🍗":"1f357","🥩":"1f969","🥓":"1f953","🍔":"1f354","🍟":"1f35f","🍕":"1f355","🌭":"1f32d","🥪":"1f96a","🌮":"1f32e","🌯":"1f32f","🥙":"1f959","🥚":"1f95a","🍳":"1f373","🥘":"1f958","🍲":"1f372","🥣":"1f963","🥗":"1f957","🍿":"1f37f","🥫":"1f96b","🍱":"1f371","🍘":"1f358","🍙":"1f359","🍚":"1f35a","🍛":"1f35b","🍜":"1f35c","🍝":"1f35d","🍠":"1f360","🍢":"1f362","🍣":"1f363","🍤":"1f364","🍥":"1f365","🍡":"1f361","🥟":"1f95f","🥠":"1f960","🥡":"1f961","🍦":"1f366","🍧":"1f367","🍨":"1f368","🍩":"1f369","🍪":"1f36a","🎂":"1f382","🍰":"1f370","🥧":"1f967","🍫":"1f36b","🍬":"1f36c","🍭":"1f36d","🍮":"1f36e","🍯":"1f36f","🍼":"1f37c","🥛":"1f95b","☕":"2615","🍵":"1f375","🍶":"1f376","🍾":"1f37e","🍷":"1f377","🍸":"1f378","🍹":"1f379","🍺":"1f37a","🍻":"1f37b","🥂":"1f942","🥃":"1f943","🥤":"1f964","🥢":"1f962","🍽":"1f37d","🍴":"1f374","🥄":"1f944","🔪":"1f52a","🏺":"1f3fa","🌍":"1f30d","🌎":"1f30e","🌏":"1f30f","🌐":"1f310","🗺":"1f5fa","🗾":"1f5fe","🏔":"1f3d4","⛰":"26f0","🌋":"1f30b","🗻":"1f5fb","🏕":"1f3d5","🏖":"1f3d6","🏜":"1f3dc","🏝":"1f3dd","🏞":"1f3de","🏟":"1f3df","🏛":"1f3db","🏗":"1f3d7","🏘":"1f3d8","🏙":"1f3d9","🏚":"1f3da","🏠":"1f3e0","🏡":"1f3e1","🏢":"1f3e2","🏣":"1f3e3","🏤":"1f3e4","🏥":"1f3e5","🏦":"1f3e6","🏨":"1f3e8","🏩":"1f3e9","🏪":"1f3ea","🏫":"1f3eb","🏬":"1f3ec","🏭":"1f3ed","🏯":"1f3ef","🏰":"1f3f0","💒":"1f492","🗼":"1f5fc","🗽":"1f5fd","⛪":"26ea","🕌":"1f54c","🕍":"1f54d","⛩":"26e9","🕋":"1f54b","⛲":"26f2","⛺":"26fa","🌁":"1f301","🌃":"1f303","🌄":"1f304","🌅":"1f305","🌆":"1f306","🌇":"1f307","🌉":"1f309","♨":"2668","🌌":"1f30c","🎠":"1f3a0","🎡":"1f3a1","🎢":"1f3a2","💈":"1f488","🎪":"1f3aa","🎭":"1f3ad","🖼":"1f5bc","🎨":"1f3a8","🎰":"1f3b0","🚂":"1f682","🚃":"1f683","🚄":"1f684","🚅":"1f685","🚆":"1f686","🚇":"1f687","🚈":"1f688","🚉":"1f689","🚊":"1f68a","🚝":"1f69d","🚞":"1f69e","🚋":"1f68b","🚌":"1f68c","🚍":"1f68d","🚎":"1f68e","🚐":"1f690","🚑":"1f691","🚒":"1f692","🚓":"1f693","🚔":"1f694","🚕":"1f695","🚖":"1f696","🚗":"1f697","🚘":"1f698","🚙":"1f699","🚚":"1f69a","🚛":"1f69b","🚜":"1f69c","🚲":"1f6b2","🛴":"1f6f4","🛵":"1f6f5","🚏":"1f68f","🛣":"1f6e3","🛤":"1f6e4","⛽":"26fd","🚨":"1f6a8","🚥":"1f6a5","🚦":"1f6a6","🚧":"1f6a7","🛑":"1f6d1","⚓":"2693","⛵":"26f5","🛶":"1f6f6","🚤":"1f6a4","🛳":"1f6f3","⛴":"26f4","🛥":"1f6e5","🚢":"1f6a2","✈":"2708","🛩":"1f6e9","🛫":"1f6eb","🛬":"1f6ec","💺":"1f4ba","🚁":"1f681","🚟":"1f69f","🚠":"1f6a0","🚡":"1f6a1","🛰":"1f6f0","🚀":"1f680","🛸":"1f6f8","🛎":"1f6ce","🚪":"1f6aa","🛏":"1f6cf","🛋":"1f6cb","🚽":"1f6bd","🚿":"1f6bf","🛁":"1f6c1","⌛":"231b","⏳":"23f3","⌚":"231a","⏰":"23f0","⏱":"23f1","⏲":"23f2","🕰":"1f570","🕛":"1f55b","🕧":"1f567","🕐":"1f550","🕜":"1f55c","🕑":"1f551","🕝":"1f55d","🕒":"1f552","🕞":"1f55e","🕓":"1f553","🕟":"1f55f","🕔":"1f554","🕠":"1f560","🕕":"1f555","🕡":"1f561","🕖":"1f556","🕢":"1f562","🕗":"1f557","🕣":"1f563","🕘":"1f558","🕤":"1f564","🕙":"1f559","🕥":"1f565","🕚":"1f55a","🕦":"1f566","🌑":"1f311","🌒":"1f312","🌓":"1f313","🌔":"1f314","🌕":"1f315","🌖":"1f316","🌗":"1f317","🌘":"1f318","🌙":"1f319","🌚":"1f31a","🌛":"1f31b","🌜":"1f31c","🌡":"1f321","☀":"2600","🌝":"1f31d","🌞":"1f31e","⭐":"2b50","🌟":"1f31f","🌠":"1f320","☁":"2601","⛅":"26c5","⛈":"26c8","🌤":"1f324","🌥":"1f325","🌦":"1f326","🌧":"1f327","🌨":"1f328","🌩":"1f329","🌪":"1f32a","🌫":"1f32b","🌬":"1f32c","🌀":"1f300","🌈":"1f308","🌂":"1f302","☂":"2602","☔":"2614","⛱":"26f1","⚡":"26a1","❄":"2744","☃":"2603","⛄":"26c4","☄":"2604","🔥":"1f525","💧":"1f4a7","🌊":"1f30a","🎃":"1f383","🎄":"1f384","🎆":"1f386","🎇":"1f387","✨":"2728","🎈":"1f388","🎉":"1f389","🎊":"1f38a","🎋":"1f38b","🎍":"1f38d","🎎":"1f38e","🎏":"1f38f","🎐":"1f390","🎑":"1f391","🎀":"1f380","🎁":"1f381","🎗":"1f397","🎟":"1f39f","🎫":"1f3ab","🎖":"1f396","🏆":"1f3c6","🏅":"1f3c5","🥇":"1f947","🥈":"1f948","🥉":"1f949","⚽":"26bd","⚾":"26be","🏀":"1f3c0","🏐":"1f3d0","🏈":"1f3c8","🏉":"1f3c9","🎾":"1f3be","🎱":"1f3b1","🎳":"1f3b3","🏏":"1f3cf","🏑":"1f3d1","🏒":"1f3d2","🏓":"1f3d3","🏸":"1f3f8","🥊":"1f94a","🥋":"1f94b","🥅":"1f945","🎯":"1f3af","⛳":"26f3","⛸":"26f8","🎣":"1f3a3","🎽":"1f3bd","🎿":"1f3bf","🛷":"1f6f7","🥌":"1f94c","🎮":"1f3ae","🕹":"1f579","🎲":"1f3b2","♠":"2660","♥":"2665","♦":"2666","♣":"2663","🃏":"1f0cf","🀄":"1f004","🎴":"1f3b4","🔇":"1f507","🔈":"1f508","🔉":"1f509","🔊":"1f50a","📢":"1f4e2","📣":"1f4e3","📯":"1f4ef","🔔":"1f514","🔕":"1f515","🎼":"1f3bc","🎵":"1f3b5","🎶":"1f3b6","🎙":"1f399","🎚":"1f39a","🎛":"1f39b","🎤":"1f3a4","🎧":"1f3a7","📻":"1f4fb","🎷":"1f3b7","🎸":"1f3b8","🎹":"1f3b9","🎺":"1f3ba","🎻":"1f3bb","🥁":"1f941","📱":"1f4f1","📲":"1f4f2","☎":"260e","📞":"1f4de","📟":"1f4df","📠":"1f4e0","🔋":"1f50b","🔌":"1f50c","💻":"1f4bb","🖥":"1f5a5","🖨":"1f5a8","⌨":"2328","🖱":"1f5b1","🖲":"1f5b2","💽":"1f4bd","💾":"1f4be","💿":"1f4bf","📀":"1f4c0","🎥":"1f3a5","🎞":"1f39e","📽":"1f4fd","🎬":"1f3ac","📺":"1f4fa","📷":"1f4f7","📸":"1f4f8","📹":"1f4f9","📼":"1f4fc","🔍":"1f50d","🔎":"1f50e","🔬":"1f52c","🔭":"1f52d","📡":"1f4e1","🕯":"1f56f","💡":"1f4a1","🔦":"1f526","🏮":"1f3ee","📔":"1f4d4","📕":"1f4d5","📖":"1f4d6","📗":"1f4d7","📘":"1f4d8","📙":"1f4d9","📚":"1f4da","📓":"1f4d3","📒":"1f4d2","📃":"1f4c3","📜":"1f4dc","📄":"1f4c4","📰":"1f4f0","🗞":"1f5de","📑":"1f4d1","🔖":"1f516","🏷":"1f3f7","💰":"1f4b0","💴":"1f4b4","💵":"1f4b5","💶":"1f4b6","💷":"1f4b7","💸":"1f4b8","💳":"1f4b3","💹":"1f4b9","💱":"1f4b1","💲":"1f4b2","✉":"2709","📧":"1f4e7","📨":"1f4e8","📩":"1f4e9","📤":"1f4e4","📥":"1f4e5","📦":"1f4e6","📫":"1f4eb","📪":"1f4ea","📬":"1f4ec","📭":"1f4ed","📮":"1f4ee","🗳":"1f5f3","✏":"270f","✒":"2712","🖋":"1f58b","🖊":"1f58a","🖌":"1f58c","🖍":"1f58d","📝":"1f4dd","💼":"1f4bc","📁":"1f4c1","📂":"1f4c2","🗂":"1f5c2","📅":"1f4c5","📆":"1f4c6","🗒":"1f5d2","🗓":"1f5d3","📇":"1f4c7","📈":"1f4c8","📉":"1f4c9","📊":"1f4ca","📋":"1f4cb","📌":"1f4cc","📍":"1f4cd","📎":"1f4ce","🖇":"1f587","📏":"1f4cf","📐":"1f4d0","✂":"2702","🗃":"1f5c3","🗄":"1f5c4","🗑":"1f5d1","🔒":"1f512","🔓":"1f513","🔏":"1f50f","🔐":"1f510","🔑":"1f511","🗝":"1f5dd","🔨":"1f528","⛏":"26cf","⚒":"2692","🛠":"1f6e0","🗡":"1f5e1","⚔":"2694","🔫":"1f52b","🏹":"1f3f9","🛡":"1f6e1","🔧":"1f527","🔩":"1f529","⚙":"2699","🗜":"1f5dc","⚗":"2697","⚖":"2696","🔗":"1f517","⛓":"26d3","💉":"1f489","💊":"1f48a","🚬":"1f6ac","⚰":"26b0","⚱":"26b1","🗿":"1f5ff","🛢":"1f6e2","🔮":"1f52e","🛒":"1f6d2","🏧":"1f3e7","🚮":"1f6ae","🚰":"1f6b0","♿":"267f","🚹":"1f6b9","🚺":"1f6ba","🚻":"1f6bb","🚼":"1f6bc","🚾":"1f6be","🛂":"1f6c2","🛃":"1f6c3","🛄":"1f6c4","🛅":"1f6c5","⚠":"26a0","🚸":"1f6b8","⛔":"26d4","🚫":"1f6ab","🚳":"1f6b3","🚭":"1f6ad","🚯":"1f6af","🚱":"1f6b1","🚷":"1f6b7","📵":"1f4f5","🔞":"1f51e","☢":"2622","☣":"2623","⬆":"2b06","↗":"2197","➡":"27a1","↘":"2198","⬇":"2b07","↙":"2199","⬅":"2b05","↖":"2196","↕":"2195","↔":"2194","↩":"21a9","↪":"21aa","⤴":"2934","⤵":"2935","🔃":"1f503","🔄":"1f504","🔙":"1f519","🔚":"1f51a","🔛":"1f51b","🔜":"1f51c","🔝":"1f51d","🛐":"1f6d0","⚛":"269b","🕉":"1f549","✡":"2721","☸":"2638","☯":"262f","✝":"271d","☦":"2626","☪":"262a","☮":"262e","🕎":"1f54e","🔯":"1f52f","♈":"2648","♉":"2649","♊":"264a","♋":"264b","♌":"264c","♍":"264d","♎":"264e","♏":"264f","♐":"2650","♑":"2651","♒":"2652","♓":"2653","⛎":"26ce","🔀":"1f500","🔁":"1f501","🔂":"1f502","▶":"25b6","⏩":"23e9","⏭":"23ed","⏯":"23ef","◀":"25c0","⏪":"23ea","⏮":"23ee","🔼":"1f53c","⏫":"23eb","🔽":"1f53d","⏬":"23ec","⏸":"23f8","⏹":"23f9","⏺":"23fa","⏏":"23cf","🎦":"1f3a6","🔅":"1f505","🔆":"1f506","📶":"1f4f6","📳":"1f4f3","📴":"1f4f4","♀":"2640","♂":"2642","⚕":"2695","♻":"267b","⚜":"269c","🔱":"1f531","📛":"1f4db","🔰":"1f530","⭕":"2b55","✅":"2705","☑":"2611","✔":"2714","✖":"2716","❌":"274c","❎":"274e","➕":"2795","➖":"2796","➗":"2797","➰":"27b0","➿":"27bf","〽":"303d","✳":"2733","✴":"2734","❇":"2747","‼":"203c","⁉":"2049","❓":"2753","❔":"2754","❕":"2755","❗":"2757","〰":"3030","©":"a9","®":"ae","™":"2122","🔟":"1f51f","💯":"1f4af","🔠":"1f520","🔡":"1f521","🔢":"1f522","🔣":"1f523","🔤":"1f524","🅰":"1f170","🆎":"1f18e","🅱":"1f171","🆑":"1f191","🆒":"1f192","🆓":"1f193","ℹ":"2139","🆔":"1f194","Ⓜ":"24c2","🆕":"1f195","🆖":"1f196","🅾":"1f17e","🆗":"1f197","🅿":"1f17f","🆘":"1f198","🆙":"1f199","🆚":"1f19a","🈁":"1f201","🈂":"1f202","🈷":"1f237","🈶":"1f236","🈯":"1f22f","🉐":"1f250","🈹":"1f239","🈚":"1f21a","🈲":"1f232","🉑":"1f251","🈸":"1f238","🈴":"1f234","🈳":"1f233","㊗":"3297","㊙":"3299","🈺":"1f23a","🈵":"1f235","▪":"25aa","▫":"25ab","◻":"25fb","◼":"25fc","◽":"25fd","◾":"25fe","⬛":"2b1b","⬜":"2b1c","🔶":"1f536","🔷":"1f537","🔸":"1f538","🔹":"1f539","🔺":"1f53a","🔻":"1f53b","💠":"1f4a0","🔘":"1f518","🔲":"1f532","🔳":"1f533","⚪":"26aa","⚫":"26ab","🔴":"1f534","🔵":"1f535","🏁":"1f3c1","🚩":"1f6a9","🎌":"1f38c","🏴":"1f3f4","🏳":"1f3f3","☺️":"263a","☹️":"2639","☠️":"2620","👶🏻":"1f476-1f3fb","👶🏼":"1f476-1f3fc","👶🏽":"1f476-1f3fd","👶🏾":"1f476-1f3fe","👶🏿":"1f476-1f3ff","🧒🏻":"1f9d2-1f3fb","🧒🏼":"1f9d2-1f3fc","🧒🏽":"1f9d2-1f3fd","🧒🏾":"1f9d2-1f3fe","🧒🏿":"1f9d2-1f3ff","👦🏻":"1f466-1f3fb","👦🏼":"1f466-1f3fc","👦🏽":"1f466-1f3fd","👦🏾":"1f466-1f3fe","👦🏿":"1f466-1f3ff","👧🏻":"1f467-1f3fb","👧🏼":"1f467-1f3fc","👧🏽":"1f467-1f3fd","👧🏾":"1f467-1f3fe","👧🏿":"1f467-1f3ff","🧑🏻":"1f9d1-1f3fb","🧑🏼":"1f9d1-1f3fc","🧑🏽":"1f9d1-1f3fd","🧑🏾":"1f9d1-1f3fe","🧑🏿":"1f9d1-1f3ff","👨🏻":"1f468-1f3fb","👨🏼":"1f468-1f3fc","👨🏽":"1f468-1f3fd","👨🏾":"1f468-1f3fe","👨🏿":"1f468-1f3ff","👩🏻":"1f469-1f3fb","👩🏼":"1f469-1f3fc","👩🏽":"1f469-1f3fd","👩🏾":"1f469-1f3fe","👩🏿":"1f469-1f3ff","🧓🏻":"1f9d3-1f3fb","🧓🏼":"1f9d3-1f3fc","🧓🏽":"1f9d3-1f3fd","🧓🏾":"1f9d3-1f3fe","🧓🏿":"1f9d3-1f3ff","👴🏻":"1f474-1f3fb","👴🏼":"1f474-1f3fc","👴🏽":"1f474-1f3fd","👴🏾":"1f474-1f3fe","👴🏿":"1f474-1f3ff","👵🏻":"1f475-1f3fb","👵🏼":"1f475-1f3fc","👵🏽":"1f475-1f3fd","👵🏾":"1f475-1f3fe","👵🏿":"1f475-1f3ff","👮🏻":"1f46e-1f3fb","👮🏼":"1f46e-1f3fc","👮🏽":"1f46e-1f3fd","👮🏾":"1f46e-1f3fe","👮🏿":"1f46e-1f3ff","🕵️":"1f575","🕵🏻":"1f575-1f3fb","🕵🏼":"1f575-1f3fc","🕵🏽":"1f575-1f3fd","🕵🏾":"1f575-1f3fe","🕵🏿":"1f575-1f3ff","💂🏻":"1f482-1f3fb","💂🏼":"1f482-1f3fc","💂🏽":"1f482-1f3fd","💂🏾":"1f482-1f3fe","💂🏿":"1f482-1f3ff","👷🏻":"1f477-1f3fb","👷🏼":"1f477-1f3fc","👷🏽":"1f477-1f3fd","👷🏾":"1f477-1f3fe","👷🏿":"1f477-1f3ff","🤴🏻":"1f934-1f3fb","🤴🏼":"1f934-1f3fc","🤴🏽":"1f934-1f3fd","🤴🏾":"1f934-1f3fe","🤴🏿":"1f934-1f3ff","👸🏻":"1f478-1f3fb","👸🏼":"1f478-1f3fc","👸🏽":"1f478-1f3fd","👸🏾":"1f478-1f3fe","👸🏿":"1f478-1f3ff","👳🏻":"1f473-1f3fb","👳🏼":"1f473-1f3fc","👳🏽":"1f473-1f3fd","👳🏾":"1f473-1f3fe","👳🏿":"1f473-1f3ff","👲🏻":"1f472-1f3fb","👲🏼":"1f472-1f3fc","👲🏽":"1f472-1f3fd","👲🏾":"1f472-1f3fe","👲🏿":"1f472-1f3ff","🧕🏻":"1f9d5-1f3fb","🧕🏼":"1f9d5-1f3fc","🧕🏽":"1f9d5-1f3fd","🧕🏾":"1f9d5-1f3fe","🧕🏿":"1f9d5-1f3ff","🧔🏻":"1f9d4-1f3fb","🧔🏼":"1f9d4-1f3fc","🧔🏽":"1f9d4-1f3fd","🧔🏾":"1f9d4-1f3fe","🧔🏿":"1f9d4-1f3ff","👱🏻":"1f471-1f3fb","👱🏼":"1f471-1f3fc","👱🏽":"1f471-1f3fd","👱🏾":"1f471-1f3fe","👱🏿":"1f471-1f3ff","🤵🏻":"1f935-1f3fb","🤵🏼":"1f935-1f3fc","🤵🏽":"1f935-1f3fd","🤵🏾":"1f935-1f3fe","🤵🏿":"1f935-1f3ff","👰🏻":"1f470-1f3fb","👰🏼":"1f470-1f3fc","👰🏽":"1f470-1f3fd","👰🏾":"1f470-1f3fe","👰🏿":"1f470-1f3ff","🤰🏻":"1f930-1f3fb","🤰🏼":"1f930-1f3fc","🤰🏽":"1f930-1f3fd","🤰🏾":"1f930-1f3fe","🤰🏿":"1f930-1f3ff","🤱🏻":"1f931-1f3fb","🤱🏼":"1f931-1f3fc","🤱🏽":"1f931-1f3fd","🤱🏾":"1f931-1f3fe","🤱🏿":"1f931-1f3ff","👼🏻":"1f47c-1f3fb","👼🏼":"1f47c-1f3fc","👼🏽":"1f47c-1f3fd","👼🏾":"1f47c-1f3fe","👼🏿":"1f47c-1f3ff","🎅🏻":"1f385-1f3fb","🎅🏼":"1f385-1f3fc","🎅🏽":"1f385-1f3fd","🎅🏾":"1f385-1f3fe","🎅🏿":"1f385-1f3ff","🤶🏻":"1f936-1f3fb","🤶🏼":"1f936-1f3fc","🤶🏽":"1f936-1f3fd","🤶🏾":"1f936-1f3fe","🤶🏿":"1f936-1f3ff","🧙🏻":"1f9d9-1f3fb","🧙🏼":"1f9d9-1f3fc","🧙🏽":"1f9d9-1f3fd","🧙🏾":"1f9d9-1f3fe","🧙🏿":"1f9d9-1f3ff","🧚🏻":"1f9da-1f3fb","🧚🏼":"1f9da-1f3fc","🧚🏽":"1f9da-1f3fd","🧚🏾":"1f9da-1f3fe","🧚🏿":"1f9da-1f3ff","🧛🏻":"1f9db-1f3fb","🧛🏼":"1f9db-1f3fc","🧛🏽":"1f9db-1f3fd","🧛🏾":"1f9db-1f3fe","🧛🏿":"1f9db-1f3ff","🧜🏻":"1f9dc-1f3fb","🧜🏼":"1f9dc-1f3fc","🧜🏽":"1f9dc-1f3fd","🧜🏾":"1f9dc-1f3fe","🧜🏿":"1f9dc-1f3ff","🧝🏻":"1f9dd-1f3fb","🧝🏼":"1f9dd-1f3fc","🧝🏽":"1f9dd-1f3fd","🧝🏾":"1f9dd-1f3fe","🧝🏿":"1f9dd-1f3ff","🙍🏻":"1f64d-1f3fb","🙍🏼":"1f64d-1f3fc","🙍🏽":"1f64d-1f3fd","🙍🏾":"1f64d-1f3fe","🙍🏿":"1f64d-1f3ff","🙎🏻":"1f64e-1f3fb","🙎🏼":"1f64e-1f3fc","🙎🏽":"1f64e-1f3fd","🙎🏾":"1f64e-1f3fe","🙎🏿":"1f64e-1f3ff","🙅🏻":"1f645-1f3fb","🙅🏼":"1f645-1f3fc","🙅🏽":"1f645-1f3fd","🙅🏾":"1f645-1f3fe","🙅🏿":"1f645-1f3ff","🙆🏻":"1f646-1f3fb","🙆🏼":"1f646-1f3fc","🙆🏽":"1f646-1f3fd","🙆🏾":"1f646-1f3fe","🙆🏿":"1f646-1f3ff","💁🏻":"1f481-1f3fb","💁🏼":"1f481-1f3fc","💁🏽":"1f481-1f3fd","💁🏾":"1f481-1f3fe","💁🏿":"1f481-1f3ff","🙋🏻":"1f64b-1f3fb","🙋🏼":"1f64b-1f3fc","🙋🏽":"1f64b-1f3fd","🙋🏾":"1f64b-1f3fe","🙋🏿":"1f64b-1f3ff","🙇🏻":"1f647-1f3fb","🙇🏼":"1f647-1f3fc","🙇🏽":"1f647-1f3fd","🙇🏾":"1f647-1f3fe","🙇🏿":"1f647-1f3ff","🤦🏻":"1f926-1f3fb","🤦🏼":"1f926-1f3fc","🤦🏽":"1f926-1f3fd","🤦🏾":"1f926-1f3fe","🤦🏿":"1f926-1f3ff","🤷🏻":"1f937-1f3fb","🤷🏼":"1f937-1f3fc","🤷🏽":"1f937-1f3fd","🤷🏾":"1f937-1f3fe","🤷🏿":"1f937-1f3ff","💆🏻":"1f486-1f3fb","💆🏼":"1f486-1f3fc","💆🏽":"1f486-1f3fd","💆🏾":"1f486-1f3fe","💆🏿":"1f486-1f3ff","💇🏻":"1f487-1f3fb","💇🏼":"1f487-1f3fc","💇🏽":"1f487-1f3fd","💇🏾":"1f487-1f3fe","💇🏿":"1f487-1f3ff","🚶🏻":"1f6b6-1f3fb","🚶🏼":"1f6b6-1f3fc","🚶🏽":"1f6b6-1f3fd","🚶🏾":"1f6b6-1f3fe","🚶🏿":"1f6b6-1f3ff","🏃🏻":"1f3c3-1f3fb","🏃🏼":"1f3c3-1f3fc","🏃🏽":"1f3c3-1f3fd","🏃🏾":"1f3c3-1f3fe","🏃🏿":"1f3c3-1f3ff","💃🏻":"1f483-1f3fb","💃🏼":"1f483-1f3fc","💃🏽":"1f483-1f3fd","💃🏾":"1f483-1f3fe","💃🏿":"1f483-1f3ff","🕺🏻":"1f57a-1f3fb","🕺🏼":"1f57a-1f3fc","🕺🏽":"1f57a-1f3fd","🕺🏾":"1f57a-1f3fe","🕺🏿":"1f57a-1f3ff","🧖🏻":"1f9d6-1f3fb","🧖🏼":"1f9d6-1f3fc","🧖🏽":"1f9d6-1f3fd","🧖🏾":"1f9d6-1f3fe","🧖🏿":"1f9d6-1f3ff","🧗🏻":"1f9d7-1f3fb","🧗🏼":"1f9d7-1f3fc","🧗🏽":"1f9d7-1f3fd","🧗🏾":"1f9d7-1f3fe","🧗🏿":"1f9d7-1f3ff","🧘🏻":"1f9d8-1f3fb","🧘🏼":"1f9d8-1f3fc","🧘🏽":"1f9d8-1f3fd","🧘🏾":"1f9d8-1f3fe","🧘🏿":"1f9d8-1f3ff","🛀🏻":"1f6c0-1f3fb","🛀🏼":"1f6c0-1f3fc","🛀🏽":"1f6c0-1f3fd","🛀🏾":"1f6c0-1f3fe","🛀🏿":"1f6c0-1f3ff","🛌🏻":"1f6cc-1f3fb","🛌🏼":"1f6cc-1f3fc","🛌🏽":"1f6cc-1f3fd","🛌🏾":"1f6cc-1f3fe","🛌🏿":"1f6cc-1f3ff","🕴️":"1f574","🕴🏻":"1f574-1f3fb","🕴🏼":"1f574-1f3fc","🕴🏽":"1f574-1f3fd","🕴🏾":"1f574-1f3fe","🕴🏿":"1f574-1f3ff","🗣️":"1f5e3","🏇🏻":"1f3c7-1f3fb","🏇🏼":"1f3c7-1f3fc","🏇🏽":"1f3c7-1f3fd","🏇🏾":"1f3c7-1f3fe","🏇🏿":"1f3c7-1f3ff","⛷️":"26f7","🏂🏻":"1f3c2-1f3fb","🏂🏼":"1f3c2-1f3fc","🏂🏽":"1f3c2-1f3fd","🏂🏾":"1f3c2-1f3fe","🏂🏿":"1f3c2-1f3ff","🏌️":"1f3cc","🏌🏻":"1f3cc-1f3fb","🏌🏼":"1f3cc-1f3fc","🏌🏽":"1f3cc-1f3fd","🏌🏾":"1f3cc-1f3fe","🏌🏿":"1f3cc-1f3ff","🏄🏻":"1f3c4-1f3fb","🏄🏼":"1f3c4-1f3fc","🏄🏽":"1f3c4-1f3fd","🏄🏾":"1f3c4-1f3fe","🏄🏿":"1f3c4-1f3ff","🚣🏻":"1f6a3-1f3fb","🚣🏼":"1f6a3-1f3fc","🚣🏽":"1f6a3-1f3fd","🚣🏾":"1f6a3-1f3fe","🚣🏿":"1f6a3-1f3ff","🏊🏻":"1f3ca-1f3fb","🏊🏼":"1f3ca-1f3fc","🏊🏽":"1f3ca-1f3fd","🏊🏾":"1f3ca-1f3fe","🏊🏿":"1f3ca-1f3ff","⛹️":"26f9","⛹🏻":"26f9-1f3fb","⛹🏼":"26f9-1f3fc","⛹🏽":"26f9-1f3fd","⛹🏾":"26f9-1f3fe","⛹🏿":"26f9-1f3ff","🏋️":"1f3cb","🏋🏻":"1f3cb-1f3fb","🏋🏼":"1f3cb-1f3fc","🏋🏽":"1f3cb-1f3fd","🏋🏾":"1f3cb-1f3fe","🏋🏿":"1f3cb-1f3ff","🚴🏻":"1f6b4-1f3fb","🚴🏼":"1f6b4-1f3fc","🚴🏽":"1f6b4-1f3fd","🚴🏾":"1f6b4-1f3fe","🚴🏿":"1f6b4-1f3ff","🚵🏻":"1f6b5-1f3fb","🚵🏼":"1f6b5-1f3fc","🚵🏽":"1f6b5-1f3fd","🚵🏾":"1f6b5-1f3fe","🚵🏿":"1f6b5-1f3ff","🏎️":"1f3ce","🏍️":"1f3cd","🤸🏻":"1f938-1f3fb","🤸🏼":"1f938-1f3fc","🤸🏽":"1f938-1f3fd","🤸🏾":"1f938-1f3fe","🤸🏿":"1f938-1f3ff","🤽🏻":"1f93d-1f3fb","🤽🏼":"1f93d-1f3fc","🤽🏽":"1f93d-1f3fd","🤽🏾":"1f93d-1f3fe","🤽🏿":"1f93d-1f3ff","🤾🏻":"1f93e-1f3fb","🤾🏼":"1f93e-1f3fc","🤾🏽":"1f93e-1f3fd","🤾🏾":"1f93e-1f3fe","🤾🏿":"1f93e-1f3ff","🤹🏻":"1f939-1f3fb","🤹🏼":"1f939-1f3fc","🤹🏽":"1f939-1f3fd","🤹🏾":"1f939-1f3fe","🤹🏿":"1f939-1f3ff","🤳🏻":"1f933-1f3fb","🤳🏼":"1f933-1f3fc","🤳🏽":"1f933-1f3fd","🤳🏾":"1f933-1f3fe","🤳🏿":"1f933-1f3ff","💪🏻":"1f4aa-1f3fb","💪🏼":"1f4aa-1f3fc","💪🏽":"1f4aa-1f3fd","💪🏾":"1f4aa-1f3fe","💪🏿":"1f4aa-1f3ff","👈🏻":"1f448-1f3fb","👈🏼":"1f448-1f3fc","👈🏽":"1f448-1f3fd","👈🏾":"1f448-1f3fe","👈🏿":"1f448-1f3ff","👉🏻":"1f449-1f3fb","👉🏼":"1f449-1f3fc","👉🏽":"1f449-1f3fd","👉🏾":"1f449-1f3fe","👉🏿":"1f449-1f3ff","☝️":"261d","☝🏻":"261d-1f3fb","☝🏼":"261d-1f3fc","☝🏽":"261d-1f3fd","☝🏾":"261d-1f3fe","☝🏿":"261d-1f3ff","👆🏻":"1f446-1f3fb","👆🏼":"1f446-1f3fc","👆🏽":"1f446-1f3fd","👆🏾":"1f446-1f3fe","👆🏿":"1f446-1f3ff","🖕🏻":"1f595-1f3fb","🖕🏼":"1f595-1f3fc","🖕🏽":"1f595-1f3fd","🖕🏾":"1f595-1f3fe","🖕🏿":"1f595-1f3ff","👇🏻":"1f447-1f3fb","👇🏼":"1f447-1f3fc","👇🏽":"1f447-1f3fd","👇🏾":"1f447-1f3fe","👇🏿":"1f447-1f3ff","✌️":"270c","✌🏻":"270c-1f3fb","✌🏼":"270c-1f3fc","✌🏽":"270c-1f3fd","✌🏾":"270c-1f3fe","✌🏿":"270c-1f3ff","🤞🏻":"1f91e-1f3fb","🤞🏼":"1f91e-1f3fc","🤞🏽":"1f91e-1f3fd","🤞🏾":"1f91e-1f3fe","🤞🏿":"1f91e-1f3ff","🖖🏻":"1f596-1f3fb","🖖🏼":"1f596-1f3fc","🖖🏽":"1f596-1f3fd","🖖🏾":"1f596-1f3fe","🖖🏿":"1f596-1f3ff","🤘🏻":"1f918-1f3fb","🤘🏼":"1f918-1f3fc","🤘🏽":"1f918-1f3fd","🤘🏾":"1f918-1f3fe","🤘🏿":"1f918-1f3ff","🤙🏻":"1f919-1f3fb","🤙🏼":"1f919-1f3fc","🤙🏽":"1f919-1f3fd","🤙🏾":"1f919-1f3fe","🤙🏿":"1f919-1f3ff","🖐️":"1f590","🖐🏻":"1f590-1f3fb","🖐🏼":"1f590-1f3fc","🖐🏽":"1f590-1f3fd","🖐🏾":"1f590-1f3fe","🖐🏿":"1f590-1f3ff","✋🏻":"270b-1f3fb","✋🏼":"270b-1f3fc","✋🏽":"270b-1f3fd","✋🏾":"270b-1f3fe","✋🏿":"270b-1f3ff","👌🏻":"1f44c-1f3fb","👌🏼":"1f44c-1f3fc","👌🏽":"1f44c-1f3fd","👌🏾":"1f44c-1f3fe","👌🏿":"1f44c-1f3ff","👍🏻":"1f44d-1f3fb","👍🏼":"1f44d-1f3fc","👍🏽":"1f44d-1f3fd","👍🏾":"1f44d-1f3fe","👍🏿":"1f44d-1f3ff","👎🏻":"1f44e-1f3fb","👎🏼":"1f44e-1f3fc","👎🏽":"1f44e-1f3fd","👎🏾":"1f44e-1f3fe","👎🏿":"1f44e-1f3ff","✊🏻":"270a-1f3fb","✊🏼":"270a-1f3fc","✊🏽":"270a-1f3fd","✊🏾":"270a-1f3fe","✊🏿":"270a-1f3ff","👊🏻":"1f44a-1f3fb","👊🏼":"1f44a-1f3fc","👊🏽":"1f44a-1f3fd","👊🏾":"1f44a-1f3fe","👊🏿":"1f44a-1f3ff","🤛🏻":"1f91b-1f3fb","🤛🏼":"1f91b-1f3fc","🤛🏽":"1f91b-1f3fd","🤛🏾":"1f91b-1f3fe","🤛🏿":"1f91b-1f3ff","🤜🏻":"1f91c-1f3fb","🤜🏼":"1f91c-1f3fc","🤜🏽":"1f91c-1f3fd","🤜🏾":"1f91c-1f3fe","🤜🏿":"1f91c-1f3ff","🤚🏻":"1f91a-1f3fb","🤚🏼":"1f91a-1f3fc","🤚🏽":"1f91a-1f3fd","🤚🏾":"1f91a-1f3fe","🤚🏿":"1f91a-1f3ff","👋🏻":"1f44b-1f3fb","👋🏼":"1f44b-1f3fc","👋🏽":"1f44b-1f3fd","👋🏾":"1f44b-1f3fe","👋🏿":"1f44b-1f3ff","🤟🏻":"1f91f-1f3fb","🤟🏼":"1f91f-1f3fc","🤟🏽":"1f91f-1f3fd","🤟🏾":"1f91f-1f3fe","🤟🏿":"1f91f-1f3ff","✍️":"270d","✍🏻":"270d-1f3fb","✍🏼":"270d-1f3fc","✍🏽":"270d-1f3fd","✍🏾":"270d-1f3fe","✍🏿":"270d-1f3ff","👏🏻":"1f44f-1f3fb","👏🏼":"1f44f-1f3fc","👏🏽":"1f44f-1f3fd","👏🏾":"1f44f-1f3fe","👏🏿":"1f44f-1f3ff","👐🏻":"1f450-1f3fb","👐🏼":"1f450-1f3fc","👐🏽":"1f450-1f3fd","👐🏾":"1f450-1f3fe","👐🏿":"1f450-1f3ff","🙌🏻":"1f64c-1f3fb","🙌🏼":"1f64c-1f3fc","🙌🏽":"1f64c-1f3fd","🙌🏾":"1f64c-1f3fe","🙌🏿":"1f64c-1f3ff","🤲🏻":"1f932-1f3fb","🤲🏼":"1f932-1f3fc","🤲🏽":"1f932-1f3fd","🤲🏾":"1f932-1f3fe","🤲🏿":"1f932-1f3ff","🙏🏻":"1f64f-1f3fb","🙏🏼":"1f64f-1f3fc","🙏🏽":"1f64f-1f3fd","🙏🏾":"1f64f-1f3fe","🙏🏿":"1f64f-1f3ff","💅🏻":"1f485-1f3fb","💅🏼":"1f485-1f3fc","💅🏽":"1f485-1f3fd","💅🏾":"1f485-1f3fe","💅🏿":"1f485-1f3ff","👂🏻":"1f442-1f3fb","👂🏼":"1f442-1f3fc","👂🏽":"1f442-1f3fd","👂🏾":"1f442-1f3fe","👂🏿":"1f442-1f3ff","👃🏻":"1f443-1f3fb","👃🏼":"1f443-1f3fc","👃🏽":"1f443-1f3fd","👃🏾":"1f443-1f3fe","👃🏿":"1f443-1f3ff","👁️":"1f441","❤️":"2764","❣️":"2763","🗨️":"1f5e8","🗯️":"1f5ef","🕳️":"1f573","🕶️":"1f576","🛍️":"1f6cd","⛑️":"26d1","🐿️":"1f43f","🕊️":"1f54a","🕷️":"1f577","🕸️":"1f578","🏵️":"1f3f5","☘️":"2618","🌶️":"1f336","🍽️":"1f37d","🗺️":"1f5fa","🏔️":"1f3d4","⛰️":"26f0","🏕️":"1f3d5","🏖️":"1f3d6","🏜️":"1f3dc","🏝️":"1f3dd","🏞️":"1f3de","🏟️":"1f3df","🏛️":"1f3db","🏗️":"1f3d7","🏘️":"1f3d8","🏙️":"1f3d9","🏚️":"1f3da","⛩️":"26e9","♨️":"2668","🖼️":"1f5bc","🛣️":"1f6e3","🛤️":"1f6e4","🛳️":"1f6f3","⛴️":"26f4","🛥️":"1f6e5","✈️":"2708","🛩️":"1f6e9","🛰️":"1f6f0","🛎️":"1f6ce","🛏️":"1f6cf","🛋️":"1f6cb","⏱️":"23f1","⏲️":"23f2","🕰️":"1f570","🌡️":"1f321","☀️":"2600","☁️":"2601","⛈️":"26c8","🌤️":"1f324","🌥️":"1f325","🌦️":"1f326","🌧️":"1f327","🌨️":"1f328","🌩️":"1f329","🌪️":"1f32a","🌫️":"1f32b","🌬️":"1f32c","☂️":"2602","⛱️":"26f1","❄️":"2744","☃️":"2603","☄️":"2604","🎗️":"1f397","🎟️":"1f39f","🎖️":"1f396","⛸️":"26f8","🕹️":"1f579","♠️":"2660","♥️":"2665","♦️":"2666","♣️":"2663","🎙️":"1f399","🎚️":"1f39a","🎛️":"1f39b","☎️":"260e","🖥️":"1f5a5","🖨️":"1f5a8","⌨️":"2328","🖱️":"1f5b1","🖲️":"1f5b2","🎞️":"1f39e","📽️":"1f4fd","🕯️":"1f56f","🗞️":"1f5de","🏷️":"1f3f7","✉️":"2709","🗳️":"1f5f3","✏️":"270f","✒️":"2712","🖋️":"1f58b","🖊️":"1f58a","🖌️":"1f58c","🖍️":"1f58d","🗂️":"1f5c2","🗒️":"1f5d2","🗓️":"1f5d3","🖇️":"1f587","✂️":"2702","🗃️":"1f5c3","🗄️":"1f5c4","🗑️":"1f5d1","🗝️":"1f5dd","⛏️":"26cf","⚒️":"2692","🛠️":"1f6e0","🗡️":"1f5e1","⚔️":"2694","🛡️":"1f6e1","⚙️":"2699","🗜️":"1f5dc","⚗️":"2697","⚖️":"2696","⛓️":"26d3","⚰️":"26b0","⚱️":"26b1","🛢️":"1f6e2","⚠️":"26a0","☢️":"2622","☣️":"2623","⬆️":"2b06","↗️":"2197","➡️":"27a1","↘️":"2198","⬇️":"2b07","↙️":"2199","⬅️":"2b05","↖️":"2196","↕️":"2195","↔️":"2194","↩️":"21a9","↪️":"21aa","⤴️":"2934","⤵️":"2935","⚛️":"269b","🕉️":"1f549","✡️":"2721","☸️":"2638","☯️":"262f","✝️":"271d","☦️":"2626","☪️":"262a","☮️":"262e","▶️":"25b6","⏭️":"23ed","⏯️":"23ef","◀️":"25c0","⏮️":"23ee","⏸️":"23f8","⏹️":"23f9","⏺️":"23fa","⏏️":"23cf","♀️":"2640","♂️":"2642","⚕️":"2695","♻️":"267b","⚜️":"269c","☑️":"2611","✔️":"2714","✖️":"2716","〽️":"303d","✳️":"2733","✴️":"2734","❇️":"2747","‼️":"203c","⁉️":"2049","〰️":"3030","©️":"a9","®️":"ae","™️":"2122","#⃣":"23-20e3","*⃣":"2a-20e3","0⃣":"30-20e3","1⃣":"31-20e3","2⃣":"32-20e3","3⃣":"33-20e3","4⃣":"34-20e3","5⃣":"35-20e3","6⃣":"36-20e3","7⃣":"37-20e3","8⃣":"38-20e3","9⃣":"39-20e3","🅰️":"1f170","🅱️":"1f171","ℹ️":"2139","Ⓜ️":"24c2","🅾️":"1f17e","🅿️":"1f17f","🈂️":"1f202","🈷️":"1f237","㊗️":"3297","㊙️":"3299","▪️":"25aa","▫️":"25ab","◻️":"25fb","◼️":"25fc","🏳️":"1f3f3","🇦🇨":"1f1e6-1f1e8","🇦🇩":"1f1e6-1f1e9","🇦🇪":"1f1e6-1f1ea","🇦🇫":"1f1e6-1f1eb","🇦🇬":"1f1e6-1f1ec","🇦🇮":"1f1e6-1f1ee","🇦🇱":"1f1e6-1f1f1","🇦🇲":"1f1e6-1f1f2","🇦🇴":"1f1e6-1f1f4","🇦🇶":"1f1e6-1f1f6","🇦🇷":"1f1e6-1f1f7","🇦🇸":"1f1e6-1f1f8","🇦🇹":"1f1e6-1f1f9","🇦🇺":"1f1e6-1f1fa","🇦🇼":"1f1e6-1f1fc","🇦🇽":"1f1e6-1f1fd","🇦🇿":"1f1e6-1f1ff","🇧🇦":"1f1e7-1f1e6","🇧🇧":"1f1e7-1f1e7","🇧🇩":"1f1e7-1f1e9","🇧🇪":"1f1e7-1f1ea","🇧🇫":"1f1e7-1f1eb","🇧🇬":"1f1e7-1f1ec","🇧🇭":"1f1e7-1f1ed","🇧🇮":"1f1e7-1f1ee","🇧🇯":"1f1e7-1f1ef","🇧🇱":"1f1e7-1f1f1","🇧🇲":"1f1e7-1f1f2","🇧🇳":"1f1e7-1f1f3","🇧🇴":"1f1e7-1f1f4","🇧🇶":"1f1e7-1f1f6","🇧🇷":"1f1e7-1f1f7","🇧🇸":"1f1e7-1f1f8","🇧🇹":"1f1e7-1f1f9","🇧🇻":"1f1e7-1f1fb","🇧🇼":"1f1e7-1f1fc","🇧🇾":"1f1e7-1f1fe","🇧🇿":"1f1e7-1f1ff","🇨🇦":"1f1e8-1f1e6","🇨🇨":"1f1e8-1f1e8","🇨🇩":"1f1e8-1f1e9","🇨🇫":"1f1e8-1f1eb","🇨🇬":"1f1e8-1f1ec","🇨🇭":"1f1e8-1f1ed","🇨🇮":"1f1e8-1f1ee","🇨🇰":"1f1e8-1f1f0","🇨🇱":"1f1e8-1f1f1","🇨🇲":"1f1e8-1f1f2","🇨🇳":"1f1e8-1f1f3","🇨🇴":"1f1e8-1f1f4","🇨🇵":"1f1e8-1f1f5","🇨🇷":"1f1e8-1f1f7","🇨🇺":"1f1e8-1f1fa","🇨🇻":"1f1e8-1f1fb","🇨🇼":"1f1e8-1f1fc","🇨🇽":"1f1e8-1f1fd","🇨🇾":"1f1e8-1f1fe","🇨🇿":"1f1e8-1f1ff","🇩🇪":"1f1e9-1f1ea","🇩🇬":"1f1e9-1f1ec","🇩🇯":"1f1e9-1f1ef","🇩🇰":"1f1e9-1f1f0","🇩🇲":"1f1e9-1f1f2","🇩🇴":"1f1e9-1f1f4","🇩🇿":"1f1e9-1f1ff","🇪🇦":"1f1ea-1f1e6","🇪🇨":"1f1ea-1f1e8","🇪🇪":"1f1ea-1f1ea","🇪🇬":"1f1ea-1f1ec","🇪🇭":"1f1ea-1f1ed","🇪🇷":"1f1ea-1f1f7","🇪🇸":"1f1ea-1f1f8","🇪🇹":"1f1ea-1f1f9","🇪🇺":"1f1ea-1f1fa","🇫🇮":"1f1eb-1f1ee","🇫🇯":"1f1eb-1f1ef","🇫🇰":"1f1eb-1f1f0","🇫🇲":"1f1eb-1f1f2","🇫🇴":"1f1eb-1f1f4","🇫🇷":"1f1eb-1f1f7","🇬🇦":"1f1ec-1f1e6","🇬🇧":"1f1ec-1f1e7","🇬🇩":"1f1ec-1f1e9","🇬🇪":"1f1ec-1f1ea","🇬🇫":"1f1ec-1f1eb","🇬🇬":"1f1ec-1f1ec","🇬🇭":"1f1ec-1f1ed","🇬🇮":"1f1ec-1f1ee","🇬🇱":"1f1ec-1f1f1","🇬🇲":"1f1ec-1f1f2","🇬🇳":"1f1ec-1f1f3","🇬🇵":"1f1ec-1f1f5","🇬🇶":"1f1ec-1f1f6","🇬🇷":"1f1ec-1f1f7","🇬🇸":"1f1ec-1f1f8","🇬🇹":"1f1ec-1f1f9","🇬🇺":"1f1ec-1f1fa","🇬🇼":"1f1ec-1f1fc","🇬🇾":"1f1ec-1f1fe","🇭🇰":"1f1ed-1f1f0","🇭🇲":"1f1ed-1f1f2","🇭🇳":"1f1ed-1f1f3","🇭🇷":"1f1ed-1f1f7","🇭🇹":"1f1ed-1f1f9","🇭🇺":"1f1ed-1f1fa","🇮🇨":"1f1ee-1f1e8","🇮🇩":"1f1ee-1f1e9","🇮🇪":"1f1ee-1f1ea","🇮🇱":"1f1ee-1f1f1","🇮🇲":"1f1ee-1f1f2","🇮🇳":"1f1ee-1f1f3","🇮🇴":"1f1ee-1f1f4","🇮🇶":"1f1ee-1f1f6","🇮🇷":"1f1ee-1f1f7","🇮🇸":"1f1ee-1f1f8","🇮🇹":"1f1ee-1f1f9","🇯🇪":"1f1ef-1f1ea","🇯🇲":"1f1ef-1f1f2","🇯🇴":"1f1ef-1f1f4","🇯🇵":"1f1ef-1f1f5","🇰🇪":"1f1f0-1f1ea","🇰🇬":"1f1f0-1f1ec","🇰🇭":"1f1f0-1f1ed","🇰🇮":"1f1f0-1f1ee","🇰🇲":"1f1f0-1f1f2","🇰🇳":"1f1f0-1f1f3","🇰🇵":"1f1f0-1f1f5","🇰🇷":"1f1f0-1f1f7","🇰🇼":"1f1f0-1f1fc","🇰🇾":"1f1f0-1f1fe","🇰🇿":"1f1f0-1f1ff","🇱🇦":"1f1f1-1f1e6","🇱🇧":"1f1f1-1f1e7","🇱🇨":"1f1f1-1f1e8","🇱🇮":"1f1f1-1f1ee","🇱🇰":"1f1f1-1f1f0","🇱🇷":"1f1f1-1f1f7","🇱🇸":"1f1f1-1f1f8","🇱🇹":"1f1f1-1f1f9","🇱🇺":"1f1f1-1f1fa","🇱🇻":"1f1f1-1f1fb","🇱🇾":"1f1f1-1f1fe","🇲🇦":"1f1f2-1f1e6","🇲🇨":"1f1f2-1f1e8","🇲🇩":"1f1f2-1f1e9","🇲🇪":"1f1f2-1f1ea","🇲🇫":"1f1f2-1f1eb","🇲🇬":"1f1f2-1f1ec","🇲🇭":"1f1f2-1f1ed","🇲🇰":"1f1f2-1f1f0","🇲🇱":"1f1f2-1f1f1","🇲🇲":"1f1f2-1f1f2","🇲🇳":"1f1f2-1f1f3","🇲🇴":"1f1f2-1f1f4","🇲🇵":"1f1f2-1f1f5","🇲🇶":"1f1f2-1f1f6","🇲🇷":"1f1f2-1f1f7","🇲🇸":"1f1f2-1f1f8","🇲🇹":"1f1f2-1f1f9","🇲🇺":"1f1f2-1f1fa","🇲🇻":"1f1f2-1f1fb","🇲🇼":"1f1f2-1f1fc","🇲🇽":"1f1f2-1f1fd","🇲🇾":"1f1f2-1f1fe","🇲🇿":"1f1f2-1f1ff","🇳🇦":"1f1f3-1f1e6","🇳🇨":"1f1f3-1f1e8","🇳🇪":"1f1f3-1f1ea","🇳🇫":"1f1f3-1f1eb","🇳🇬":"1f1f3-1f1ec","🇳🇮":"1f1f3-1f1ee","🇳🇱":"1f1f3-1f1f1","🇳🇴":"1f1f3-1f1f4","🇳🇵":"1f1f3-1f1f5","🇳🇷":"1f1f3-1f1f7","🇳🇺":"1f1f3-1f1fa","🇳🇿":"1f1f3-1f1ff","🇴🇲":"1f1f4-1f1f2","🇵🇦":"1f1f5-1f1e6","🇵🇪":"1f1f5-1f1ea","🇵🇫":"1f1f5-1f1eb","🇵🇬":"1f1f5-1f1ec","🇵🇭":"1f1f5-1f1ed","🇵🇰":"1f1f5-1f1f0","🇵🇱":"1f1f5-1f1f1","🇵🇲":"1f1f5-1f1f2","🇵🇳":"1f1f5-1f1f3","🇵🇷":"1f1f5-1f1f7","🇵🇸":"1f1f5-1f1f8","🇵🇹":"1f1f5-1f1f9","🇵🇼":"1f1f5-1f1fc","🇵🇾":"1f1f5-1f1fe","🇶🇦":"1f1f6-1f1e6","🇷🇪":"1f1f7-1f1ea","🇷🇴":"1f1f7-1f1f4","🇷🇸":"1f1f7-1f1f8","🇷🇺":"1f1f7-1f1fa","🇷🇼":"1f1f7-1f1fc","🇸🇦":"1f1f8-1f1e6","🇸🇧":"1f1f8-1f1e7","🇸🇨":"1f1f8-1f1e8","🇸🇩":"1f1f8-1f1e9","🇸🇪":"1f1f8-1f1ea","🇸🇬":"1f1f8-1f1ec","🇸🇭":"1f1f8-1f1ed","🇸🇮":"1f1f8-1f1ee","🇸🇯":"1f1f8-1f1ef","🇸🇰":"1f1f8-1f1f0","🇸🇱":"1f1f8-1f1f1","🇸🇲":"1f1f8-1f1f2","🇸🇳":"1f1f8-1f1f3","🇸🇴":"1f1f8-1f1f4","🇸🇷":"1f1f8-1f1f7","🇸🇸":"1f1f8-1f1f8","🇸🇹":"1f1f8-1f1f9","🇸🇻":"1f1f8-1f1fb","🇸🇽":"1f1f8-1f1fd","🇸🇾":"1f1f8-1f1fe","🇸🇿":"1f1f8-1f1ff","🇹🇦":"1f1f9-1f1e6","🇹🇨":"1f1f9-1f1e8","🇹🇩":"1f1f9-1f1e9","🇹🇫":"1f1f9-1f1eb","🇹🇬":"1f1f9-1f1ec","🇹🇭":"1f1f9-1f1ed","🇹🇯":"1f1f9-1f1ef","🇹🇰":"1f1f9-1f1f0","🇹🇱":"1f1f9-1f1f1","🇹🇲":"1f1f9-1f1f2","🇹🇳":"1f1f9-1f1f3","🇹🇴":"1f1f9-1f1f4","🇹🇷":"1f1f9-1f1f7","🇹🇹":"1f1f9-1f1f9","🇹🇻":"1f1f9-1f1fb","🇹🇼":"1f1f9-1f1fc","🇹🇿":"1f1f9-1f1ff","🇺🇦":"1f1fa-1f1e6","🇺🇬":"1f1fa-1f1ec","🇺🇲":"1f1fa-1f1f2","🇺🇳":"1f1fa-1f1f3","🇺🇸":"1f1fa-1f1f8","🇺🇾":"1f1fa-1f1fe","🇺🇿":"1f1fa-1f1ff","🇻🇦":"1f1fb-1f1e6","🇻🇨":"1f1fb-1f1e8","🇻🇪":"1f1fb-1f1ea","🇻🇬":"1f1fb-1f1ec","🇻🇮":"1f1fb-1f1ee","🇻🇳":"1f1fb-1f1f3","🇻🇺":"1f1fb-1f1fa","🇼🇫":"1f1fc-1f1eb","🇼🇸":"1f1fc-1f1f8","🇽🇰":"1f1fd-1f1f0","🇾🇪":"1f1fe-1f1ea","🇾🇹":"1f1fe-1f1f9","🇿🇦":"1f1ff-1f1e6","🇿🇲":"1f1ff-1f1f2","🇿🇼":"1f1ff-1f1fc","👨⚕":"1f468-200d-2695-fe0f","👩⚕":"1f469-200d-2695-fe0f","👨🎓":"1f468-200d-1f393","👩🎓":"1f469-200d-1f393","👨🏫":"1f468-200d-1f3eb","👩🏫":"1f469-200d-1f3eb","👨⚖":"1f468-200d-2696-fe0f","👩⚖":"1f469-200d-2696-fe0f","👨🌾":"1f468-200d-1f33e","👩🌾":"1f469-200d-1f33e","👨🍳":"1f468-200d-1f373","👩🍳":"1f469-200d-1f373","👨🔧":"1f468-200d-1f527","👩🔧":"1f469-200d-1f527","👨🏭":"1f468-200d-1f3ed","👩🏭":"1f469-200d-1f3ed","👨💼":"1f468-200d-1f4bc","👩💼":"1f469-200d-1f4bc","👨🔬":"1f468-200d-1f52c","👩🔬":"1f469-200d-1f52c","👨💻":"1f468-200d-1f4bb","👩💻":"1f469-200d-1f4bb","👨🎤":"1f468-200d-1f3a4","👩🎤":"1f469-200d-1f3a4","👨🎨":"1f468-200d-1f3a8","👩🎨":"1f469-200d-1f3a8","👨✈":"1f468-200d-2708-fe0f","👩✈":"1f469-200d-2708-fe0f","👨🚀":"1f468-200d-1f680","👩🚀":"1f469-200d-1f680","👨🚒":"1f468-200d-1f692","👩🚒":"1f469-200d-1f692","👮♂":"1f46e-200d-2642-fe0f","👮♀":"1f46e-200d-2640-fe0f","🕵♂":"1f575-fe0f-200d-2642-fe0f","🕵♀":"1f575-fe0f-200d-2640-fe0f","💂♂":"1f482-200d-2642-fe0f","💂♀":"1f482-200d-2640-fe0f","👷♂":"1f477-200d-2642-fe0f","👷♀":"1f477-200d-2640-fe0f","👳♂":"1f473-200d-2642-fe0f","👳♀":"1f473-200d-2640-fe0f","👱♂":"1f471-200d-2642-fe0f","👱♀":"1f471-200d-2640-fe0f","🧙♀":"1f9d9-200d-2640-fe0f","🧙♂":"1f9d9-200d-2642-fe0f","🧚♀":"1f9da-200d-2640-fe0f","🧚♂":"1f9da-200d-2642-fe0f","🧛♀":"1f9db-200d-2640-fe0f","🧛♂":"1f9db-200d-2642-fe0f","🧜♀":"1f9dc-200d-2640-fe0f","🧜♂":"1f9dc-200d-2642-fe0f","🧝♀":"1f9dd-200d-2640-fe0f","🧝♂":"1f9dd-200d-2642-fe0f","🧞♀":"1f9de-200d-2640-fe0f","🧞♂":"1f9de-200d-2642-fe0f","🧟♀":"1f9df-200d-2640-fe0f","🧟♂":"1f9df-200d-2642-fe0f","🙍♂":"1f64d-200d-2642-fe0f","🙍♀":"1f64d-200d-2640-fe0f","🙎♂":"1f64e-200d-2642-fe0f","🙎♀":"1f64e-200d-2640-fe0f","🙅♂":"1f645-200d-2642-fe0f","🙅♀":"1f645-200d-2640-fe0f","🙆♂":"1f646-200d-2642-fe0f","🙆♀":"1f646-200d-2640-fe0f","💁♂":"1f481-200d-2642-fe0f","💁♀":"1f481-200d-2640-fe0f","🙋♂":"1f64b-200d-2642-fe0f","🙋♀":"1f64b-200d-2640-fe0f","🙇♂":"1f647-200d-2642-fe0f","🙇♀":"1f647-200d-2640-fe0f","🤦♂":"1f926-200d-2642-fe0f","🤦♀":"1f926-200d-2640-fe0f","🤷♂":"1f937-200d-2642-fe0f","🤷♀":"1f937-200d-2640-fe0f","💆♂":"1f486-200d-2642-fe0f","💆♀":"1f486-200d-2640-fe0f","💇♂":"1f487-200d-2642-fe0f","💇♀":"1f487-200d-2640-fe0f","🚶♂":"1f6b6-200d-2642-fe0f","🚶♀":"1f6b6-200d-2640-fe0f","🏃♂":"1f3c3-200d-2642-fe0f","🏃♀":"1f3c3-200d-2640-fe0f","👯♂":"1f46f-200d-2642-fe0f","👯♀":"1f46f-200d-2640-fe0f","🧖♀":"1f9d6-200d-2640-fe0f","🧖♂":"1f9d6-200d-2642-fe0f","🧗♀":"1f9d7-200d-2640-fe0f","🧗♂":"1f9d7-200d-2642-fe0f","🧘♀":"1f9d8-200d-2640-fe0f","🧘♂":"1f9d8-200d-2642-fe0f","🏌♂":"1f3cc-fe0f-200d-2642-fe0f","🏌♀":"1f3cc-fe0f-200d-2640-fe0f","🏄♂":"1f3c4-200d-2642-fe0f","🏄♀":"1f3c4-200d-2640-fe0f","🚣♂":"1f6a3-200d-2642-fe0f","🚣♀":"1f6a3-200d-2640-fe0f","🏊♂":"1f3ca-200d-2642-fe0f","🏊♀":"1f3ca-200d-2640-fe0f","⛹♂":"26f9-fe0f-200d-2642-fe0f","⛹♀":"26f9-fe0f-200d-2640-fe0f","🏋♂":"1f3cb-fe0f-200d-2642-fe0f","🏋♀":"1f3cb-fe0f-200d-2640-fe0f","🚴♂":"1f6b4-200d-2642-fe0f","🚴♀":"1f6b4-200d-2640-fe0f","🚵♂":"1f6b5-200d-2642-fe0f","🚵♀":"1f6b5-200d-2640-fe0f","🤸♂":"1f938-200d-2642-fe0f","🤸♀":"1f938-200d-2640-fe0f","🤼♂":"1f93c-200d-2642-fe0f","🤼♀":"1f93c-200d-2640-fe0f","🤽♂":"1f93d-200d-2642-fe0f","🤽♀":"1f93d-200d-2640-fe0f","🤾♂":"1f93e-200d-2642-fe0f","🤾♀":"1f93e-200d-2640-fe0f","🤹♂":"1f939-200d-2642-fe0f","🤹♀":"1f939-200d-2640-fe0f","👨👦":"1f468-200d-1f466","👨👧":"1f468-200d-1f467","👩👦":"1f469-200d-1f466","👩👧":"1f469-200d-1f467","👁🗨":"1f441-200d-1f5e8","#️⃣":"23-20e3","*️⃣":"2a-20e3","0️⃣":"30-20e3","1️⃣":"31-20e3","2️⃣":"32-20e3","3️⃣":"33-20e3","4️⃣":"34-20e3","5️⃣":"35-20e3","6️⃣":"36-20e3","7️⃣":"37-20e3","8️⃣":"38-20e3","9️⃣":"39-20e3","🏳🌈":"1f3f3-fe0f-200d-1f308","👨⚕️":"1f468-200d-2695-fe0f","👨🏻⚕":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕":"1f468-1f3ff-200d-2695-fe0f","👩⚕️":"1f469-200d-2695-fe0f","👩🏻⚕":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕":"1f469-1f3ff-200d-2695-fe0f","👨🏻🎓":"1f468-1f3fb-200d-1f393","👨🏼🎓":"1f468-1f3fc-200d-1f393","👨🏽🎓":"1f468-1f3fd-200d-1f393","👨🏾🎓":"1f468-1f3fe-200d-1f393","👨🏿🎓":"1f468-1f3ff-200d-1f393","👩🏻🎓":"1f469-1f3fb-200d-1f393","👩🏼🎓":"1f469-1f3fc-200d-1f393","👩🏽🎓":"1f469-1f3fd-200d-1f393","👩🏾🎓":"1f469-1f3fe-200d-1f393","👩🏿🎓":"1f469-1f3ff-200d-1f393","👨🏻🏫":"1f468-1f3fb-200d-1f3eb","👨🏼🏫":"1f468-1f3fc-200d-1f3eb","👨🏽🏫":"1f468-1f3fd-200d-1f3eb","👨🏾🏫":"1f468-1f3fe-200d-1f3eb","👨🏿🏫":"1f468-1f3ff-200d-1f3eb","👩🏻🏫":"1f469-1f3fb-200d-1f3eb","👩🏼🏫":"1f469-1f3fc-200d-1f3eb","👩🏽🏫":"1f469-1f3fd-200d-1f3eb","👩🏾🏫":"1f469-1f3fe-200d-1f3eb","👩🏿🏫":"1f469-1f3ff-200d-1f3eb","👨⚖️":"1f468-200d-2696-fe0f","👨🏻⚖":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖":"1f468-1f3ff-200d-2696-fe0f","👩⚖️":"1f469-200d-2696-fe0f","👩🏻⚖":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖":"1f469-1f3ff-200d-2696-fe0f","👨🏻🌾":"1f468-1f3fb-200d-1f33e","👨🏼🌾":"1f468-1f3fc-200d-1f33e","👨🏽🌾":"1f468-1f3fd-200d-1f33e","👨🏾🌾":"1f468-1f3fe-200d-1f33e","👨🏿🌾":"1f468-1f3ff-200d-1f33e","👩🏻🌾":"1f469-1f3fb-200d-1f33e","👩🏼🌾":"1f469-1f3fc-200d-1f33e","👩🏽🌾":"1f469-1f3fd-200d-1f33e","👩🏾🌾":"1f469-1f3fe-200d-1f33e","👩🏿🌾":"1f469-1f3ff-200d-1f33e","👨🏻🍳":"1f468-1f3fb-200d-1f373","👨🏼🍳":"1f468-1f3fc-200d-1f373","👨🏽🍳":"1f468-1f3fd-200d-1f373","👨🏾🍳":"1f468-1f3fe-200d-1f373","👨🏿🍳":"1f468-1f3ff-200d-1f373","👩🏻🍳":"1f469-1f3fb-200d-1f373","👩🏼🍳":"1f469-1f3fc-200d-1f373","👩🏽🍳":"1f469-1f3fd-200d-1f373","👩🏾🍳":"1f469-1f3fe-200d-1f373","👩🏿🍳":"1f469-1f3ff-200d-1f373","👨🏻🔧":"1f468-1f3fb-200d-1f527","👨🏼🔧":"1f468-1f3fc-200d-1f527","👨🏽🔧":"1f468-1f3fd-200d-1f527","👨🏾🔧":"1f468-1f3fe-200d-1f527","👨🏿🔧":"1f468-1f3ff-200d-1f527","👩🏻🔧":"1f469-1f3fb-200d-1f527","👩🏼🔧":"1f469-1f3fc-200d-1f527","👩🏽🔧":"1f469-1f3fd-200d-1f527","👩🏾🔧":"1f469-1f3fe-200d-1f527","👩🏿🔧":"1f469-1f3ff-200d-1f527","👨🏻🏭":"1f468-1f3fb-200d-1f3ed","👨🏼🏭":"1f468-1f3fc-200d-1f3ed","👨🏽🏭":"1f468-1f3fd-200d-1f3ed","👨🏾🏭":"1f468-1f3fe-200d-1f3ed","👨🏿🏭":"1f468-1f3ff-200d-1f3ed","👩🏻🏭":"1f469-1f3fb-200d-1f3ed","👩🏼🏭":"1f469-1f3fc-200d-1f3ed","👩🏽🏭":"1f469-1f3fd-200d-1f3ed","👩🏾🏭":"1f469-1f3fe-200d-1f3ed","👩🏿🏭":"1f469-1f3ff-200d-1f3ed","👨🏻💼":"1f468-1f3fb-200d-1f4bc","👨🏼💼":"1f468-1f3fc-200d-1f4bc","👨🏽💼":"1f468-1f3fd-200d-1f4bc","👨🏾💼":"1f468-1f3fe-200d-1f4bc","👨🏿💼":"1f468-1f3ff-200d-1f4bc","👩🏻💼":"1f469-1f3fb-200d-1f4bc","👩🏼💼":"1f469-1f3fc-200d-1f4bc","👩🏽💼":"1f469-1f3fd-200d-1f4bc","👩🏾💼":"1f469-1f3fe-200d-1f4bc","👩🏿💼":"1f469-1f3ff-200d-1f4bc","👨🏻🔬":"1f468-1f3fb-200d-1f52c","👨🏼🔬":"1f468-1f3fc-200d-1f52c","👨🏽🔬":"1f468-1f3fd-200d-1f52c","👨🏾🔬":"1f468-1f3fe-200d-1f52c","👨🏿🔬":"1f468-1f3ff-200d-1f52c","👩🏻🔬":"1f469-1f3fb-200d-1f52c","👩🏼🔬":"1f469-1f3fc-200d-1f52c","👩🏽🔬":"1f469-1f3fd-200d-1f52c","👩🏾🔬":"1f469-1f3fe-200d-1f52c","👩🏿🔬":"1f469-1f3ff-200d-1f52c","👨🏻💻":"1f468-1f3fb-200d-1f4bb","👨🏼💻":"1f468-1f3fc-200d-1f4bb","👨🏽💻":"1f468-1f3fd-200d-1f4bb","👨🏾💻":"1f468-1f3fe-200d-1f4bb","👨🏿💻":"1f468-1f3ff-200d-1f4bb","👩🏻💻":"1f469-1f3fb-200d-1f4bb","👩🏼💻":"1f469-1f3fc-200d-1f4bb","👩🏽💻":"1f469-1f3fd-200d-1f4bb","👩🏾💻":"1f469-1f3fe-200d-1f4bb","👩🏿💻":"1f469-1f3ff-200d-1f4bb","👨🏻🎤":"1f468-1f3fb-200d-1f3a4","👨🏼🎤":"1f468-1f3fc-200d-1f3a4","👨🏽🎤":"1f468-1f3fd-200d-1f3a4","👨🏾🎤":"1f468-1f3fe-200d-1f3a4","👨🏿🎤":"1f468-1f3ff-200d-1f3a4","👩🏻🎤":"1f469-1f3fb-200d-1f3a4","👩🏼🎤":"1f469-1f3fc-200d-1f3a4","👩🏽🎤":"1f469-1f3fd-200d-1f3a4","👩🏾🎤":"1f469-1f3fe-200d-1f3a4","👩🏿🎤":"1f469-1f3ff-200d-1f3a4","👨🏻🎨":"1f468-1f3fb-200d-1f3a8","👨🏼🎨":"1f468-1f3fc-200d-1f3a8","👨🏽🎨":"1f468-1f3fd-200d-1f3a8","👨🏾🎨":"1f468-1f3fe-200d-1f3a8","👨🏿🎨":"1f468-1f3ff-200d-1f3a8","👩🏻🎨":"1f469-1f3fb-200d-1f3a8","👩🏼🎨":"1f469-1f3fc-200d-1f3a8","👩🏽🎨":"1f469-1f3fd-200d-1f3a8","👩🏾🎨":"1f469-1f3fe-200d-1f3a8","👩🏿🎨":"1f469-1f3ff-200d-1f3a8","👨✈️":"1f468-200d-2708-fe0f","👨🏻✈":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈":"1f468-1f3ff-200d-2708-fe0f","👩✈️":"1f469-200d-2708-fe0f","👩🏻✈":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈":"1f469-1f3ff-200d-2708-fe0f","👨🏻🚀":"1f468-1f3fb-200d-1f680","👨🏼🚀":"1f468-1f3fc-200d-1f680","👨🏽🚀":"1f468-1f3fd-200d-1f680","👨🏾🚀":"1f468-1f3fe-200d-1f680","👨🏿🚀":"1f468-1f3ff-200d-1f680","👩🏻🚀":"1f469-1f3fb-200d-1f680","👩🏼🚀":"1f469-1f3fc-200d-1f680","👩🏽🚀":"1f469-1f3fd-200d-1f680","👩🏾🚀":"1f469-1f3fe-200d-1f680","👩🏿🚀":"1f469-1f3ff-200d-1f680","👨🏻🚒":"1f468-1f3fb-200d-1f692","👨🏼🚒":"1f468-1f3fc-200d-1f692","👨🏽🚒":"1f468-1f3fd-200d-1f692","👨🏾🚒":"1f468-1f3fe-200d-1f692","👨🏿🚒":"1f468-1f3ff-200d-1f692","👩🏻🚒":"1f469-1f3fb-200d-1f692","👩🏼🚒":"1f469-1f3fc-200d-1f692","👩🏽🚒":"1f469-1f3fd-200d-1f692","👩🏾🚒":"1f469-1f3fe-200d-1f692","👩🏿🚒":"1f469-1f3ff-200d-1f692","👮♂️":"1f46e-200d-2642-fe0f","👮🏻♂":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂":"1f46e-1f3ff-200d-2642-fe0f","👮♀️":"1f46e-200d-2640-fe0f","👮🏻♀":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀":"1f46e-1f3ff-200d-2640-fe0f","🕵♂️":"1f575-fe0f-200d-2642-fe0f","🕵️♂":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂":"1f575-1f3ff-200d-2642-fe0f","🕵♀️":"1f575-fe0f-200d-2640-fe0f","🕵️♀":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀":"1f575-1f3ff-200d-2640-fe0f","💂♂️":"1f482-200d-2642-fe0f","💂🏻♂":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂":"1f482-1f3ff-200d-2642-fe0f","💂♀️":"1f482-200d-2640-fe0f","💂🏻♀":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀":"1f482-1f3ff-200d-2640-fe0f","👷♂️":"1f477-200d-2642-fe0f","👷🏻♂":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂":"1f477-1f3ff-200d-2642-fe0f","👷♀️":"1f477-200d-2640-fe0f","👷🏻♀":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀":"1f477-1f3ff-200d-2640-fe0f","👳♂️":"1f473-200d-2642-fe0f","👳🏻♂":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂":"1f473-1f3ff-200d-2642-fe0f","👳♀️":"1f473-200d-2640-fe0f","👳🏻♀":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀":"1f473-1f3ff-200d-2640-fe0f","👱♂️":"1f471-200d-2642-fe0f","👱🏻♂":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂":"1f471-1f3ff-200d-2642-fe0f","👱♀️":"1f471-200d-2640-fe0f","👱🏻♀":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀":"1f471-1f3ff-200d-2640-fe0f","🧙♀️":"1f9d9-200d-2640-fe0f","🧙🏻♀":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀":"1f9d9-1f3ff-200d-2640-fe0f","🧙♂️":"1f9d9-200d-2642-fe0f","🧙🏻♂":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂":"1f9d9-1f3ff-200d-2642-fe0f","🧚♀️":"1f9da-200d-2640-fe0f","🧚🏻♀":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀":"1f9da-1f3ff-200d-2640-fe0f","🧚♂️":"1f9da-200d-2642-fe0f","🧚🏻♂":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂":"1f9da-1f3ff-200d-2642-fe0f","🧛♀️":"1f9db-200d-2640-fe0f","🧛🏻♀":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀":"1f9db-1f3ff-200d-2640-fe0f","🧛♂️":"1f9db-200d-2642-fe0f","🧛🏻♂":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂":"1f9db-1f3ff-200d-2642-fe0f","🧜♀️":"1f9dc-200d-2640-fe0f","🧜🏻♀":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀":"1f9dc-1f3ff-200d-2640-fe0f","🧜♂️":"1f9dc-200d-2642-fe0f","🧜🏻♂":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂":"1f9dc-1f3ff-200d-2642-fe0f","🧝♀️":"1f9dd-200d-2640-fe0f","🧝🏻♀":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀":"1f9dd-1f3ff-200d-2640-fe0f","🧝♂️":"1f9dd-200d-2642-fe0f","🧝🏻♂":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂":"1f9dd-1f3ff-200d-2642-fe0f","🧞♀️":"1f9de-200d-2640-fe0f","🧞♂️":"1f9de-200d-2642-fe0f","🧟♀️":"1f9df-200d-2640-fe0f","🧟♂️":"1f9df-200d-2642-fe0f","🙍♂️":"1f64d-200d-2642-fe0f","🙍🏻♂":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂":"1f64d-1f3ff-200d-2642-fe0f","🙍♀️":"1f64d-200d-2640-fe0f","🙍🏻♀":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀":"1f64d-1f3ff-200d-2640-fe0f","🙎♂️":"1f64e-200d-2642-fe0f","🙎🏻♂":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂":"1f64e-1f3ff-200d-2642-fe0f","🙎♀️":"1f64e-200d-2640-fe0f","🙎🏻♀":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀":"1f64e-1f3ff-200d-2640-fe0f","🙅♂️":"1f645-200d-2642-fe0f","🙅🏻♂":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂":"1f645-1f3ff-200d-2642-fe0f","🙅♀️":"1f645-200d-2640-fe0f","🙅🏻♀":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀":"1f645-1f3ff-200d-2640-fe0f","🙆♂️":"1f646-200d-2642-fe0f","🙆🏻♂":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂":"1f646-1f3ff-200d-2642-fe0f","🙆♀️":"1f646-200d-2640-fe0f","🙆🏻♀":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀":"1f646-1f3ff-200d-2640-fe0f","💁♂️":"1f481-200d-2642-fe0f","💁🏻♂":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂":"1f481-1f3ff-200d-2642-fe0f","💁♀️":"1f481-200d-2640-fe0f","💁🏻♀":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀":"1f481-1f3ff-200d-2640-fe0f","🙋♂️":"1f64b-200d-2642-fe0f","🙋🏻♂":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂":"1f64b-1f3ff-200d-2642-fe0f","🙋♀️":"1f64b-200d-2640-fe0f","🙋🏻♀":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀":"1f64b-1f3ff-200d-2640-fe0f","🙇♂️":"1f647-200d-2642-fe0f","🙇🏻♂":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂":"1f647-1f3ff-200d-2642-fe0f","🙇♀️":"1f647-200d-2640-fe0f","🙇🏻♀":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀":"1f647-1f3ff-200d-2640-fe0f","🤦♂️":"1f926-200d-2642-fe0f","🤦🏻♂":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂":"1f926-1f3ff-200d-2642-fe0f","🤦♀️":"1f926-200d-2640-fe0f","🤦🏻♀":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀":"1f926-1f3ff-200d-2640-fe0f","🤷♂️":"1f937-200d-2642-fe0f","🤷🏻♂":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂":"1f937-1f3ff-200d-2642-fe0f","🤷♀️":"1f937-200d-2640-fe0f","🤷🏻♀":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀":"1f937-1f3ff-200d-2640-fe0f","💆♂️":"1f486-200d-2642-fe0f","💆🏻♂":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂":"1f486-1f3ff-200d-2642-fe0f","💆♀️":"1f486-200d-2640-fe0f","💆🏻♀":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀":"1f486-1f3ff-200d-2640-fe0f","💇♂️":"1f487-200d-2642-fe0f","💇🏻♂":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂":"1f487-1f3ff-200d-2642-fe0f","💇♀️":"1f487-200d-2640-fe0f","💇🏻♀":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀":"1f487-1f3ff-200d-2640-fe0f","🚶♂️":"1f6b6-200d-2642-fe0f","🚶🏻♂":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂":"1f6b6-1f3ff-200d-2642-fe0f","🚶♀️":"1f6b6-200d-2640-fe0f","🚶🏻♀":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀":"1f6b6-1f3ff-200d-2640-fe0f","🏃♂️":"1f3c3-200d-2642-fe0f","🏃🏻♂":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂":"1f3c3-1f3ff-200d-2642-fe0f","🏃♀️":"1f3c3-200d-2640-fe0f","🏃🏻♀":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀":"1f3c3-1f3ff-200d-2640-fe0f","👯♂️":"1f46f-200d-2642-fe0f","👯♀️":"1f46f-200d-2640-fe0f","🧖♀️":"1f9d6-200d-2640-fe0f","🧖🏻♀":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀":"1f9d6-1f3ff-200d-2640-fe0f","🧖♂️":"1f9d6-200d-2642-fe0f","🧖🏻♂":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂":"1f9d6-1f3ff-200d-2642-fe0f","🧗♀️":"1f9d7-200d-2640-fe0f","🧗🏻♀":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀":"1f9d7-1f3ff-200d-2640-fe0f","🧗♂️":"1f9d7-200d-2642-fe0f","🧗🏻♂":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂":"1f9d7-1f3ff-200d-2642-fe0f","🧘♀️":"1f9d8-200d-2640-fe0f","🧘🏻♀":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀":"1f9d8-1f3ff-200d-2640-fe0f","🧘♂️":"1f9d8-200d-2642-fe0f","🧘🏻♂":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂":"1f9d8-1f3ff-200d-2642-fe0f","🏌♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌️♂":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂":"1f3cc-1f3ff-200d-2642-fe0f","🏌♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌️♀":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀":"1f3cc-1f3ff-200d-2640-fe0f","🏄♂️":"1f3c4-200d-2642-fe0f","🏄🏻♂":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂":"1f3c4-1f3ff-200d-2642-fe0f","🏄♀️":"1f3c4-200d-2640-fe0f","🏄🏻♀":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀":"1f3c4-1f3ff-200d-2640-fe0f","🚣♂️":"1f6a3-200d-2642-fe0f","🚣🏻♂":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂":"1f6a3-1f3ff-200d-2642-fe0f","🚣♀️":"1f6a3-200d-2640-fe0f","🚣🏻♀":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀":"1f6a3-1f3ff-200d-2640-fe0f","🏊♂️":"1f3ca-200d-2642-fe0f","🏊🏻♂":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂":"1f3ca-1f3ff-200d-2642-fe0f","🏊♀️":"1f3ca-200d-2640-fe0f","🏊🏻♀":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀":"1f3ca-1f3ff-200d-2640-fe0f","⛹♂️":"26f9-fe0f-200d-2642-fe0f","⛹️♂":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂":"26f9-1f3ff-200d-2642-fe0f","⛹♀️":"26f9-fe0f-200d-2640-fe0f","⛹️♀":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀":"26f9-1f3ff-200d-2640-fe0f","🏋♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋️♂":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂":"1f3cb-1f3ff-200d-2642-fe0f","🏋♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋️♀":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀":"1f3cb-1f3ff-200d-2640-fe0f","🚴♂️":"1f6b4-200d-2642-fe0f","🚴🏻♂":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂":"1f6b4-1f3ff-200d-2642-fe0f","🚴♀️":"1f6b4-200d-2640-fe0f","🚴🏻♀":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀":"1f6b4-1f3ff-200d-2640-fe0f","🚵♂️":"1f6b5-200d-2642-fe0f","🚵🏻♂":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂":"1f6b5-1f3ff-200d-2642-fe0f","🚵♀️":"1f6b5-200d-2640-fe0f","🚵🏻♀":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀":"1f6b5-1f3ff-200d-2640-fe0f","🤸♂️":"1f938-200d-2642-fe0f","🤸🏻♂":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂":"1f938-1f3ff-200d-2642-fe0f","🤸♀️":"1f938-200d-2640-fe0f","🤸🏻♀":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀":"1f938-1f3ff-200d-2640-fe0f","🤼♂️":"1f93c-200d-2642-fe0f","🤼♀️":"1f93c-200d-2640-fe0f","🤽♂️":"1f93d-200d-2642-fe0f","🤽🏻♂":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂":"1f93d-1f3ff-200d-2642-fe0f","🤽♀️":"1f93d-200d-2640-fe0f","🤽🏻♀":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀":"1f93d-1f3ff-200d-2640-fe0f","🤾♂️":"1f93e-200d-2642-fe0f","🤾🏻♂":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂":"1f93e-1f3ff-200d-2642-fe0f","🤾♀️":"1f93e-200d-2640-fe0f","🤾🏻♀":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀":"1f93e-1f3ff-200d-2640-fe0f","🤹♂️":"1f939-200d-2642-fe0f","🤹🏻♂":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂":"1f939-1f3ff-200d-2642-fe0f","🤹♀️":"1f939-200d-2640-fe0f","🤹🏻♀":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀":"1f939-1f3ff-200d-2640-fe0f","👁🗨️":"1f441-200d-1f5e8","👁️🗨":"1f441-200d-1f5e8","🏳️🌈":"1f3f3-fe0f-200d-1f308","👨🏻⚕️":"1f468-1f3fb-200d-2695-fe0f","👨🏼⚕️":"1f468-1f3fc-200d-2695-fe0f","👨🏽⚕️":"1f468-1f3fd-200d-2695-fe0f","👨🏾⚕️":"1f468-1f3fe-200d-2695-fe0f","👨🏿⚕️":"1f468-1f3ff-200d-2695-fe0f","👩🏻⚕️":"1f469-1f3fb-200d-2695-fe0f","👩🏼⚕️":"1f469-1f3fc-200d-2695-fe0f","👩🏽⚕️":"1f469-1f3fd-200d-2695-fe0f","👩🏾⚕️":"1f469-1f3fe-200d-2695-fe0f","👩🏿⚕️":"1f469-1f3ff-200d-2695-fe0f","👨🏻⚖️":"1f468-1f3fb-200d-2696-fe0f","👨🏼⚖️":"1f468-1f3fc-200d-2696-fe0f","👨🏽⚖️":"1f468-1f3fd-200d-2696-fe0f","👨🏾⚖️":"1f468-1f3fe-200d-2696-fe0f","👨🏿⚖️":"1f468-1f3ff-200d-2696-fe0f","👩🏻⚖️":"1f469-1f3fb-200d-2696-fe0f","👩🏼⚖️":"1f469-1f3fc-200d-2696-fe0f","👩🏽⚖️":"1f469-1f3fd-200d-2696-fe0f","👩🏾⚖️":"1f469-1f3fe-200d-2696-fe0f","👩🏿⚖️":"1f469-1f3ff-200d-2696-fe0f","👨🏻✈️":"1f468-1f3fb-200d-2708-fe0f","👨🏼✈️":"1f468-1f3fc-200d-2708-fe0f","👨🏽✈️":"1f468-1f3fd-200d-2708-fe0f","👨🏾✈️":"1f468-1f3fe-200d-2708-fe0f","👨🏿✈️":"1f468-1f3ff-200d-2708-fe0f","👩🏻✈️":"1f469-1f3fb-200d-2708-fe0f","👩🏼✈️":"1f469-1f3fc-200d-2708-fe0f","👩🏽✈️":"1f469-1f3fd-200d-2708-fe0f","👩🏾✈️":"1f469-1f3fe-200d-2708-fe0f","👩🏿✈️":"1f469-1f3ff-200d-2708-fe0f","👮🏻♂️":"1f46e-1f3fb-200d-2642-fe0f","👮🏼♂️":"1f46e-1f3fc-200d-2642-fe0f","👮🏽♂️":"1f46e-1f3fd-200d-2642-fe0f","👮🏾♂️":"1f46e-1f3fe-200d-2642-fe0f","👮🏿♂️":"1f46e-1f3ff-200d-2642-fe0f","👮🏻♀️":"1f46e-1f3fb-200d-2640-fe0f","👮🏼♀️":"1f46e-1f3fc-200d-2640-fe0f","👮🏽♀️":"1f46e-1f3fd-200d-2640-fe0f","👮🏾♀️":"1f46e-1f3fe-200d-2640-fe0f","👮🏿♀️":"1f46e-1f3ff-200d-2640-fe0f","🕵️♂️":"1f575-fe0f-200d-2642-fe0f","🕵🏻♂️":"1f575-1f3fb-200d-2642-fe0f","🕵🏼♂️":"1f575-1f3fc-200d-2642-fe0f","🕵🏽♂️":"1f575-1f3fd-200d-2642-fe0f","🕵🏾♂️":"1f575-1f3fe-200d-2642-fe0f","🕵🏿♂️":"1f575-1f3ff-200d-2642-fe0f","🕵️♀️":"1f575-fe0f-200d-2640-fe0f","🕵🏻♀️":"1f575-1f3fb-200d-2640-fe0f","🕵🏼♀️":"1f575-1f3fc-200d-2640-fe0f","🕵🏽♀️":"1f575-1f3fd-200d-2640-fe0f","🕵🏾♀️":"1f575-1f3fe-200d-2640-fe0f","🕵🏿♀️":"1f575-1f3ff-200d-2640-fe0f","💂🏻♂️":"1f482-1f3fb-200d-2642-fe0f","💂🏼♂️":"1f482-1f3fc-200d-2642-fe0f","💂🏽♂️":"1f482-1f3fd-200d-2642-fe0f","💂🏾♂️":"1f482-1f3fe-200d-2642-fe0f","💂🏿♂️":"1f482-1f3ff-200d-2642-fe0f","💂🏻♀️":"1f482-1f3fb-200d-2640-fe0f","💂🏼♀️":"1f482-1f3fc-200d-2640-fe0f","💂🏽♀️":"1f482-1f3fd-200d-2640-fe0f","💂🏾♀️":"1f482-1f3fe-200d-2640-fe0f","💂🏿♀️":"1f482-1f3ff-200d-2640-fe0f","👷🏻♂️":"1f477-1f3fb-200d-2642-fe0f","👷🏼♂️":"1f477-1f3fc-200d-2642-fe0f","👷🏽♂️":"1f477-1f3fd-200d-2642-fe0f","👷🏾♂️":"1f477-1f3fe-200d-2642-fe0f","👷🏿♂️":"1f477-1f3ff-200d-2642-fe0f","👷🏻♀️":"1f477-1f3fb-200d-2640-fe0f","👷🏼♀️":"1f477-1f3fc-200d-2640-fe0f","👷🏽♀️":"1f477-1f3fd-200d-2640-fe0f","👷🏾♀️":"1f477-1f3fe-200d-2640-fe0f","👷🏿♀️":"1f477-1f3ff-200d-2640-fe0f","👳🏻♂️":"1f473-1f3fb-200d-2642-fe0f","👳🏼♂️":"1f473-1f3fc-200d-2642-fe0f","👳🏽♂️":"1f473-1f3fd-200d-2642-fe0f","👳🏾♂️":"1f473-1f3fe-200d-2642-fe0f","👳🏿♂️":"1f473-1f3ff-200d-2642-fe0f","👳🏻♀️":"1f473-1f3fb-200d-2640-fe0f","👳🏼♀️":"1f473-1f3fc-200d-2640-fe0f","👳🏽♀️":"1f473-1f3fd-200d-2640-fe0f","👳🏾♀️":"1f473-1f3fe-200d-2640-fe0f","👳🏿♀️":"1f473-1f3ff-200d-2640-fe0f","👱🏻♂️":"1f471-1f3fb-200d-2642-fe0f","👱🏼♂️":"1f471-1f3fc-200d-2642-fe0f","👱🏽♂️":"1f471-1f3fd-200d-2642-fe0f","👱🏾♂️":"1f471-1f3fe-200d-2642-fe0f","👱🏿♂️":"1f471-1f3ff-200d-2642-fe0f","👱🏻♀️":"1f471-1f3fb-200d-2640-fe0f","👱🏼♀️":"1f471-1f3fc-200d-2640-fe0f","👱🏽♀️":"1f471-1f3fd-200d-2640-fe0f","👱🏾♀️":"1f471-1f3fe-200d-2640-fe0f","👱🏿♀️":"1f471-1f3ff-200d-2640-fe0f","🧙🏻♀️":"1f9d9-1f3fb-200d-2640-fe0f","🧙🏼♀️":"1f9d9-1f3fc-200d-2640-fe0f","🧙🏽♀️":"1f9d9-1f3fd-200d-2640-fe0f","🧙🏾♀️":"1f9d9-1f3fe-200d-2640-fe0f","🧙🏿♀️":"1f9d9-1f3ff-200d-2640-fe0f","🧙🏻♂️":"1f9d9-1f3fb-200d-2642-fe0f","🧙🏼♂️":"1f9d9-1f3fc-200d-2642-fe0f","🧙🏽♂️":"1f9d9-1f3fd-200d-2642-fe0f","🧙🏾♂️":"1f9d9-1f3fe-200d-2642-fe0f","🧙🏿♂️":"1f9d9-1f3ff-200d-2642-fe0f","🧚🏻♀️":"1f9da-1f3fb-200d-2640-fe0f","🧚🏼♀️":"1f9da-1f3fc-200d-2640-fe0f","🧚🏽♀️":"1f9da-1f3fd-200d-2640-fe0f","🧚🏾♀️":"1f9da-1f3fe-200d-2640-fe0f","🧚🏿♀️":"1f9da-1f3ff-200d-2640-fe0f","🧚🏻♂️":"1f9da-1f3fb-200d-2642-fe0f","🧚🏼♂️":"1f9da-1f3fc-200d-2642-fe0f","🧚🏽♂️":"1f9da-1f3fd-200d-2642-fe0f","🧚🏾♂️":"1f9da-1f3fe-200d-2642-fe0f","🧚🏿♂️":"1f9da-1f3ff-200d-2642-fe0f","🧛🏻♀️":"1f9db-1f3fb-200d-2640-fe0f","🧛🏼♀️":"1f9db-1f3fc-200d-2640-fe0f","🧛🏽♀️":"1f9db-1f3fd-200d-2640-fe0f","🧛🏾♀️":"1f9db-1f3fe-200d-2640-fe0f","🧛🏿♀️":"1f9db-1f3ff-200d-2640-fe0f","🧛🏻♂️":"1f9db-1f3fb-200d-2642-fe0f","🧛🏼♂️":"1f9db-1f3fc-200d-2642-fe0f","🧛🏽♂️":"1f9db-1f3fd-200d-2642-fe0f","🧛🏾♂️":"1f9db-1f3fe-200d-2642-fe0f","🧛🏿♂️":"1f9db-1f3ff-200d-2642-fe0f","🧜🏻♀️":"1f9dc-1f3fb-200d-2640-fe0f","🧜🏼♀️":"1f9dc-1f3fc-200d-2640-fe0f","🧜🏽♀️":"1f9dc-1f3fd-200d-2640-fe0f","🧜🏾♀️":"1f9dc-1f3fe-200d-2640-fe0f","🧜🏿♀️":"1f9dc-1f3ff-200d-2640-fe0f","🧜🏻♂️":"1f9dc-1f3fb-200d-2642-fe0f","🧜🏼♂️":"1f9dc-1f3fc-200d-2642-fe0f","🧜🏽♂️":"1f9dc-1f3fd-200d-2642-fe0f","🧜🏾♂️":"1f9dc-1f3fe-200d-2642-fe0f","🧜🏿♂️":"1f9dc-1f3ff-200d-2642-fe0f","🧝🏻♀️":"1f9dd-1f3fb-200d-2640-fe0f","🧝🏼♀️":"1f9dd-1f3fc-200d-2640-fe0f","🧝🏽♀️":"1f9dd-1f3fd-200d-2640-fe0f","🧝🏾♀️":"1f9dd-1f3fe-200d-2640-fe0f","🧝🏿♀️":"1f9dd-1f3ff-200d-2640-fe0f","🧝🏻♂️":"1f9dd-1f3fb-200d-2642-fe0f","🧝🏼♂️":"1f9dd-1f3fc-200d-2642-fe0f","🧝🏽♂️":"1f9dd-1f3fd-200d-2642-fe0f","🧝🏾♂️":"1f9dd-1f3fe-200d-2642-fe0f","🧝🏿♂️":"1f9dd-1f3ff-200d-2642-fe0f","🙍🏻♂️":"1f64d-1f3fb-200d-2642-fe0f","🙍🏼♂️":"1f64d-1f3fc-200d-2642-fe0f","🙍🏽♂️":"1f64d-1f3fd-200d-2642-fe0f","🙍🏾♂️":"1f64d-1f3fe-200d-2642-fe0f","🙍🏿♂️":"1f64d-1f3ff-200d-2642-fe0f","🙍🏻♀️":"1f64d-1f3fb-200d-2640-fe0f","🙍🏼♀️":"1f64d-1f3fc-200d-2640-fe0f","🙍🏽♀️":"1f64d-1f3fd-200d-2640-fe0f","🙍🏾♀️":"1f64d-1f3fe-200d-2640-fe0f","🙍🏿♀️":"1f64d-1f3ff-200d-2640-fe0f","🙎🏻♂️":"1f64e-1f3fb-200d-2642-fe0f","🙎🏼♂️":"1f64e-1f3fc-200d-2642-fe0f","🙎🏽♂️":"1f64e-1f3fd-200d-2642-fe0f","🙎🏾♂️":"1f64e-1f3fe-200d-2642-fe0f","🙎🏿♂️":"1f64e-1f3ff-200d-2642-fe0f","🙎🏻♀️":"1f64e-1f3fb-200d-2640-fe0f","🙎🏼♀️":"1f64e-1f3fc-200d-2640-fe0f","🙎🏽♀️":"1f64e-1f3fd-200d-2640-fe0f","🙎🏾♀️":"1f64e-1f3fe-200d-2640-fe0f","🙎🏿♀️":"1f64e-1f3ff-200d-2640-fe0f","🙅🏻♂️":"1f645-1f3fb-200d-2642-fe0f","🙅🏼♂️":"1f645-1f3fc-200d-2642-fe0f","🙅🏽♂️":"1f645-1f3fd-200d-2642-fe0f","🙅🏾♂️":"1f645-1f3fe-200d-2642-fe0f","🙅🏿♂️":"1f645-1f3ff-200d-2642-fe0f","🙅🏻♀️":"1f645-1f3fb-200d-2640-fe0f","🙅🏼♀️":"1f645-1f3fc-200d-2640-fe0f","🙅🏽♀️":"1f645-1f3fd-200d-2640-fe0f","🙅🏾♀️":"1f645-1f3fe-200d-2640-fe0f","🙅🏿♀️":"1f645-1f3ff-200d-2640-fe0f","🙆🏻♂️":"1f646-1f3fb-200d-2642-fe0f","🙆🏼♂️":"1f646-1f3fc-200d-2642-fe0f","🙆🏽♂️":"1f646-1f3fd-200d-2642-fe0f","🙆🏾♂️":"1f646-1f3fe-200d-2642-fe0f","🙆🏿♂️":"1f646-1f3ff-200d-2642-fe0f","🙆🏻♀️":"1f646-1f3fb-200d-2640-fe0f","🙆🏼♀️":"1f646-1f3fc-200d-2640-fe0f","🙆🏽♀️":"1f646-1f3fd-200d-2640-fe0f","🙆🏾♀️":"1f646-1f3fe-200d-2640-fe0f","🙆🏿♀️":"1f646-1f3ff-200d-2640-fe0f","💁🏻♂️":"1f481-1f3fb-200d-2642-fe0f","💁🏼♂️":"1f481-1f3fc-200d-2642-fe0f","💁🏽♂️":"1f481-1f3fd-200d-2642-fe0f","💁🏾♂️":"1f481-1f3fe-200d-2642-fe0f","💁🏿♂️":"1f481-1f3ff-200d-2642-fe0f","💁🏻♀️":"1f481-1f3fb-200d-2640-fe0f","💁🏼♀️":"1f481-1f3fc-200d-2640-fe0f","💁🏽♀️":"1f481-1f3fd-200d-2640-fe0f","💁🏾♀️":"1f481-1f3fe-200d-2640-fe0f","💁🏿♀️":"1f481-1f3ff-200d-2640-fe0f","🙋🏻♂️":"1f64b-1f3fb-200d-2642-fe0f","🙋🏼♂️":"1f64b-1f3fc-200d-2642-fe0f","🙋🏽♂️":"1f64b-1f3fd-200d-2642-fe0f","🙋🏾♂️":"1f64b-1f3fe-200d-2642-fe0f","🙋🏿♂️":"1f64b-1f3ff-200d-2642-fe0f","🙋🏻♀️":"1f64b-1f3fb-200d-2640-fe0f","🙋🏼♀️":"1f64b-1f3fc-200d-2640-fe0f","🙋🏽♀️":"1f64b-1f3fd-200d-2640-fe0f","🙋🏾♀️":"1f64b-1f3fe-200d-2640-fe0f","🙋🏿♀️":"1f64b-1f3ff-200d-2640-fe0f","🙇🏻♂️":"1f647-1f3fb-200d-2642-fe0f","🙇🏼♂️":"1f647-1f3fc-200d-2642-fe0f","🙇🏽♂️":"1f647-1f3fd-200d-2642-fe0f","🙇🏾♂️":"1f647-1f3fe-200d-2642-fe0f","🙇🏿♂️":"1f647-1f3ff-200d-2642-fe0f","🙇🏻♀️":"1f647-1f3fb-200d-2640-fe0f","🙇🏼♀️":"1f647-1f3fc-200d-2640-fe0f","🙇🏽♀️":"1f647-1f3fd-200d-2640-fe0f","🙇🏾♀️":"1f647-1f3fe-200d-2640-fe0f","🙇🏿♀️":"1f647-1f3ff-200d-2640-fe0f","🤦🏻♂️":"1f926-1f3fb-200d-2642-fe0f","🤦🏼♂️":"1f926-1f3fc-200d-2642-fe0f","🤦🏽♂️":"1f926-1f3fd-200d-2642-fe0f","🤦🏾♂️":"1f926-1f3fe-200d-2642-fe0f","🤦🏿♂️":"1f926-1f3ff-200d-2642-fe0f","🤦🏻♀️":"1f926-1f3fb-200d-2640-fe0f","🤦🏼♀️":"1f926-1f3fc-200d-2640-fe0f","🤦🏽♀️":"1f926-1f3fd-200d-2640-fe0f","🤦🏾♀️":"1f926-1f3fe-200d-2640-fe0f","🤦🏿♀️":"1f926-1f3ff-200d-2640-fe0f","🤷🏻♂️":"1f937-1f3fb-200d-2642-fe0f","🤷🏼♂️":"1f937-1f3fc-200d-2642-fe0f","🤷🏽♂️":"1f937-1f3fd-200d-2642-fe0f","🤷🏾♂️":"1f937-1f3fe-200d-2642-fe0f","🤷🏿♂️":"1f937-1f3ff-200d-2642-fe0f","🤷🏻♀️":"1f937-1f3fb-200d-2640-fe0f","🤷🏼♀️":"1f937-1f3fc-200d-2640-fe0f","🤷🏽♀️":"1f937-1f3fd-200d-2640-fe0f","🤷🏾♀️":"1f937-1f3fe-200d-2640-fe0f","🤷🏿♀️":"1f937-1f3ff-200d-2640-fe0f","💆🏻♂️":"1f486-1f3fb-200d-2642-fe0f","💆🏼♂️":"1f486-1f3fc-200d-2642-fe0f","💆🏽♂️":"1f486-1f3fd-200d-2642-fe0f","💆🏾♂️":"1f486-1f3fe-200d-2642-fe0f","💆🏿♂️":"1f486-1f3ff-200d-2642-fe0f","💆🏻♀️":"1f486-1f3fb-200d-2640-fe0f","💆🏼♀️":"1f486-1f3fc-200d-2640-fe0f","💆🏽♀️":"1f486-1f3fd-200d-2640-fe0f","💆🏾♀️":"1f486-1f3fe-200d-2640-fe0f","💆🏿♀️":"1f486-1f3ff-200d-2640-fe0f","💇🏻♂️":"1f487-1f3fb-200d-2642-fe0f","💇🏼♂️":"1f487-1f3fc-200d-2642-fe0f","💇🏽♂️":"1f487-1f3fd-200d-2642-fe0f","💇🏾♂️":"1f487-1f3fe-200d-2642-fe0f","💇🏿♂️":"1f487-1f3ff-200d-2642-fe0f","💇🏻♀️":"1f487-1f3fb-200d-2640-fe0f","💇🏼♀️":"1f487-1f3fc-200d-2640-fe0f","💇🏽♀️":"1f487-1f3fd-200d-2640-fe0f","💇🏾♀️":"1f487-1f3fe-200d-2640-fe0f","💇🏿♀️":"1f487-1f3ff-200d-2640-fe0f","🚶🏻♂️":"1f6b6-1f3fb-200d-2642-fe0f","🚶🏼♂️":"1f6b6-1f3fc-200d-2642-fe0f","🚶🏽♂️":"1f6b6-1f3fd-200d-2642-fe0f","🚶🏾♂️":"1f6b6-1f3fe-200d-2642-fe0f","🚶🏿♂️":"1f6b6-1f3ff-200d-2642-fe0f","🚶🏻♀️":"1f6b6-1f3fb-200d-2640-fe0f","🚶🏼♀️":"1f6b6-1f3fc-200d-2640-fe0f","🚶🏽♀️":"1f6b6-1f3fd-200d-2640-fe0f","🚶🏾♀️":"1f6b6-1f3fe-200d-2640-fe0f","🚶🏿♀️":"1f6b6-1f3ff-200d-2640-fe0f","🏃🏻♂️":"1f3c3-1f3fb-200d-2642-fe0f","🏃🏼♂️":"1f3c3-1f3fc-200d-2642-fe0f","🏃🏽♂️":"1f3c3-1f3fd-200d-2642-fe0f","🏃🏾♂️":"1f3c3-1f3fe-200d-2642-fe0f","🏃🏿♂️":"1f3c3-1f3ff-200d-2642-fe0f","🏃🏻♀️":"1f3c3-1f3fb-200d-2640-fe0f","🏃🏼♀️":"1f3c3-1f3fc-200d-2640-fe0f","🏃🏽♀️":"1f3c3-1f3fd-200d-2640-fe0f","🏃🏾♀️":"1f3c3-1f3fe-200d-2640-fe0f","🏃🏿♀️":"1f3c3-1f3ff-200d-2640-fe0f","🧖🏻♀️":"1f9d6-1f3fb-200d-2640-fe0f","🧖🏼♀️":"1f9d6-1f3fc-200d-2640-fe0f","🧖🏽♀️":"1f9d6-1f3fd-200d-2640-fe0f","🧖🏾♀️":"1f9d6-1f3fe-200d-2640-fe0f","🧖🏿♀️":"1f9d6-1f3ff-200d-2640-fe0f","🧖🏻♂️":"1f9d6-1f3fb-200d-2642-fe0f","🧖🏼♂️":"1f9d6-1f3fc-200d-2642-fe0f","🧖🏽♂️":"1f9d6-1f3fd-200d-2642-fe0f","🧖🏾♂️":"1f9d6-1f3fe-200d-2642-fe0f","🧖🏿♂️":"1f9d6-1f3ff-200d-2642-fe0f","🧗🏻♀️":"1f9d7-1f3fb-200d-2640-fe0f","🧗🏼♀️":"1f9d7-1f3fc-200d-2640-fe0f","🧗🏽♀️":"1f9d7-1f3fd-200d-2640-fe0f","🧗🏾♀️":"1f9d7-1f3fe-200d-2640-fe0f","🧗🏿♀️":"1f9d7-1f3ff-200d-2640-fe0f","🧗🏻♂️":"1f9d7-1f3fb-200d-2642-fe0f","🧗🏼♂️":"1f9d7-1f3fc-200d-2642-fe0f","🧗🏽♂️":"1f9d7-1f3fd-200d-2642-fe0f","🧗🏾♂️":"1f9d7-1f3fe-200d-2642-fe0f","🧗🏿♂️":"1f9d7-1f3ff-200d-2642-fe0f","🧘🏻♀️":"1f9d8-1f3fb-200d-2640-fe0f","🧘🏼♀️":"1f9d8-1f3fc-200d-2640-fe0f","🧘🏽♀️":"1f9d8-1f3fd-200d-2640-fe0f","🧘🏾♀️":"1f9d8-1f3fe-200d-2640-fe0f","🧘🏿♀️":"1f9d8-1f3ff-200d-2640-fe0f","🧘🏻♂️":"1f9d8-1f3fb-200d-2642-fe0f","🧘🏼♂️":"1f9d8-1f3fc-200d-2642-fe0f","🧘🏽♂️":"1f9d8-1f3fd-200d-2642-fe0f","🧘🏾♂️":"1f9d8-1f3fe-200d-2642-fe0f","🧘🏿♂️":"1f9d8-1f3ff-200d-2642-fe0f","🏌️♂️":"1f3cc-fe0f-200d-2642-fe0f","🏌🏻♂️":"1f3cc-1f3fb-200d-2642-fe0f","🏌🏼♂️":"1f3cc-1f3fc-200d-2642-fe0f","🏌🏽♂️":"1f3cc-1f3fd-200d-2642-fe0f","🏌🏾♂️":"1f3cc-1f3fe-200d-2642-fe0f","🏌🏿♂️":"1f3cc-1f3ff-200d-2642-fe0f","🏌️♀️":"1f3cc-fe0f-200d-2640-fe0f","🏌🏻♀️":"1f3cc-1f3fb-200d-2640-fe0f","🏌🏼♀️":"1f3cc-1f3fc-200d-2640-fe0f","🏌🏽♀️":"1f3cc-1f3fd-200d-2640-fe0f","🏌🏾♀️":"1f3cc-1f3fe-200d-2640-fe0f","🏌🏿♀️":"1f3cc-1f3ff-200d-2640-fe0f","🏄🏻♂️":"1f3c4-1f3fb-200d-2642-fe0f","🏄🏼♂️":"1f3c4-1f3fc-200d-2642-fe0f","🏄🏽♂️":"1f3c4-1f3fd-200d-2642-fe0f","🏄🏾♂️":"1f3c4-1f3fe-200d-2642-fe0f","🏄🏿♂️":"1f3c4-1f3ff-200d-2642-fe0f","🏄🏻♀️":"1f3c4-1f3fb-200d-2640-fe0f","🏄🏼♀️":"1f3c4-1f3fc-200d-2640-fe0f","🏄🏽♀️":"1f3c4-1f3fd-200d-2640-fe0f","🏄🏾♀️":"1f3c4-1f3fe-200d-2640-fe0f","🏄🏿♀️":"1f3c4-1f3ff-200d-2640-fe0f","🚣🏻♂️":"1f6a3-1f3fb-200d-2642-fe0f","🚣🏼♂️":"1f6a3-1f3fc-200d-2642-fe0f","🚣🏽♂️":"1f6a3-1f3fd-200d-2642-fe0f","🚣🏾♂️":"1f6a3-1f3fe-200d-2642-fe0f","🚣🏿♂️":"1f6a3-1f3ff-200d-2642-fe0f","🚣🏻♀️":"1f6a3-1f3fb-200d-2640-fe0f","🚣🏼♀️":"1f6a3-1f3fc-200d-2640-fe0f","🚣🏽♀️":"1f6a3-1f3fd-200d-2640-fe0f","🚣🏾♀️":"1f6a3-1f3fe-200d-2640-fe0f","🚣🏿♀️":"1f6a3-1f3ff-200d-2640-fe0f","🏊🏻♂️":"1f3ca-1f3fb-200d-2642-fe0f","🏊🏼♂️":"1f3ca-1f3fc-200d-2642-fe0f","🏊🏽♂️":"1f3ca-1f3fd-200d-2642-fe0f","🏊🏾♂️":"1f3ca-1f3fe-200d-2642-fe0f","🏊🏿♂️":"1f3ca-1f3ff-200d-2642-fe0f","🏊🏻♀️":"1f3ca-1f3fb-200d-2640-fe0f","🏊🏼♀️":"1f3ca-1f3fc-200d-2640-fe0f","🏊🏽♀️":"1f3ca-1f3fd-200d-2640-fe0f","🏊🏾♀️":"1f3ca-1f3fe-200d-2640-fe0f","🏊🏿♀️":"1f3ca-1f3ff-200d-2640-fe0f","⛹️♂️":"26f9-fe0f-200d-2642-fe0f","⛹🏻♂️":"26f9-1f3fb-200d-2642-fe0f","⛹🏼♂️":"26f9-1f3fc-200d-2642-fe0f","⛹🏽♂️":"26f9-1f3fd-200d-2642-fe0f","⛹🏾♂️":"26f9-1f3fe-200d-2642-fe0f","⛹🏿♂️":"26f9-1f3ff-200d-2642-fe0f","⛹️♀️":"26f9-fe0f-200d-2640-fe0f","⛹🏻♀️":"26f9-1f3fb-200d-2640-fe0f","⛹🏼♀️":"26f9-1f3fc-200d-2640-fe0f","⛹🏽♀️":"26f9-1f3fd-200d-2640-fe0f","⛹🏾♀️":"26f9-1f3fe-200d-2640-fe0f","⛹🏿♀️":"26f9-1f3ff-200d-2640-fe0f","🏋️♂️":"1f3cb-fe0f-200d-2642-fe0f","🏋🏻♂️":"1f3cb-1f3fb-200d-2642-fe0f","🏋🏼♂️":"1f3cb-1f3fc-200d-2642-fe0f","🏋🏽♂️":"1f3cb-1f3fd-200d-2642-fe0f","🏋🏾♂️":"1f3cb-1f3fe-200d-2642-fe0f","🏋🏿♂️":"1f3cb-1f3ff-200d-2642-fe0f","🏋️♀️":"1f3cb-fe0f-200d-2640-fe0f","🏋🏻♀️":"1f3cb-1f3fb-200d-2640-fe0f","🏋🏼♀️":"1f3cb-1f3fc-200d-2640-fe0f","🏋🏽♀️":"1f3cb-1f3fd-200d-2640-fe0f","🏋🏾♀️":"1f3cb-1f3fe-200d-2640-fe0f","🏋🏿♀️":"1f3cb-1f3ff-200d-2640-fe0f","🚴🏻♂️":"1f6b4-1f3fb-200d-2642-fe0f","🚴🏼♂️":"1f6b4-1f3fc-200d-2642-fe0f","🚴🏽♂️":"1f6b4-1f3fd-200d-2642-fe0f","🚴🏾♂️":"1f6b4-1f3fe-200d-2642-fe0f","🚴🏿♂️":"1f6b4-1f3ff-200d-2642-fe0f","🚴🏻♀️":"1f6b4-1f3fb-200d-2640-fe0f","🚴🏼♀️":"1f6b4-1f3fc-200d-2640-fe0f","🚴🏽♀️":"1f6b4-1f3fd-200d-2640-fe0f","🚴🏾♀️":"1f6b4-1f3fe-200d-2640-fe0f","🚴🏿♀️":"1f6b4-1f3ff-200d-2640-fe0f","🚵🏻♂️":"1f6b5-1f3fb-200d-2642-fe0f","🚵🏼♂️":"1f6b5-1f3fc-200d-2642-fe0f","🚵🏽♂️":"1f6b5-1f3fd-200d-2642-fe0f","🚵🏾♂️":"1f6b5-1f3fe-200d-2642-fe0f","🚵🏿♂️":"1f6b5-1f3ff-200d-2642-fe0f","🚵🏻♀️":"1f6b5-1f3fb-200d-2640-fe0f","🚵🏼♀️":"1f6b5-1f3fc-200d-2640-fe0f","🚵🏽♀️":"1f6b5-1f3fd-200d-2640-fe0f","🚵🏾♀️":"1f6b5-1f3fe-200d-2640-fe0f","🚵🏿♀️":"1f6b5-1f3ff-200d-2640-fe0f","🤸🏻♂️":"1f938-1f3fb-200d-2642-fe0f","🤸🏼♂️":"1f938-1f3fc-200d-2642-fe0f","🤸🏽♂️":"1f938-1f3fd-200d-2642-fe0f","🤸🏾♂️":"1f938-1f3fe-200d-2642-fe0f","🤸🏿♂️":"1f938-1f3ff-200d-2642-fe0f","🤸🏻♀️":"1f938-1f3fb-200d-2640-fe0f","🤸🏼♀️":"1f938-1f3fc-200d-2640-fe0f","🤸🏽♀️":"1f938-1f3fd-200d-2640-fe0f","🤸🏾♀️":"1f938-1f3fe-200d-2640-fe0f","🤸🏿♀️":"1f938-1f3ff-200d-2640-fe0f","🤽🏻♂️":"1f93d-1f3fb-200d-2642-fe0f","🤽🏼♂️":"1f93d-1f3fc-200d-2642-fe0f","🤽🏽♂️":"1f93d-1f3fd-200d-2642-fe0f","🤽🏾♂️":"1f93d-1f3fe-200d-2642-fe0f","🤽🏿♂️":"1f93d-1f3ff-200d-2642-fe0f","🤽🏻♀️":"1f93d-1f3fb-200d-2640-fe0f","🤽🏼♀️":"1f93d-1f3fc-200d-2640-fe0f","🤽🏽♀️":"1f93d-1f3fd-200d-2640-fe0f","🤽🏾♀️":"1f93d-1f3fe-200d-2640-fe0f","🤽🏿♀️":"1f93d-1f3ff-200d-2640-fe0f","🤾🏻♂️":"1f93e-1f3fb-200d-2642-fe0f","🤾🏼♂️":"1f93e-1f3fc-200d-2642-fe0f","🤾🏽♂️":"1f93e-1f3fd-200d-2642-fe0f","🤾🏾♂️":"1f93e-1f3fe-200d-2642-fe0f","🤾🏿♂️":"1f93e-1f3ff-200d-2642-fe0f","🤾🏻♀️":"1f93e-1f3fb-200d-2640-fe0f","🤾🏼♀️":"1f93e-1f3fc-200d-2640-fe0f","🤾🏽♀️":"1f93e-1f3fd-200d-2640-fe0f","🤾🏾♀️":"1f93e-1f3fe-200d-2640-fe0f","🤾🏿♀️":"1f93e-1f3ff-200d-2640-fe0f","🤹🏻♂️":"1f939-1f3fb-200d-2642-fe0f","🤹🏼♂️":"1f939-1f3fc-200d-2642-fe0f","🤹🏽♂️":"1f939-1f3fd-200d-2642-fe0f","🤹🏾♂️":"1f939-1f3fe-200d-2642-fe0f","🤹🏿♂️":"1f939-1f3ff-200d-2642-fe0f","🤹🏻♀️":"1f939-1f3fb-200d-2640-fe0f","🤹🏼♀️":"1f939-1f3fc-200d-2640-fe0f","🤹🏽♀️":"1f939-1f3fd-200d-2640-fe0f","🤹🏾♀️":"1f939-1f3fe-200d-2640-fe0f","🤹🏿♀️":"1f939-1f3ff-200d-2640-fe0f","👩❤👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤👩":"1f469-200d-2764-fe0f-200d-1f469","👨👩👦":"1f468-200d-1f469-200d-1f466","👨👩👧":"1f468-200d-1f469-200d-1f467","👨👨👦":"1f468-200d-1f468-200d-1f466","👨👨👧":"1f468-200d-1f468-200d-1f467","👩👩👦":"1f469-200d-1f469-200d-1f466","👩👩👧":"1f469-200d-1f469-200d-1f467","👨👦👦":"1f468-200d-1f466-200d-1f466","👨👧👦":"1f468-200d-1f467-200d-1f466","👨👧👧":"1f468-200d-1f467-200d-1f467","👩👦👦":"1f469-200d-1f466-200d-1f466","👩👧👦":"1f469-200d-1f467-200d-1f466","👩👧👧":"1f469-200d-1f467-200d-1f467","👁️🗨️":"1f441-200d-1f5e8","👩❤️👨":"1f469-200d-2764-fe0f-200d-1f468","👨❤️👨":"1f468-200d-2764-fe0f-200d-1f468","👩❤️👩":"1f469-200d-2764-fe0f-200d-1f469","👩❤💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469","👨👩👧👦":"1f468-200d-1f469-200d-1f467-200d-1f466","👨👩👦👦":"1f468-200d-1f469-200d-1f466-200d-1f466","👨👩👧👧":"1f468-200d-1f469-200d-1f467-200d-1f467","👨👨👧👦":"1f468-200d-1f468-200d-1f467-200d-1f466","👨👨👦👦":"1f468-200d-1f468-200d-1f466-200d-1f466","👨👨👧👧":"1f468-200d-1f468-200d-1f467-200d-1f467","👩👩👧👦":"1f469-200d-1f469-200d-1f467-200d-1f466","👩👩👦👦":"1f469-200d-1f469-200d-1f466-200d-1f466","👩👩👧👧":"1f469-200d-1f469-200d-1f467-200d-1f467","🏴":"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f","🏴":"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f","🏴":"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f","👩❤️💋👨":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468","👨❤️💋👨":"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468","👩❤️💋👩":"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469"}
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
@@ -0,0 +1,41 @@
+// The output of this module is designed to mimic emoji-mart's
+// "data" object, such that we can use it for a light version of emoji-mart's
+// emojiIndex.search functionality.
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
+
+const emojis = {};
+
+// decompress
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+ let [
+ filenameData, // eslint-disable-line no-unused-vars
+ searchData,
+ ] = shortCodesToEmojiData[shortCode];
+ let [
+ native,
+ short_names,
+ search,
+ unified,
+ ] = searchData;
+
+ if (!unified) {
+ // unified name can be derived from unicodeToUnifiedName
+ unified = unicodeToUnifiedName(native);
+ }
+
+ short_names = [shortCode].concat(short_names);
+ emojis[shortCode] = {
+ native,
+ search,
+ short_names,
+ unified,
+ };
+});
+
+module.exports = {
+ emojis,
+ skins,
+ categories,
+ short_names,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..5755bf1c4
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
@@ -0,0 +1,157 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
+
+import data from './emoji_mart_data_light';
+import { getData, getSanitizedData, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+
+for (let emoji in data.emojis) {
+ let emojiData = data.emojis[emoji];
+ let { short_names, emoticons } = emojiData;
+ let id = short_names[0];
+
+ if (emoticons) {
+ emoticons.forEach(emoticon => {
+ if (emoticonsList[emoticon]) {
+ return;
+ }
+
+ emoticonsList[emoticon] = id;
+ });
+ }
+
+ emojisList[id] = getSanitizedData(id);
+ originalPool[id] = emojiData;
+}
+
+function addCustomToPool(custom, pool) {
+ custom.forEach((emoji) => {
+ let emojiId = emoji.id || emoji.short_names[0];
+
+ if (emojiId && !pool[emojiId]) {
+ pool[emojiId] = getData(emoji);
+ emojisList[emojiId] = getSanitizedData(emoji);
+ }
+ });
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
+ addCustomToPool(custom, originalPool);
+
+ maxResults = maxResults || 75;
+ include = include || [];
+ exclude = exclude || [];
+
+ let results = null,
+ pool = originalPool;
+
+ if (value.length) {
+ if (value === '-' || value === '-1') {
+ return [emojisList['-1']];
+ }
+
+ let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
+ allResults = [];
+
+ if (values.length > 2) {
+ values = [values[0], values[1]];
+ }
+
+ if (include.length || exclude.length) {
+ pool = {};
+
+ data.categories.forEach(category => {
+ let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
+ let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
+ if (!isIncluded || isExcluded) {
+ return;
+ }
+
+ category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
+ });
+
+ if (custom.length) {
+ let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
+ let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
+ if (customIsIncluded && !customIsExcluded) {
+ addCustomToPool(custom, pool);
+ }
+ }
+ }
+
+ allResults = values.map((value) => {
+ let aPool = pool,
+ aIndex = index,
+ length = 0;
+
+ for (let charIndex = 0; charIndex < value.length; charIndex++) {
+ const char = value[charIndex];
+ length++;
+
+ aIndex[char] = aIndex[char] || {};
+ aIndex = aIndex[char];
+
+ if (!aIndex.results) {
+ let scores = {};
+
+ aIndex.results = [];
+ aIndex.pool = {};
+
+ for (let id in aPool) {
+ let emoji = aPool[id],
+ { search } = emoji,
+ sub = value.substr(0, length),
+ subIndex = search.indexOf(sub);
+
+ if (subIndex !== -1) {
+ let score = subIndex + 1;
+ if (sub === id) score = 0;
+
+ aIndex.results.push(emojisList[id]);
+ aIndex.pool[id] = emoji;
+
+ scores[id] = score;
+ }
+ }
+
+ aIndex.results.sort((a, b) => {
+ let aScore = scores[a.id],
+ bScore = scores[b.id];
+
+ return aScore - bScore;
+ });
+ }
+
+ aPool = aIndex.pool;
+ }
+
+ return aIndex.results;
+ }).filter(a => a);
+
+ if (allResults.length > 1) {
+ results = intersect.apply(null, allResults);
+ } else if (allResults.length) {
+ results = allResults[0];
+ } else {
+ results = [];
+ }
+ }
+
+ if (results) {
+ if (emojisToShowFilter) {
+ results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
+ }
+
+ if (results && results.length > maxResults) {
+ results = results.slice(0, maxResults);
+ }
+ }
+
+ return results;
+}
+
+export { search };
diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.js b/app/javascript/mastodon/features/emoji/emoji_picker.js
new file mode 100644
index 000000000..7e145381e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji';
+
+export {
+ Picker,
+ Emoji,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
@@ -0,0 +1,35 @@
+// A mapping of unicode strings to an object containing the filename
+// (i.e. the svg filename) and a shortCode intended to be shown
+// as a "title" attribute in an HTML element (aka tooltip).
+
+const [
+ shortCodesToEmojiData,
+ skins, // eslint-disable-line no-unused-vars
+ categories, // eslint-disable-line no-unused-vars
+ short_names, // eslint-disable-line no-unused-vars
+ emojisWithoutShortCodes,
+] = require('./emoji_compressed');
+const { unicodeToFilename } = require('./unicode_to_filename');
+
+// decompress
+const unicodeMapping = {};
+
+function processEmojiMapData(emojiMapData, shortCode) {
+ let [ native, filename ] = emojiMapData;
+ if (!filename) {
+ // filename name can be derived from unicodeToFilename
+ filename = unicodeToFilename(native);
+ }
+ unicodeMapping[native] = {
+ shortCode: shortCode,
+ filename: filename,
+ };
+}
+
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+ let [ filenameData ] = shortCodesToEmojiData[shortCode];
+ filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
+});
+emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
+
+module.exports = unicodeMapping;
diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js
new file mode 100644
index 000000000..dbf725c1f
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_utils.js
@@ -0,0 +1,258 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
+
+import data from './emoji_mart_data_light';
+
+const buildSearch = (data) => {
+ const search = [];
+
+ let addToSearch = (strings, split) => {
+ if (!strings) {
+ return;
+ }
+
+ (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
+ (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
+ s = s.toLowerCase();
+
+ if (search.indexOf(s) === -1) {
+ search.push(s);
+ }
+ });
+ });
+ };
+
+ addToSearch(data.short_names, true);
+ addToSearch(data.name, true);
+ addToSearch(data.keywords, false);
+ addToSearch(data.emoticons, false);
+
+ return search.join(',');
+};
+
+const _String = String;
+
+const stringFromCodePoint = _String.fromCodePoint || function () {
+ let MAX_SIZE = 0x4000;
+ let codeUnits = [];
+ let highSurrogate;
+ let lowSurrogate;
+ let index = -1;
+ let length = arguments.length;
+ if (!length) {
+ return '';
+ }
+ let result = '';
+ while (++index < length) {
+ let codePoint = Number(arguments[index]);
+ if (
+ !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
+ codePoint < 0 || // not a valid Unicode code point
+ codePoint > 0x10FFFF || // not a valid Unicode code point
+ Math.floor(codePoint) !== codePoint // not an integer
+ ) {
+ throw RangeError('Invalid code point: ' + codePoint);
+ }
+ if (codePoint <= 0xFFFF) { // BMP code point
+ codeUnits.push(codePoint);
+ } else { // Astral code point; split in surrogate halves
+ // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+ codePoint -= 0x10000;
+ highSurrogate = (codePoint >> 10) + 0xD800;
+ lowSurrogate = (codePoint % 0x400) + 0xDC00;
+ codeUnits.push(highSurrogate, lowSurrogate);
+ }
+ if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+ result += String.fromCharCode.apply(null, codeUnits);
+ codeUnits.length = 0;
+ }
+ }
+ return result;
+};
+
+
+const _JSON = JSON;
+
+const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
+const SKINS = [
+ '1F3FA', '1F3FB', '1F3FC',
+ '1F3FD', '1F3FE', '1F3FF',
+];
+
+function unifiedToNative(unified) {
+ let unicodes = unified.split('-'),
+ codePoints = unicodes.map((u) => `0x${u}`);
+
+ return stringFromCodePoint.apply(null, codePoints);
+}
+
+function sanitize(emoji) {
+ let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
+ id = emoji.id || short_names[0],
+ colons = `:${id}:`;
+
+ if (custom) {
+ return {
+ id,
+ name,
+ colons,
+ emoticons,
+ custom,
+ imageUrl,
+ };
+ }
+
+ if (skin_tone) {
+ colons += `:skin-tone-${skin_tone}:`;
+ }
+
+ return {
+ id,
+ name,
+ colons,
+ emoticons,
+ unified: unified.toLowerCase(),
+ skin: skin_tone || (skin_variations ? 1 : null),
+ native: unifiedToNative(unified),
+ };
+}
+
+function getSanitizedData() {
+ return sanitize(getData(...arguments));
+}
+
+function getData(emoji, skin, set) {
+ let emojiData = {};
+
+ if (typeof emoji === 'string') {
+ let matches = emoji.match(COLONS_REGEX);
+
+ if (matches) {
+ emoji = matches[1];
+
+ if (matches[2]) {
+ skin = parseInt(matches[2]);
+ }
+ }
+
+ if (data.short_names.hasOwnProperty(emoji)) {
+ emoji = data.short_names[emoji];
+ }
+
+ if (data.emojis.hasOwnProperty(emoji)) {
+ emojiData = data.emojis[emoji];
+ }
+ } else if (emoji.id) {
+ if (data.short_names.hasOwnProperty(emoji.id)) {
+ emoji.id = data.short_names[emoji.id];
+ }
+
+ if (data.emojis.hasOwnProperty(emoji.id)) {
+ emojiData = data.emojis[emoji.id];
+ skin = skin || emoji.skin;
+ }
+ }
+
+ if (!Object.keys(emojiData).length) {
+ emojiData = emoji;
+ emojiData.custom = true;
+
+ if (!emojiData.search) {
+ emojiData.search = buildSearch(emoji);
+ }
+ }
+
+ emojiData.emoticons = emojiData.emoticons || [];
+ emojiData.variations = emojiData.variations || [];
+
+ if (emojiData.skin_variations && skin > 1 && set) {
+ emojiData = JSON.parse(_JSON.stringify(emojiData));
+
+ let skinKey = SKINS[skin - 1],
+ variationData = emojiData.skin_variations[skinKey];
+
+ if (!variationData.variations && emojiData.variations) {
+ delete emojiData.variations;
+ }
+
+ if (variationData[`has_img_${set}`]) {
+ emojiData.skin_tone = skin;
+
+ for (let k in variationData) {
+ let v = variationData[k];
+ emojiData[k] = v;
+ }
+ }
+ }
+
+ if (emojiData.variations && emojiData.variations.length) {
+ emojiData = JSON.parse(_JSON.stringify(emojiData));
+ emojiData.unified = emojiData.variations.shift();
+ }
+
+ return emojiData;
+}
+
+function uniq(arr) {
+ return arr.reduce((acc, item) => {
+ if (acc.indexOf(item) === -1) {
+ acc.push(item);
+ }
+ return acc;
+ }, []);
+}
+
+function intersect(a, b) {
+ const uniqA = uniq(a);
+ const uniqB = uniq(b);
+
+ return uniqA.filter(item => uniqB.indexOf(item) >= 0);
+}
+
+function deepMerge(a, b) {
+ let o = {};
+
+ for (let key in a) {
+ let originalValue = a[key],
+ value = originalValue;
+
+ if (b.hasOwnProperty(key)) {
+ value = b[key];
+ }
+
+ if (typeof value === 'object') {
+ value = deepMerge(originalValue, value);
+ }
+
+ o[key] = value;
+ }
+
+ return o;
+}
+
+// https://github.com/sonicdoe/measure-scrollbar
+function measureScrollbar() {
+ const div = document.createElement('div');
+
+ div.style.width = '100px';
+ div.style.height = '100px';
+ div.style.overflow = 'scroll';
+ div.style.position = 'absolute';
+ div.style.top = '-9999px';
+
+ document.body.appendChild(div);
+ const scrollbarWidth = div.offsetWidth - div.clientWidth;
+ document.body.removeChild(div);
+
+ return scrollbarWidth;
+}
+
+export {
+ getData,
+ getSanitizedData,
+ uniq,
+ intersect,
+ deepMerge,
+ unifiedToNative,
+ measureScrollbar,
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
@@ -0,0 +1,26 @@
+// taken from:
+// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
+exports.unicodeToFilename = (str) => {
+ let result = '';
+ let charCode = 0;
+ let p = 0;
+ let i = 0;
+ while (i < str.length) {
+ charCode = str.charCodeAt(i++);
+ if (p) {
+ if (result.length > 0) {
+ result += '-';
+ }
+ result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
+ p = 0;
+ } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
+ p = charCode;
+ } else {
+ if (result.length > 0) {
+ result += '-';
+ }
+ result += charCode.toString(16);
+ }
+ }
+ return result;
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..808ac197e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
@@ -0,0 +1,17 @@
+function padLeft(str, num) {
+ while (str.length < num) {
+ str = '0' + str;
+ }
+ return str;
+}
+
+exports.unicodeToUnifiedName = (str) => {
+ let output = '';
+ for (let i = 0; i < str.length; i += 2) {
+ if (i > 0) {
+ output += '-';
+ }
+ output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
+ }
+ return output;
+};
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
new file mode 100644
index 000000000..1e1f5873c
--- /dev/null
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import StatusList from '../../components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Favourites extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFavouritedStatuses());
+ }
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('FAVOURITES', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleScrollToBottom = () => {
+ this.props.dispatch(expandFavouritedStatuses());
+ }
+
+ render () {
+ const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
new file mode 100644
index 000000000..6f113beb4
--- /dev/null
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavourites } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Favourites extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFavourites(this.props.params.statusId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this.props.dispatch(fetchFavourites(nextProps.params.statusId));
+ }
+ }
+
+ render () {
+ const { accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..4fc5638d9
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from '../../../components/permalink';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+@injectIntl
+export default class AccountAuthorize extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onAuthorize: PropTypes.func.isRequired,
+ onReject: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { intl, account, onAuthorize, onReject } = this.props;
+ const content = { __html: account.get('note_emojified') };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..8db471f73
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+ onAuthorize () {
+ dispatch(authorizeFollowRequest(id));
+ },
+
+ onReject () {
+ dispatch(rejectFollowRequest(id));
+ },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
new file mode 100644
index 000000000..eae821f92
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class FollowRequests extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchFollowRequests());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandFollowRequests());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
new file mode 100644
index 000000000..f64ed7948
--- /dev/null
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+ fetchAccount,
+ fetchFollowers,
+ expandFollowers,
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Followers extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowers(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+ this.props.dispatch(expandFollowers(this.props.params.accountId));
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.dispatch(expandFollowers(this.props.params.accountId));
+ }
+
+ render () {
+ const { accountIds, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {accountIds.map(id =>
)}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
new file mode 100644
index 000000000..a0c0fac05
--- /dev/null
+++ b/app/javascript/mastodon/features/following/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+ fetchAccount,
+ fetchFollowing,
+ expandFollowing,
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Following extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowing(this.props.params.accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ this.props.dispatch(fetchAccount(nextProps.params.accountId));
+ this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+ }
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+ this.props.dispatch(expandFollowing(this.props.params.accountId));
+ }
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.dispatch(expandFollowing(this.props.params.accountId));
+ }
+
+ render () {
+ const { accountIds, hasMore } = this.props;
+
+ let loadMore = null;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasMore) {
+ loadMore = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {accountIds.map(id =>
)}
+ {loadMore}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js
new file mode 100644
index 000000000..0290be47f
--- /dev/null
+++ b/app/javascript/mastodon/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+
+
+
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
new file mode 100644
index 000000000..4b4ae6947
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -0,0 +1,112 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import ColumnLink from '../ui/components/column_link';
+import ColumnSubheading from '../ui/components/column_subheading';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from '../../initial_state';
+
+const messages = defineMessages({
+ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+ public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+ navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
+ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
+ community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+ sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+ favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
+ pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
+});
+
+const mapStateToProps = state => ({
+ myAccount: state.getIn(['accounts', me]),
+ columns: state.getIn(['settings', 'columns']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class GettingStarted extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ myAccount: ImmutablePropTypes.map.isRequired,
+ columns: ImmutablePropTypes.list,
+ multiColumn: PropTypes.bool,
+ };
+
+ render () {
+ const { intl, myAccount, columns, multiColumn } = this.props;
+
+ let navItems = [];
+
+ if (multiColumn) {
+ if (!columns.find(item => item.get('id') === 'HOME')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
+ navItems.push( );
+ }
+
+ if (!columns.find(item => item.get('id') === 'PUBLIC')) {
+ navItems.push( );
+ }
+ }
+
+ navItems = navItems.concat([
+ ,
+ ,
+ ]);
+
+ if (myAccount.get('locked')) {
+ navItems.push( );
+ }
+
+ navItems = navItems.concat([
+ ,
+ ,
+ ]);
+
+ return (
+
+
+
+ {navItems}
+
+
+
+
+
+
+
+
+
+ • •
+
+
+ tootsuite/mastodon }}
+ />
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..5fe21ce90
--- /dev/null
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import {
+ refreshHashtagTimeline,
+ expandHashtagTimeline,
+} from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { FormattedMessage } from 'react-intl';
+import { connectHashtagStream } from '../../actions/streaming';
+
+const mapStateToProps = (state, props) => ({
+ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+export default class HashtagTimeline extends React.PureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ hasUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ _subscribe (dispatch, id) {
+ this.disconnect = dispatch(connectHashtagStream(id));
+ }
+
+ _unsubscribe () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+ const { id } = this.props.params;
+
+ dispatch(refreshHashtagTimeline(id));
+ this._subscribe(dispatch, id);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.id !== this.props.params.id) {
+ this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+ this._unsubscribe();
+ this._subscribe(this.props.dispatch, nextProps.params.id);
+ }
+ }
+
+ componentWillUnmount () {
+ this._unsubscribe();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+ }
+
+ render () {
+ const { hasUnread, columnId, multiColumn } = this.props;
+ const { id } = this.props.params;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..43172bd25
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from '../../notifications/components/setting_toggle';
+import SettingText from '../../../components/setting_text';
+
+const messages = defineMessages({
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+ settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { settings, onChange, intl } = this.props;
+
+ return (
+
+
+
+
+ } />
+
+
+
+ } />
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..fd8a39298
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'home']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['home', ...key], checked));
+ },
+
+ onSave () {
+ dispatch(saveSettings());
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
new file mode 100644
index 000000000..a4bc60fac
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from '../../actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+ title: { id: 'column.home', defaultMessage: 'Home' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class HomeTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasUnread: PropTypes.bool,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('HOME', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHomeTimeline());
+ }
+
+ render () {
+ const { intl, hasUnread, columnId, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }} />}
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
new file mode 100644
index 000000000..bb351ece2
--- /dev/null
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchMutes, expandMutes } from '../../actions/mutes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+ accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Mutes extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchMutes());
+ }
+
+ handleScroll = (e) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+ if (scrollTop === scrollHeight - clientHeight) {
+ this.props.dispatch(expandMutes());
+ }
+ }
+
+ render () {
+ const { intl, accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {accountIds.map(id =>
+
+ )}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..22a10753f
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class ClearColumnButton extends React.Component {
+
+ static propTypes = {
+ onClick: PropTypes.func.isRequired,
+ };
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..88a29d4d3
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ClearColumnButton from './clear_column_button';
+import SettingToggle from './setting_toggle';
+
+export default class ColumnSettings extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ pushSettings: ImmutablePropTypes.map.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ };
+
+ onPushChange = (key, checked) => {
+ this.props.onChange(['push', ...key], checked);
+ }
+
+ render () {
+ const { settings, pushSettings, onChange, onClear } = this.props;
+
+ const alertStr = ;
+ const showStr = ;
+ const soundStr = ;
+
+ const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+ const pushStr = showPushSettings && ;
+ const pushMeta = showPushSettings && ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
new file mode 100644
index 000000000..9d170cad5
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContainer from '../../../containers/status_container';
+import AccountContainer from '../../../containers/account_container';
+import { FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+
+export default class Notification extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ notification: ImmutablePropTypes.map.isRequired,
+ hidden: PropTypes.bool,
+ onMoveUp: PropTypes.func.isRequired,
+ onMoveDown: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ };
+
+ handleMoveUp = () => {
+ const { notification, onMoveUp } = this.props;
+ onMoveUp(notification.get('id'));
+ }
+
+ handleMoveDown = () => {
+ const { notification, onMoveDown } = this.props;
+ onMoveDown(notification.get('id'));
+ }
+
+ handleOpen = () => {
+ const { notification } = this.props;
+
+ if (notification.get('status')) {
+ this.context.router.history.push(`/statuses/${notification.get('status')}`);
+ } else {
+ this.handleOpenProfile();
+ }
+ }
+
+ handleOpenProfile = () => {
+ const { notification } = this.props;
+ this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+ }
+
+ handleMention = e => {
+ e.preventDefault();
+
+ const { notification, onMention } = this.props;
+ onMention(notification.get('account'), this.context.router.history);
+ }
+
+ getHandlers () {
+ return {
+ moveUp: this.handleMoveUp,
+ moveDown: this.handleMoveDown,
+ open: this.handleOpen,
+ openProfile: this.handleOpenProfile,
+ mention: this.handleMention,
+ reply: this.handleMention,
+ };
+ }
+
+ renderFollow (account, link) {
+ return (
+
+
+
+ );
+ }
+
+ renderMention (notification) {
+ return (
+
+ );
+ }
+
+ renderFavourite (notification, link) {
+ return (
+
+
+
+ );
+ }
+
+ renderReblog (notification, link) {
+ return (
+
+
+
+ );
+ }
+
+ render () {
+ const { notification } = this.props;
+ const account = notification.get('account');
+ const displayNameHtml = { __html: account.get('display_name_html') };
+ const link = ;
+
+ switch(notification.get('type')) {
+ case 'follow':
+ return this.renderFollow(account, link);
+ case 'mention':
+ return this.renderMention(notification);
+ case 'favourite':
+ return this.renderFavourite(notification, link);
+ case 'reblog':
+ return this.renderReblog(notification, link);
+ }
+
+ return null;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..281359d2a
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class SettingToggle extends React.PureComponent {
+
+ static propTypes = {
+ prefix: PropTypes.string,
+ settings: ImmutablePropTypes.map.isRequired,
+ settingKey: PropTypes.array.isRequired,
+ label: PropTypes.node.isRequired,
+ meta: PropTypes.node,
+ onChange: PropTypes.func.isRequired,
+ }
+
+ onChange = ({ target }) => {
+ this.props.onChange(this.props.settingKey, target.checked);
+ }
+
+ render () {
+ const { prefix, settings, settingKey, label, meta } = this.props;
+ const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
+
+ return (
+
+
+ {label}
+ {meta && {meta} }
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..d4ead7881
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,44 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+import { clearNotifications } from '../../../actions/notifications';
+import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
+import { openModal } from '../../../actions/modal';
+
+const messages = defineMessages({
+ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
+ clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+});
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'notifications']),
+ pushSettings: state.get('push_notifications'),
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onChange (key, checked) {
+ if (key[0] === 'push') {
+ dispatch(changePushNotifications(key.slice(1), checked));
+ } else {
+ dispatch(changeSetting(['notifications', ...key], checked));
+ }
+ },
+
+ onSave () {
+ dispatch(saveSettings());
+ dispatch(savePushNotificationSettings());
+ },
+
+ onClear () {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.clearMessage),
+ confirm: intl.formatMessage(messages.clearConfirm),
+ onConfirm: () => dispatch(clearNotifications()),
+ }));
+ },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..921aa460f
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { makeGetNotification } from '../../../selectors';
+import Notification from '../components/notification';
+import { mentionCompose } from '../../../actions/compose';
+
+const makeMapStateToProps = () => {
+ const getNotification = makeGetNotification();
+
+ const mapStateToProps = (state, props) => ({
+ notification: getNotification(state, props.notification, props.accountId),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+ onMention: (account, router) => {
+ dispatch(mentionCompose(account, router));
+ },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
new file mode 100644
index 000000000..35b430bfb
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -0,0 +1,168 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from '../../components/scrollable_list';
+
+const messages = defineMessages({
+ title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+});
+
+const getNotifications = createSelector([
+ state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+ state => state.getIn(['notifications', 'items']),
+], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+
+const mapStateToProps = state => ({
+ notifications: getNotifications(state),
+ isLoading: state.getIn(['notifications', 'isLoading'], true),
+ isUnread: state.getIn(['notifications', 'unread']) > 0,
+ hasMore: !!state.getIn(['notifications', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Notifications extends React.PureComponent {
+
+ static propTypes = {
+ columnId: PropTypes.string,
+ notifications: ImmutablePropTypes.list.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ shouldUpdateScroll: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ isLoading: PropTypes.bool,
+ isUnread: PropTypes.bool,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ handleScrollToBottom = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
+ this.props.dispatch(expandNotifications());
+ }, 300, { leading: true });
+
+ handleScrollToTop = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(true));
+ }, 100);
+
+ handleScroll = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
+ }, 100);
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('NOTIFICATIONS', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setColumnRef = c => {
+ this.column = c;
+ }
+
+ handleMoveUp = id => {
+ const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+ this._selectChild(elementIndex);
+ }
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+ this._selectChild(elementIndex);
+ }
+
+ _selectChild (index) {
+ const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ render () {
+ const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
+ const pinned = !!columnId;
+ const emptyMessage = ;
+
+ let scrollableContent = null;
+
+ if (isLoading && this.scrollableContent) {
+ scrollableContent = this.scrollableContent;
+ } else if (notifications.size > 0 || hasMore) {
+ scrollableContent = notifications.map((item) => (
+
+ ));
+ } else {
+ scrollableContent = null;
+ }
+
+ this.scrollableContent = scrollableContent;
+
+ const scrollContainer = (
+
+ {scrollableContent}
+
+ );
+
+ return (
+
+
+
+
+
+ {scrollContainer}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js
new file mode 100644
index 000000000..b4a6c1e52
--- /dev/null
+++ b/app/javascript/mastodon/features/pinned_statuses/index.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from '../../actions/pin_statuses';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import StatusList from '../../components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'pins', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PinnedStatuses extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ hasMore: PropTypes.bool.isRequired,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchPinnedStatuses());
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ render () {
+ const { intl, statusIds, hasMore } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..203e1da92
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../../community_timeline/components/column_settings';
+import { changeSetting } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+ settings: state.getIn(['settings', 'public']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (key, checked) {
+ dispatch(changeSetting(['public', ...key], checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
new file mode 100644
index 000000000..193489c63
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import {
+ refreshPublicTimeline,
+ expandPublicTimeline,
+} from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from '../../actions/streaming';
+
+const messages = defineMessages({
+ title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = state => ({
+ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasUnread: PropTypes.bool,
+ };
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('PUBLIC', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshPublicTimeline());
+ this.disconnect = dispatch(connectPublicStream());
+ }
+
+ componentWillUnmount () {
+ if (this.disconnect) {
+ this.disconnect();
+ this.disconnect = null;
+ }
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandPublicTimeline());
+ }
+
+ render () {
+ const { intl, columnId, hasUnread, multiColumn } = this.props;
+ const pinned = !!columnId;
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
new file mode 100644
index 000000000..579d6aaa0
--- /dev/null
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchReblogs } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Reblogs extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchReblogs(this.props.params.statusId));
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this.props.dispatch(fetchReblogs(nextProps.params.statusId));
+ }
+ }
+
+ render () {
+ const { accountIds } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {accountIds.map(id =>
)}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
new file mode 100644
index 000000000..cc9232201
--- /dev/null
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class StatusCheckBox extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ checked: PropTypes.bool,
+ onToggle: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ };
+
+ render () {
+ const { status, checked, onToggle, disabled } = this.props;
+ const content = { __html: status.get('contentHtml') };
+
+ if (status.get('reblog')) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..48cd0319b
--- /dev/null
+++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { toggleStatusReport } from '../../../actions/reports';
+import { Set as ImmutableSet } from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+ status: state.getIn(['statuses', id]),
+ checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+ onToggle (e) {
+ dispatch(toggleStatusReport(id, e.target.checked));
+ },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
new file mode 100644
index 000000000..0d764575f
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from '../../compose/containers/compose_form_container';
+import NotificationsContainer from '../../ui/containers/notifications_container';
+import LoadingBarContainer from '../../ui/containers/loading_bar_container';
+import ModalContainer from '../../ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+ render () {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..f15fbb2f4
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../../ui/containers/status_list_container';
+import {
+ refreshHashtagTimeline,
+ expandHashtagTimeline,
+} from '../../../actions/timelines';
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ hashtag: PropTypes.string.isRequired,
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ componentDidMount () {
+ const { dispatch, hashtag } = this.props;
+
+ dispatch(refreshHashtagTimeline(hashtag));
+
+ this.polling = setInterval(() => {
+ dispatch(refreshHashtagTimeline(hashtag));
+ }, 10000);
+ }
+
+ componentWillUnmount () {
+ if (typeof this.polling !== 'undefined') {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+ }
+
+ render () {
+ const { hashtag } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js
new file mode 100644
index 000000000..de4b5320a
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../../ui/containers/status_list_container';
+import {
+ refreshPublicTimeline,
+ expandPublicTimeline,
+} from '../../../actions/timelines';
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props;
+
+ dispatch(refreshPublicTimeline());
+
+ this.polling = setInterval(() => {
+ dispatch(refreshPublicTimeline());
+ }, 3000);
+ }
+
+ componentWillUnmount () {
+ if (typeof this.polling !== 'undefined') {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ }
+
+ handleLoadMore = () => {
+ this.props.dispatch(expandPublicTimeline());
+ }
+
+ render () {
+ const { intl } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
new file mode 100644
index 000000000..7b65420d0
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me } from '../../../initial_state';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onFavourite: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onMention: PropTypes.func.isRequired,
+ onReport: PropTypes.func,
+ onPin: PropTypes.func,
+ onEmbed: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleShare = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ if (me === status.getIn(['account', 'id'])) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+
+ );
+
+ let reblogIcon = 'retweet';
+ if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+ else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+
+ let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+
+ return (
+
+
+
+
+ {shareButton}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
new file mode 100644
index 000000000..bb83374b9
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import punycode from 'punycode';
+import classnames from 'classnames';
+
+const IDNA_PREFIX = 'xn--';
+
+const decodeIDNA = domain => {
+ return domain
+ .split('.')
+ .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+ .join('.');
+};
+
+const getHostname = url => {
+ const parser = document.createElement('a');
+ parser.href = url;
+ return parser.hostname;
+};
+
+export default class Card extends React.PureComponent {
+
+ static propTypes = {
+ card: ImmutablePropTypes.map,
+ maxDescription: PropTypes.number,
+ };
+
+ static defaultProps = {
+ maxDescription: 50,
+ };
+
+ state = {
+ width: 0,
+ };
+
+ renderLink () {
+ const { card, maxDescription } = this.props;
+
+ let image = '';
+ let provider = card.get('provider_name');
+
+ if (card.get('image')) {
+ image = (
+
+
+
+ );
+ }
+
+ if (provider.length < 1) {
+ provider = decodeIDNA(getHostname(card.get('url')));
+ }
+
+ const className = classnames('status-card', {
+ 'horizontal': card.get('width') > card.get('height'),
+ });
+
+ return (
+
+ {image}
+
+
+
{card.get('title')}
+
{(card.get('description') || '').substring(0, maxDescription)}
+
{provider}
+
+
+ );
+ }
+
+ renderPhoto () {
+ const { card } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ setRef = c => {
+ if (c) {
+ this.setState({ width: c.offsetWidth });
+ }
+ }
+
+ renderVideo () {
+ const { card } = this.props;
+ const content = { __html: card.get('html') };
+ const { width } = this.state;
+ const ratio = card.get('width') / card.get('height');
+ const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
+
+ return (
+
+ );
+ }
+
+ render () {
+ const { card } = this.props;
+
+ if (card === null) {
+ return null;
+ }
+
+ switch(card.get('type')) {
+ case 'link':
+ return this.renderLink();
+ case 'photo':
+ return this.renderPhoto();
+ case 'video':
+ return this.renderVideo();
+ case 'rich':
+ default:
+ return null;
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
new file mode 100644
index 000000000..81f71749b
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+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 CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from '../../video';
+
+export default class DetailedStatus extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ onOpenVideo: PropTypes.func.isRequired,
+ };
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ e.stopPropagation();
+ }
+
+ handleOpenVideo = startTime => {
+ this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+ }
+
+ render () {
+ const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+
+ let media = '';
+ let applicationLink = '';
+ let reblogLink = '';
+ let reblogIcon = 'retweet';
+
+ if (status.get('media_attachments').size > 0) {
+ if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+ media = ;
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const video = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ );
+ } else {
+ media = (
+
+ );
+ }
+ } else if (status.get('spoiler_text').length === 0) {
+ media = ;
+ }
+
+ if (status.get('application')) {
+ applicationLink = · {status.getIn(['application', 'name'])} ;
+ }
+
+ if (status.get('visibility') === 'direct') {
+ reblogIcon = 'envelope';
+ } else if (status.get('visibility') === 'private') {
+ reblogIcon = 'lock';
+ }
+
+ if (status.get('visibility') === 'private') {
+ reblogLink = ;
+ } else {
+ reblogLink = (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {media}
+
+
+
+
+ {applicationLink} · {reblogLink} ·
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
new file mode 100644
index 000000000..a97404de1
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/card_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+ card: state.getIn(['cards', statusId], null),
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
new file mode 100644
index 000000000..cc28ff5fc
--- /dev/null
+++ b/app/javascript/mastodon/features/status/index.js
@@ -0,0 +1,338 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '../../actions/statuses';
+import MissingIndicator from '../../components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+ favourite,
+ unfavourite,
+ reblog,
+ unreblog,
+ pin,
+ unpin,
+} from '../../actions/interactions';
+import {
+ replyCompose,
+ mentionCompose,
+} from '../../actions/compose';
+import { deleteStatus } from '../../actions/statuses';
+import { initReport } from '../../actions/reports';
+import { makeGetStatus } from '../../selectors';
+import { ScrollContainer } from 'react-router-scroll-4';
+import ColumnBackButton from '../../components/column_back_button';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, deleteModal } from '../../initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props.params.statusId),
+ ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+ descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
+ });
+
+ return mapStateToProps;
+};
+
+@injectIntl
+@connect(makeMapStateToProps)
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ status: ImmutablePropTypes.map,
+ ancestorsIds: ImmutablePropTypes.list,
+ descendantsIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ fullscreen: false,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchStatus(this.props.params.statusId));
+ }
+
+ componentDidMount () {
+ attachFullscreenListener(this.onFullScreenChange);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this._scrolledIntoView = false;
+ this.props.dispatch(fetchStatus(nextProps.params.statusId));
+ }
+ }
+
+ handleFavouriteClick = (status) => {
+ if (status.get('favourited')) {
+ this.props.dispatch(unfavourite(status));
+ } else {
+ this.props.dispatch(favourite(status));
+ }
+ }
+
+ handlePin = (status) => {
+ if (status.get('pinned')) {
+ this.props.dispatch(unpin(status));
+ } else {
+ this.props.dispatch(pin(status));
+ }
+ }
+
+ handleReplyClick = (status) => {
+ this.props.dispatch(replyCompose(status, this.context.router.history));
+ }
+
+ handleModalReblog = (status) => {
+ this.props.dispatch(reblog(status));
+ }
+
+ handleReblogClick = (status, e) => {
+ if (status.get('reblogged')) {
+ this.props.dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.handleModalReblog(status);
+ } else {
+ this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+ }
+ }
+ }
+
+ handleDeleteClick = (status) => {
+ const { dispatch, intl } = this.props;
+
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ }
+
+ handleMentionClick = (account, router) => {
+ this.props.dispatch(mentionCompose(account, router));
+ }
+
+ handleOpenMedia = (media, index) => {
+ this.props.dispatch(openModal('MEDIA', { media, index }));
+ }
+
+ handleOpenVideo = (media, time) => {
+ this.props.dispatch(openModal('VIDEO', { media, time }));
+ }
+
+ handleReport = (status) => {
+ this.props.dispatch(initReport(status.get('account'), status));
+ }
+
+ handleEmbed = (status) => {
+ this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+ }
+
+ handleHotkeyMoveUp = () => {
+ this.handleMoveUp(this.props.status.get('id'));
+ }
+
+ handleHotkeyMoveDown = () => {
+ this.handleMoveDown(this.props.status.get('id'));
+ }
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.handleReplyClick(this.props.status);
+ }
+
+ handleHotkeyFavourite = () => {
+ this.handleFavouriteClick(this.props.status);
+ }
+
+ handleHotkeyBoost = () => {
+ this.handleReblogClick(this.props.status);
+ }
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.handleMentionClick(this.props.status);
+ }
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ handleMoveUp = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size - 1);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index);
+ } else {
+ this._selectChild(index - 1);
+ }
+ }
+ }
+
+ handleMoveDown = id => {
+ const { status, ancestorsIds, descendantsIds } = this.props;
+
+ if (id === status.get('id')) {
+ this._selectChild(ancestorsIds.size + 1);
+ } else {
+ let index = ancestorsIds.indexOf(id);
+
+ if (index === -1) {
+ index = descendantsIds.indexOf(id);
+ this._selectChild(ancestorsIds.size + index + 2);
+ } else {
+ this._selectChild(index + 1);
+ }
+ }
+ }
+
+ _selectChild (index) {
+ const element = this.node.querySelectorAll('.focusable')[index];
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ renderChildren (list) {
+ return list.map(id => (
+
+ ));
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidUpdate () {
+ if (this._scrolledIntoView) {
+ return;
+ }
+
+ const { status, ancestorsIds } = this.props;
+
+ if (status && ancestorsIds && ancestorsIds.size > 0) {
+ const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+ element.scrollIntoView(true);
+ this._scrolledIntoView = true;
+ }
+ }
+
+ componentWillUnmount () {
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ render () {
+ let ancestors, descendants;
+ const { status, ancestorsIds, descendantsIds } = this.props;
+ const { fullscreen } = this.state;
+
+ if (status === null) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (ancestorsIds && ancestorsIds.size > 0) {
+ ancestors = {this.renderChildren(ancestorsIds)}
;
+ }
+
+ if (descendantsIds && descendantsIds.size > 0) {
+ descendants = {this.renderChildren(descendantsIds)}
;
+ }
+
+ const handlers = {
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ openProfile: this.handleHotkeyOpenProfile,
+ };
+
+ return (
+
+
+
+
+
+ {ancestors}
+
+
+
+
+
+ {descendants}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
new file mode 100644
index 000000000..1e5e1d8dc
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Column from '../column';
+import ColumnHeader from '../column_header';
+
+describe(' ', () => {
+ describe(' click handler', () => {
+ const originalRaf = global.requestAnimationFrame;
+
+ beforeEach(() => {
+ global.requestAnimationFrame = jest.fn();
+ });
+
+ afterAll(() => {
+ global.requestAnimationFrame = originalRaf;
+ });
+
+ it('runs the scroll animation if the column contains scrollable content', () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper.find(ColumnHeader).simulate('click');
+ expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
+ });
+
+ it('does not try to scroll if there is no scrollable content', () => {
+ const wrapper = mount( );
+ wrapper.find(ColumnHeader).simulate('click');
+ expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
+ });
+ });
+});
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
new file mode 100644
index 000000000..79a5a20ef
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import IconButton from '../../../components/icon_button';
+import classNames from 'classnames';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ actions: PropTypes.array,
+ onClick: PropTypes.func,
+ };
+
+ renderAction = (action, i) => {
+ if (action === null) {
+ return ;
+ }
+
+ const { icon = null, text, meta = null, active = false, href = '#' } = action;
+
+ return (
+
+
+ {icon && }
+
+
+
+ );
+ }
+
+ render () {
+ const status = this.props.status && (
+
+ );
+
+ return (
+
+ {status}
+
+
+ {this.props.actions.map(this.renderAction)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..0e9592c97
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+@injectIntl
+export default class BoostModal extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReblog: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleReblog = () => {
+ this.props.onReblog(this.props.status);
+ this.props.onClose();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.props.onClose();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js
new file mode 100644
index 000000000..fc88e0c70
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.Component {
+
+ static propTypes = {
+ fetchComponent: PropTypes.func.isRequired,
+ loading: PropTypes.func,
+ error: PropTypes.func,
+ children: PropTypes.func.isRequired,
+ renderDelay: PropTypes.number,
+ onFetch: PropTypes.func,
+ onFetchSuccess: PropTypes.func,
+ onFetchFail: PropTypes.func,
+ }
+
+ static defaultProps = {
+ loading: emptyComponent,
+ error: emptyComponent,
+ renderDelay: 0,
+ onFetch: noop,
+ onFetchSuccess: noop,
+ onFetchFail: noop,
+ }
+
+ static cache = {}
+
+ state = {
+ mod: undefined,
+ forceRender: false,
+ }
+
+ componentWillMount() {
+ this.load(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.fetchComponent !== this.props.fetchComponent) {
+ this.load(nextProps);
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+
+ load = (props) => {
+ const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+
+ onFetch();
+
+ if (Bundle.cache[fetchComponent.name]) {
+ const mod = Bundle.cache[fetchComponent.name];
+
+ this.setState({ mod: mod.default });
+ onFetchSuccess();
+ return Promise.resolve();
+ }
+
+ this.setState({ mod: undefined });
+
+ if (renderDelay !== 0) {
+ this.timestamp = new Date();
+ this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+ }
+
+ return fetchComponent()
+ .then((mod) => {
+ Bundle.cache[fetchComponent.name] = mod;
+ this.setState({ mod: mod.default });
+ onFetchSuccess();
+ })
+ .catch((error) => {
+ this.setState({ mod: null });
+ onFetchFail(error);
+ });
+ }
+
+ render() {
+ const { loading: Loading, error: Error, children, renderDelay } = this.props;
+ const { mod, forceRender } = this.state;
+ const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+ if (mod === undefined) {
+ return (elapsed >= renderDelay || forceRender) ? : null;
+ }
+
+ if (mod === null) {
+ return ;
+ }
+
+ return children(mod);
+ }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.js b/app/javascript/mastodon/features/ui/components/bundle_column_error.js
new file mode 100644
index 000000000..cd124746a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle_column_error.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import Column from './column';
+import ColumnHeader from './column_header';
+import ColumnBackButtonSlim from '../../../components/column_back_button_slim';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+ title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
+ body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
+ retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
+});
+
+class BundleColumnError extends React.Component {
+
+ static propTypes = {
+ onRetry: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ handleRetry = () => {
+ this.props.onRetry();
+ }
+
+ render () {
+ const { intl: { formatMessage } } = this.props;
+
+ return (
+
+
+
+
+
+ {formatMessage(messages.body)}
+
+
+ );
+ }
+
+}
+
+export default injectIntl(BundleColumnError);
diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
new file mode 100644
index 000000000..928bfe1f7
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+ error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
+ retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
+ close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
+});
+
+class BundleModalError extends React.Component {
+
+ static propTypes = {
+ onRetry: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ handleRetry = () => {
+ this.props.onRetry();
+ }
+
+ render () {
+ const { onClose, intl: { formatMessage } } = this.props;
+
+ // Keep the markup in sync with
+ // (make sure they have the same dimensions)
+ return (
+
+
+
+ {formatMessage(messages.error)}
+
+
+
+
+
+ {formatMessage(messages.close)}
+
+
+
+
+ );
+ }
+
+}
+
+export default injectIntl(BundleModalError);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
new file mode 100644
index 000000000..15538ea38
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+import { debounce } from 'lodash';
+import { scrollTop } from '../../../scroll';
+import { isMobile } from '../../../is_mobile';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ heading: PropTypes.string,
+ icon: PropTypes.string,
+ children: PropTypes.node,
+ active: PropTypes.bool,
+ hideHeadingOnMobile: PropTypes.bool,
+ };
+
+ handleHeaderClick = () => {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+
+ handleScroll = debounce(() => {
+ if (typeof this._interruptScrollAnimation !== 'undefined') {
+ this._interruptScrollAnimation();
+ }
+ }, 200)
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
+
+ const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
+
+ const columnHeaderId = showHeading && heading.replace(/ /g, '-');
+ const header = showHeading && (
+
+ );
+ return (
+
+ {header}
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
new file mode 100644
index 000000000..af195ea9c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ColumnHeader extends React.PureComponent {
+
+ static propTypes = {
+ icon: PropTypes.string,
+ type: PropTypes.string,
+ active: PropTypes.bool,
+ onClick: PropTypes.func,
+ columnHeaderId: PropTypes.string,
+ };
+
+ handleClick = () => {
+ this.props.onClick();
+ }
+
+ render () {
+ const { type, active, columnHeaderId } = this.props;
+
+ let icon = '';
+
+ if (this.props.icon) {
+ icon = ;
+ }
+
+ return (
+
+ {icon}
+ {type}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
new file mode 100644
index 000000000..5425219c4
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
+const ColumnLink = ({ icon, text, to, href, method }) => {
+ if (href) {
+ return (
+
+
+ {text}
+
+ );
+ } else {
+ return (
+
+
+ {text}
+
+ );
+ }
+};
+
+ColumnLink.propTypes = {
+ icon: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ to: PropTypes.string,
+ href: PropTypes.string,
+ method: PropTypes.string,
+ hideOnMobile: PropTypes.bool,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
new file mode 100644
index 000000000..9503a7a1a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_loading.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class ColumnLoading extends ImmutablePureComponent {
+
+ static propTypes = {
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ icon: PropTypes.string,
+ };
+
+ static defaultProps = {
+ title: '',
+ icon: '',
+ };
+
+ render() {
+ let { title, icon } = this.props;
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_subheading.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+ return (
+
+ {text}
+
+ );
+};
+
+ColumnSubheading.propTypes = {
+ text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
new file mode 100644
index 000000000..5610095b9
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -0,0 +1,173 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import ReactSwipeableViews from 'react-swipeable-views';
+import { links, getIndex, getLink } from './tabs_bar';
+
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
+
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from '../../../scroll';
+
+const componentMap = {
+ 'COMPOSE': Compose,
+ 'HOME': HomeTimeline,
+ 'NOTIFICATIONS': Notifications,
+ 'PUBLIC': PublicTimeline,
+ 'COMMUNITY': CommunityTimeline,
+ 'HASHTAG': HashtagTimeline,
+ 'FAVOURITES': FavouritedStatuses,
+};
+
+@component => injectIntl(component, { withRef: true })
+export default class ColumnsArea extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ columns: ImmutablePropTypes.list.isRequired,
+ singleColumn: PropTypes.bool,
+ children: PropTypes.node,
+ };
+
+ state = {
+ shouldAnimate: false,
+ }
+
+ componentWillReceiveProps() {
+ this.setState({ shouldAnimate: false });
+ }
+
+ componentDidMount() {
+ if (!this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+ this.lastIndex = getIndex(this.context.router.history.location.pathname);
+ this.setState({ shouldAnimate: true });
+ }
+
+ componentWillUpdate(nextProps) {
+ if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+ this.lastIndex = getIndex(this.context.router.history.location.pathname);
+ this.setState({ shouldAnimate: true });
+ }
+
+ componentWillUnmount () {
+ if (!this.props.singleColumn) {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+ }
+
+ handleChildrenContentChange() {
+ if (!this.props.singleColumn) {
+ this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
+ }
+ }
+
+ handleSwipe = (index) => {
+ this.pendingIndex = index;
+
+ const nextLinkTranslationId = links[index].props['data-preview-title-id'];
+ const currentLinkSelector = '.tabs-bar__link.active';
+ const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
+
+ // HACK: Remove the active class from the current link and set it to the next one
+ // React-router does this for us, but too late, feeling laggy.
+ document.querySelector(currentLinkSelector).classList.remove('active');
+ document.querySelector(nextLinkSelector).classList.add('active');
+ }
+
+ handleAnimationEnd = () => {
+ if (typeof this.pendingIndex === 'number') {
+ this.context.router.history.push(getLink(this.pendingIndex));
+ this.pendingIndex = null;
+ }
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ }
+
+ setRef = (node) => {
+ this.node = node;
+ }
+
+ renderView = (link, index) => {
+ const columnIndex = getIndex(this.context.router.history.location.pathname);
+ const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
+ const icon = link.props['data-preview-icon'];
+
+ const view = (index === columnIndex) ?
+ React.cloneElement(this.props.children) :
+ ;
+
+ return (
+
+ {view}
+
+ );
+ }
+
+ renderLoading = columnId => () => {
+ return columnId === 'COMPOSE' ? : ;
+ }
+
+ renderError = (props) => {
+ return ;
+ }
+
+ render () {
+ const { columns, children, singleColumn } = this.props;
+ const { shouldAnimate } = this.state;
+
+ const columnIndex = getIndex(this.context.router.history.location.pathname);
+ this.pendingIndex = null;
+
+ if (singleColumn) {
+ return columnIndex !== -1 ? (
+
+ {links.map(this.renderView)}
+
+ ) : {children}
;
+ }
+
+ return (
+
+ {columns.map(column => {
+ const params = column.get('params', null) === null ? null : column.get('params').toJS();
+
+ return (
+
+ {SpecificComponent => }
+
+ );
+ })}
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..86588c46a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+
+@injectIntl
+export default class ConfirmationModal extends React.PureComponent {
+
+ static propTypes = {
+ message: PropTypes.node.isRequired,
+ confirm: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm();
+ }
+
+ handleCancel = () => {
+ this.props.onClose();
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ render () {
+ const { message, confirm } = this.props;
+
+ return (
+
+
+ {message}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.js b/app/javascript/mastodon/features/ui/components/drawer_loading.js
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/drawer_loading.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DrawerLoading = () => (
+
+);
+
+export default DrawerLoading;
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..1afffb51b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import axios from 'axios';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ url: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ state = {
+ loading: false,
+ oembed: null,
+ };
+
+ componentDidMount () {
+ const { url } = this.props;
+
+ this.setState({ loading: true });
+
+ axios.post('/api/web/embed', { url }).then(res => {
+ this.setState({ loading: false, oembed: res.data });
+
+ const iframeDocument = this.iframe.contentWindow.document;
+
+ iframeDocument.open();
+ iframeDocument.write(res.data.html);
+ iframeDocument.close();
+
+ iframeDocument.body.style.margin = 0;
+ this.iframe.width = iframeDocument.body.scrollWidth;
+ this.iframe.height = iframeDocument.body.scrollHeight;
+ });
+ }
+
+ setIframeRef = c => {
+ this.iframe = c;
+ }
+
+ handleTextareaClick = (e) => {
+ e.target.select();
+ }
+
+ render () {
+ const { oembed } = this.state;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js
new file mode 100644
index 000000000..aad594380
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_loader.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class ImageLoader extends React.PureComponent {
+
+ static propTypes = {
+ alt: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ previewSrc: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ }
+
+ static defaultProps = {
+ alt: '',
+ width: null,
+ height: null,
+ };
+
+ state = {
+ loading: true,
+ error: false,
+ }
+
+ removers = [];
+
+ get canvasContext() {
+ if (!this.canvas) {
+ return null;
+ }
+ this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+ return this._canvasContext;
+ }
+
+ componentDidMount () {
+ this.loadImage(this.props);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.src !== nextProps.src) {
+ this.loadImage(nextProps);
+ }
+ }
+
+ loadImage (props) {
+ this.removeEventListeners();
+ this.setState({ loading: true, error: false });
+ Promise.all([
+ this.loadPreviewCanvas(props),
+ this.hasSize() && this.loadOriginalImage(props),
+ ].filter(Boolean))
+ .then(() => {
+ this.setState({ loading: false, error: false });
+ this.clearPreviewCanvas();
+ })
+ .catch(() => this.setState({ loading: false, error: true }));
+ }
+
+ loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ this.canvasContext.drawImage(image, 0, 0, width, height);
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = previewSrc;
+ this.removers.push(removeEventListeners);
+ })
+
+ clearPreviewCanvas () {
+ const { width, height } = this.canvas;
+ this.canvasContext.clearRect(0, 0, width, height);
+ }
+
+ loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+ const image = new Image();
+ const removeEventListeners = () => {
+ image.removeEventListener('error', handleError);
+ image.removeEventListener('load', handleLoad);
+ };
+ const handleError = () => {
+ removeEventListeners();
+ reject();
+ };
+ const handleLoad = () => {
+ removeEventListeners();
+ resolve();
+ };
+ image.addEventListener('error', handleError);
+ image.addEventListener('load', handleLoad);
+ image.src = src;
+ this.removers.push(removeEventListeners);
+ });
+
+ removeEventListeners () {
+ this.removers.forEach(listeners => listeners());
+ this.removers = [];
+ }
+
+ hasSize () {
+ const { width, height } = this.props;
+ return typeof width === 'number' && typeof height === 'number';
+ }
+
+ setCanvasRef = c => {
+ this.canvas = c;
+ }
+
+ render () {
+ const { alt, src, width, height } = this.props;
+ const { loading } = this.state;
+
+ const className = classNames('image-loader', {
+ 'image-loader--loading': loading,
+ 'image-loader--amorphous': !this.hasSize(),
+ });
+
+ return (
+
+
+
+ {!loading && (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
new file mode 100644
index 000000000..f41a83089
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+@injectIntl
+export default class MediaModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ index: PropTypes.number.isRequired,
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ index: null,
+ };
+
+ handleSwipe = (index) => {
+ this.setState({ index: index % this.props.media.size });
+ }
+
+ handleNextClick = () => {
+ this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
+ }
+
+ handlePrevClick = () => {
+ this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
+ }
+
+ handleChangeIndex = (e) => {
+ const index = Number(e.currentTarget.getAttribute('data-index'));
+ this.setState({ index: index % this.props.media.size });
+ }
+
+ handleKeyUp = (e) => {
+ switch(e.key) {
+ case 'ArrowLeft':
+ this.handlePrevClick();
+ break;
+ case 'ArrowRight':
+ this.handleNextClick();
+ break;
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ getIndex () {
+ return this.state.index !== null ? this.state.index : this.props.index;
+ }
+
+ render () {
+ const { media, intl, onClose } = this.props;
+
+ const index = this.getIndex();
+ let pagination = [];
+
+ const leftNav = media.size > 1 && ;
+ const rightNav = media.size > 1 && ;
+
+ if (media.size > 1) {
+ pagination = media.map((item, i) => {
+ const classes = ['media-modal__button'];
+ if (i === index) {
+ classes.push('media-modal__button--active');
+ }
+ return ({i + 1} );
+ });
+ }
+
+ const content = media.map((image) => {
+ const width = image.getIn(['meta', 'original', 'width']) || null;
+ const height = image.getIn(['meta', 'original', 'height']) || null;
+
+ if (image.get('type') === 'image') {
+ return ;
+ } else if (image.get('type') === 'gifv') {
+ return ;
+ }
+
+ return null;
+ }).toArray();
+
+ const containerStyle = {
+ alignItems: 'center', // center vertically
+ };
+
+ return (
+
+ {leftNav}
+
+
+
+
+ {content}
+
+
+
+
+ {rightNav}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.js b/app/javascript/mastodon/features/ui/components/modal_loading.js
new file mode 100644
index 000000000..f403ca4c9
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_loading.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import LoadingIndicator from '../../../components/loading_indicator';
+
+// Keep the markup in sync with
+// (make sure they have the same dimensions)
+const ModalLoading = () => (
+
+);
+
+export default ModalLoading;
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
new file mode 100644
index 000000000..79d86370e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -0,0 +1,127 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import ConfirmationModal from './confirmation_modal';
+import {
+ OnboardingModal,
+ MuteModal,
+ ReportModal,
+ EmbedModal,
+} from '../../../features/ui/util/async-components';
+
+const MODAL_COMPONENTS = {
+ 'MEDIA': () => Promise.resolve({ default: MediaModal }),
+ 'ONBOARDING': OnboardingModal,
+ 'VIDEO': () => Promise.resolve({ default: VideoModal }),
+ 'BOOST': () => Promise.resolve({ default: BoostModal }),
+ 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+ 'MUTE': MuteModal,
+ 'REPORT': ReportModal,
+ 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+ 'EMBED': EmbedModal,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+ static propTypes = {
+ type: PropTypes.string,
+ props: PropTypes.object,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ state = {
+ revealed: false,
+ };
+
+ handleKeyUp = (e) => {
+ if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+ && !!this.props.type) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!!nextProps.type && !this.props.type) {
+ this.activeElement = document.activeElement;
+
+ this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+ } else if (!nextProps.type) {
+ this.setState({ revealed: false });
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (!this.props.type && !!prevProps.type) {
+ this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+ this.activeElement.focus();
+ this.activeElement = null;
+ }
+ if (this.props.type) {
+ requestAnimationFrame(() => {
+ this.setState({ revealed: true });
+ });
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ getSiblings = () => {
+ return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+ }
+
+ setRef = ref => {
+ this.node = ref;
+ }
+
+ renderLoading = modalId => () => {
+ return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
+ }
+
+ renderError = (props) => {
+ const { onClose } = this.props;
+
+ return ;
+ }
+
+ render () {
+ const { type, props, onClose } = this.props;
+ const { revealed } = this.state;
+ const visible = !!type;
+
+ if (!visible) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {
+ visible ?
+ (
+ {(SpecificComponent) => }
+ ) :
+ null
+ }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
new file mode 100644
index 000000000..73e48cf09
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { muteAccount } from '../../../actions/accounts';
+import { toggleHideNotifications } from '../../../actions/mutes';
+
+
+const mapStateToProps = state => {
+ return {
+ isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+ account: state.getIn(['mutes', 'new', 'account']),
+ notifications: state.getIn(['mutes', 'new', 'notifications']),
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onConfirm(account, notifications) {
+ dispatch(muteAccount(account.get('id'), notifications));
+ },
+
+ onClose() {
+ dispatch(closeModal());
+ },
+
+ onToggleNotifications() {
+ dispatch(toggleHideNotifications());
+ },
+ };
+};
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class MuteModal extends React.PureComponent {
+
+ static propTypes = {
+ isSubmitting: PropTypes.bool.isRequired,
+ account: PropTypes.object.isRequired,
+ notifications: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onToggleNotifications: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentDidMount() {
+ this.button.focus();
+ }
+
+ handleClick = () => {
+ this.props.onClose();
+ this.props.onConfirm(this.props.account, this.props.notifications);
+ }
+
+ handleCancel = () => {
+ this.props.onClose();
+ }
+
+ setRef = (c) => {
+ this.button = c;
+ }
+
+ toggleNotifications = () => {
+ this.props.onToggleNotifications();
+ }
+
+ render () {
+ const { account, notifications } = this.props;
+
+ return (
+
+
+
+ @{account.get('acct')} }}
+ />
+
+
+
+
+ {' '}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..54673e223
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -0,0 +1,318 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ReactSwipeableViews from 'react-swipeable-views';
+import classNames from 'classnames';
+import Permalink from '../../../components/permalink';
+import ComposeForm from '../../compose/components/compose_form';
+import Search from '../../compose/components/search';
+import NavigationBar from '../../compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import { List as ImmutableList } from 'immutable';
+import { me } from '../../../initial_state';
+
+const noop = () => { };
+
+const messages = defineMessages({
+ home_title: { id: 'column.home', defaultMessage: 'Home' },
+ notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+ local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+ federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const PageOne = ({ acct, domain }) => (
+
+
+
+
+
+
+
@{acct}@{domain} }} />
+
+
+);
+
+PageOne.propTypes = {
+ acct: PropTypes.string.isRequired,
+ domain: PropTypes.string.isRequired,
+};
+
+const PageTwo = ({ myAccount }) => (
+
+);
+
+PageTwo.propTypes = {
+ myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ myAccount }) => (
+
+
+
+
#illustration, introductions: #introductions }} />
+
+
+);
+
+PageThree.propTypes = {
+ myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageFour = ({ domain, intl }) => (
+
+);
+
+PageFour.propTypes = {
+ domain: PropTypes.string.isRequired,
+ intl: PropTypes.object.isRequired,
+};
+
+const PageSix = ({ admin, domain }) => {
+ let adminSection = '';
+
+ if (admin) {
+ adminSection = (
+
+ @{admin.get('acct')} }} />
+
+ }} />
+
+ );
+ }
+
+ return (
+
+
+ {adminSection}
+
GitHub }} />
+
}} />
+
+
+ );
+};
+
+PageSix.propTypes = {
+ admin: ImmutablePropTypes.map,
+ domain: PropTypes.string.isRequired,
+};
+
+const mapStateToProps = state => ({
+ myAccount: state.getIn(['accounts', me]),
+ admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+ domain: state.getIn(['meta', 'domain']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class OnboardingModal extends React.PureComponent {
+
+ static propTypes = {
+ onClose: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ myAccount: ImmutablePropTypes.map.isRequired,
+ domain: PropTypes.string.isRequired,
+ admin: ImmutablePropTypes.map,
+ };
+
+ state = {
+ currentIndex: 0,
+ };
+
+ componentWillMount() {
+ const { myAccount, admin, domain, intl } = this.props;
+ this.pages = [
+ ,
+ ,
+ ,
+ ,
+ ,
+ ];
+ };
+
+ componentDidMount() {
+ window.addEventListener('keyup', this.handleKeyUp);
+ }
+
+ componentWillUnmount() {
+ window.addEventListener('keyup', this.handleKeyUp);
+ }
+
+ handleSkip = (e) => {
+ e.preventDefault();
+ this.props.onClose();
+ }
+
+ handleDot = (e) => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.setState({ currentIndex: i });
+ }
+
+ handlePrev = () => {
+ this.setState(({ currentIndex }) => ({
+ currentIndex: Math.max(0, currentIndex - 1),
+ }));
+ }
+
+ handleNext = () => {
+ const { pages } = this;
+ this.setState(({ currentIndex }) => ({
+ currentIndex: Math.min(currentIndex + 1, pages.length - 1),
+ }));
+ }
+
+ handleSwipe = (index) => {
+ this.setState({ currentIndex: index });
+ }
+
+ handleKeyUp = ({ key }) => {
+ switch (key) {
+ case 'ArrowLeft':
+ this.handlePrev();
+ break;
+ case 'ArrowRight':
+ this.handleNext();
+ break;
+ }
+ }
+
+ handleClose = () => {
+ this.props.onClose();
+ }
+
+ render () {
+ const { pages } = this;
+ const { currentIndex } = this.state;
+ const hasMore = currentIndex < pages.length - 1;
+
+ const nextOrDoneBtn = hasMore ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+
+ {pages.map((page, i) => {
+ const className = classNames('onboarding-modal__page__wrapper', {
+ 'onboarding-modal__page__wrapper--active': i === currentIndex,
+ });
+ return (
+ {page}
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ {pages.map((_, i) => {
+ const className = classNames('onboarding-modal__dot', {
+ active: i === currentIndex,
+ });
+ return (
+
+ );
+ })}
+
+
+
+ {nextOrDoneBtn}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
new file mode 100644
index 000000000..b5dfa422e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { changeReportComment, submitReport } from '../../../actions/reports';
+import { refreshAccountTimeline } from '../../../actions/timelines';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { makeGetAccount } from '../../../selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from '../../report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Button from '../../../components/button';
+
+const messages = defineMessages({
+ placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+ submit: { id: 'report.submit', defaultMessage: 'Submit' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = state => {
+ const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+ return {
+ isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+ account: getAccount(state, accountId),
+ comment: state.getIn(['reports', 'new', 'comment']),
+ statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+ };
+ };
+
+ return mapStateToProps;
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class ReportModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ isSubmitting: PropTypes.bool,
+ account: ImmutablePropTypes.map,
+ statusIds: ImmutablePropTypes.orderedSet.isRequired,
+ comment: PropTypes.string.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleCommentChange = (e) => {
+ this.props.dispatch(changeReportComment(e.target.value));
+ }
+
+ handleSubmit = () => {
+ this.props.dispatch(submitReport());
+ }
+
+ componentDidMount () {
+ this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.account !== nextProps.account && nextProps.account) {
+ this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
+ }
+ }
+
+ render () {
+ const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ return (
+
+
+ {account.get('acct')} }} />
+
+
+
+
+
+ {statusIds.map(statusId => )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..7694e5ab3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from '../../../is_mobile';
+
+export const links = [
+ ,
+ ,
+ ,
+
+ ,
+ ,
+
+ ,
+];
+
+export function getIndex (path) {
+ return links.findIndex(link => link.props.to === path);
+}
+
+export function getLink (index) {
+ return links[index].props.to;
+}
+
+@injectIntl
+export default class TabsBar extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ }
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ }
+
+ setRef = ref => {
+ this.node = ref;
+ }
+
+ handleClick = (e) => {
+ // Only apply optimization for touch devices, which we assume are slower
+ // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
+ if (isUserTouching()) {
+ e.preventDefault();
+ e.persist();
+
+ requestAnimationFrame(() => {
+ const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
+ const currentTab = tabs.find(tab => tab.classList.contains('active'));
+ const nextTab = tabs.find(tab => tab.contains(e.target));
+ const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
+
+
+ if (currentTab !== nextTab) {
+ if (currentTab) {
+ currentTab.classList.remove('active');
+ }
+
+ const listener = debounce(() => {
+ nextTab.removeEventListener('transitionend', listener);
+ this.context.router.history.push(to);
+ }, 50);
+
+ nextTab.addEventListener('transitionend', listener);
+ nextTab.classList.add('active');
+ }
+ });
+ }
+
+ }
+
+ render () {
+ const { intl: { formatMessage } } = this.props;
+
+ return (
+
+ {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
new file mode 100644
index 000000000..8b9a26270
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadArea extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onClose: PropTypes.func,
+ };
+
+ handleKeyUp = (e) => {
+ const keyCode = e.keyCode;
+ if (this.props.active) {
+ switch(keyCode) {
+ case 27:
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onClose();
+ break;
+ }
+ }
+ }
+
+ componentDidMount () {
+ window.addEventListener('keyup', this.handleKeyUp, false);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('keyup', this.handleKeyUp);
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+ {({ backgroundOpacity, backgroundScale }) =>
+
+ }
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
new file mode 100644
index 000000000..1437deeb0
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from '../../video';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ time: PropTypes.number,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ const { media, time, onClose } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/containers/bundle_container.js b/app/javascript/mastodon/features/ui/containers/bundle_container.js
new file mode 100644
index 000000000..7e3f0c3a6
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/bundle_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+
+import Bundle from '../components/bundle';
+
+import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles';
+
+const mapDispatchToProps = dispatch => ({
+ onFetch () {
+ dispatch(fetchBundleRequest());
+ },
+ onFetchSuccess () {
+ dispatch(fetchBundleSuccess());
+ },
+ onFetchFail (error) {
+ dispatch(fetchBundleFail(error));
+ },
+});
+
+export default connect(null, mapDispatchToProps)(Bundle);
diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
new file mode 100644
index 000000000..95f95618b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+
+const mapStateToProps = state => ({
+ columns: state.getIn(['settings', 'columns']),
+});
+
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..4bb90fb68
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state) => ({
+ loading: state.get('loadingBar'),
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..2d27180f7
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+ type: state.get('modal').modalType,
+ props: state.get('modal').modalProps,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onClose () {
+ dispatch(closeModal());
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..5924197f1
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import { dismissAlert } from '../../../actions/alerts';
+import { getAlerts } from '../../../selectors';
+
+const mapStateToProps = state => ({
+ notifications: getAlerts(state),
+});
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onDismiss: alert => {
+ dispatch(dismissAlert(alert));
+ },
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..a0aec4403
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -0,0 +1,73 @@
+import { connect } from 'react-redux';
+import StatusList from '../../../components/status_list';
+import { scrollTopTimeline } from '../../../actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'lodash';
+import { me } from '../../../initial_state';
+
+const makeGetStatusIds = () => createSelector([
+ (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+ (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+ (state) => state.get('statuses'),
+], (columnSettings, statusIds, statuses) => {
+ const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
+ let regex = null;
+
+ try {
+ regex = rawRegex && new RegExp(rawRegex, 'i');
+ } catch (e) {
+ // Bad regex, don't affect filters
+ }
+
+ return statusIds.filter(id => {
+ const statusForId = statuses.get(id);
+ let showStatus = true;
+
+ if (columnSettings.getIn(['shows', 'reblog']) === false) {
+ showStatus = showStatus && statusForId.get('reblog') === null;
+ }
+
+ if (columnSettings.getIn(['shows', 'reply']) === false) {
+ showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+ }
+
+ if (showStatus && regex && statusForId.get('account') !== me) {
+ const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
+ showStatus = !regex.test(searchIndex);
+ }
+
+ return showStatus;
+ });
+});
+
+const makeMapStateToProps = () => {
+ const getStatusIds = makeGetStatusIds();
+
+ const mapStateToProps = (state, { timelineId }) => ({
+ statusIds: getStatusIds(state, { type: timelineId }),
+ isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+ hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
+
+ onScrollToBottom: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, false));
+ loadMore();
+ }, 300, { leading: true }),
+
+ onScrollToTop: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, true));
+ }, 100),
+
+ onScroll: debounce(() => {
+ dispatch(scrollTopTimeline(timelineId, false));
+ }, 100),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
new file mode 100644
index 000000000..f28b37099
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -0,0 +1,407 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { isMobile } from '../../is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose } from '../../actions/compose';
+import { refreshHomeTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
+import { clearHeight } from '../../actions/height_cache';
+import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+import UploadArea from './components/upload_area';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import {
+ Compose,
+ Status,
+ GettingStarted,
+ PublicTimeline,
+ CommunityTimeline,
+ AccountTimeline,
+ AccountGallery,
+ HomeTimeline,
+ Followers,
+ Following,
+ Reblogs,
+ Favourites,
+ HashtagTimeline,
+ Notifications,
+ FollowRequests,
+ GenericNotFound,
+ FavouritedStatuses,
+ Blocks,
+ Mutes,
+ PinnedStatuses,
+} from './util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import { me } from '../../initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Dummy import, to make sure that ends up in the application bundle.
+// Without this it ends up in ~8 very commonly used bundles.
+import '../../components/status';
+
+const messages = defineMessages({
+ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
+const mapStateToProps = state => ({
+ isComposing: state.getIn(['compose', 'is_composing']),
+ hasComposingText: state.getIn(['compose', 'text']) !== '',
+});
+
+const keyMap = {
+ new: 'n',
+ search: 's',
+ forceNew: 'option+n',
+ focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+ reply: 'r',
+ favourite: 'f',
+ boost: 'b',
+ mention: 'm',
+ open: ['enter', 'o'],
+ openProfile: 'p',
+ moveDown: ['down', 'j'],
+ moveUp: ['up', 'k'],
+ back: 'backspace',
+ goToHome: 'g h',
+ goToNotifications: 'g n',
+ goToLocal: 'g l',
+ goToFederated: 'g t',
+ goToStart: 'g s',
+ goToFavourites: 'g f',
+ goToPinned: 'g p',
+ goToProfile: 'g u',
+ goToBlocked: 'g b',
+ goToMuted: 'g m',
+};
+
+@connect(mapStateToProps)
+@injectIntl
+@withRouter
+export default class UI extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ children: PropTypes.node,
+ isComposing: PropTypes.bool,
+ hasComposingText: PropTypes.bool,
+ location: PropTypes.object,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ width: window.innerWidth,
+ draggingOver: false,
+ };
+
+ handleBeforeUnload = (e) => {
+ const { intl, isComposing, hasComposingText } = this.props;
+
+ if (isComposing && hasComposingText) {
+ // Setting returnValue to any string causes confirmation dialog.
+ // Many browsers no longer display this text to users,
+ // but we set user-friendly message for other browsers, e.g. Edge.
+ e.returnValue = intl.formatMessage(messages.beforeUnload);
+ }
+ }
+
+ handleResize = debounce(() => {
+ // The cached heights are no longer accurate, invalidate
+ this.props.dispatch(clearHeight());
+
+ this.setState({ width: window.innerWidth });
+ }, 500, {
+ trailing: true,
+ });
+
+ handleDragEnter = (e) => {
+ e.preventDefault();
+
+ if (!this.dragTargets) {
+ this.dragTargets = [];
+ }
+
+ if (this.dragTargets.indexOf(e.target) === -1) {
+ this.dragTargets.push(e.target);
+ }
+
+ if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+ this.setState({ draggingOver: true });
+ }
+ }
+
+ handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ try {
+ e.dataTransfer.dropEffect = 'copy';
+ } catch (err) {
+
+ }
+
+ return false;
+ }
+
+ handleDrop = (e) => {
+ e.preventDefault();
+
+ this.setState({ draggingOver: false });
+
+ if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+ this.props.dispatch(uploadCompose(e.dataTransfer.files));
+ }
+ }
+
+ handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+ if (this.dragTargets.length > 0) {
+ return;
+ }
+
+ this.setState({ draggingOver: false });
+ }
+
+ closeUploadModal = () => {
+ this.setState({ draggingOver: false });
+ }
+
+ handleServiceWorkerPostMessage = ({ data }) => {
+ if (data.type === 'navigate') {
+ this.context.router.history.push(data.path);
+ } else {
+ console.warn('Unknown message type:', data.type);
+ }
+ }
+
+ componentWillMount () {
+ window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ document.addEventListener('dragenter', this.handleDragEnter, false);
+ document.addEventListener('dragover', this.handleDragOver, false);
+ document.addEventListener('drop', this.handleDrop, false);
+ document.addEventListener('dragleave', this.handleDragLeave, false);
+ document.addEventListener('dragend', this.handleDragEnd, false);
+
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+ }
+
+ this.props.dispatch(refreshHomeTimeline());
+ this.props.dispatch(refreshNotifications());
+ }
+
+ componentDidMount () {
+ this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+ return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+ };
+ }
+
+ shouldComponentUpdate (nextProps) {
+ if (nextProps.isComposing !== this.props.isComposing) {
+ // Avoid expensive update just to toggle a class
+ this.node.classList.toggle('is-composing', nextProps.isComposing);
+
+ return false;
+ }
+
+ // Why isn't this working?!?
+ // return super.shouldComponentUpdate(nextProps, nextState);
+ return true;
+ }
+
+ componentDidUpdate (prevProps) {
+ if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+ this.columnsAreaNode.handleChildrenContentChange();
+ }
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
+ window.removeEventListener('resize', this.handleResize);
+ document.removeEventListener('dragenter', this.handleDragEnter);
+ document.removeEventListener('dragover', this.handleDragOver);
+ document.removeEventListener('drop', this.handleDrop);
+ document.removeEventListener('dragleave', this.handleDragLeave);
+ document.removeEventListener('dragend', this.handleDragEnd);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ setColumnsAreaRef = c => {
+ this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+ }
+
+ handleHotkeyNew = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ handleHotkeySearch = e => {
+ e.preventDefault();
+
+ const element = this.node.querySelector('.search__input');
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ handleHotkeyForceNew = e => {
+ this.handleHotkeyNew(e);
+ this.props.dispatch(resetCompose());
+ }
+
+ 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) {
+ const status = column.querySelector('.focusable');
+
+ if (status) {
+ status.focus();
+ }
+ }
+ }
+
+ handleHotkeyBack = () => {
+ if (window.history && window.history.length === 1) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ setHotkeysRef = c => {
+ this.hotkeys = c;
+ }
+
+ handleHotkeyGoToHome = () => {
+ this.context.router.history.push('/timelines/home');
+ }
+
+ handleHotkeyGoToNotifications = () => {
+ this.context.router.history.push('/notifications');
+ }
+
+ handleHotkeyGoToLocal = () => {
+ this.context.router.history.push('/timelines/public/local');
+ }
+
+ handleHotkeyGoToFederated = () => {
+ this.context.router.history.push('/timelines/public');
+ }
+
+ handleHotkeyGoToStart = () => {
+ this.context.router.history.push('/getting-started');
+ }
+
+ handleHotkeyGoToFavourites = () => {
+ this.context.router.history.push('/favourites');
+ }
+
+ handleHotkeyGoToPinned = () => {
+ this.context.router.history.push('/pinned');
+ }
+
+ handleHotkeyGoToProfile = () => {
+ this.context.router.history.push(`/accounts/${me}`);
+ }
+
+ handleHotkeyGoToBlocked = () => {
+ this.context.router.history.push('/blocks');
+ }
+
+ handleHotkeyGoToMuted = () => {
+ this.context.router.history.push('/mutes');
+ }
+
+ render () {
+ const { width, draggingOver } = this.state;
+ const { children } = this.props;
+
+ const handlers = {
+ new: this.handleHotkeyNew,
+ search: this.handleHotkeySearch,
+ forceNew: this.handleHotkeyForceNew,
+ focusColumn: this.handleHotkeyFocusColumn,
+ back: this.handleHotkeyBack,
+ goToHome: this.handleHotkeyGoToHome,
+ goToNotifications: this.handleHotkeyGoToNotifications,
+ goToLocal: this.handleHotkeyGoToLocal,
+ goToFederated: this.handleHotkeyGoToFederated,
+ goToStart: this.handleHotkeyGoToStart,
+ goToFavourites: this.handleHotkeyGoToFavourites,
+ goToPinned: this.handleHotkeyGoToPinned,
+ goToProfile: this.handleHotkeyGoToProfile,
+ goToBlocked: this.handleHotkeyGoToBlocked,
+ goToMuted: this.handleHotkeyGoToMuted,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
new file mode 100644
index 000000000..39663d5ca
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -0,0 +1,107 @@
+export function EmojiPicker () {
+ return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
+}
+
+export function Compose () {
+ return import(/* webpackChunkName: "features/compose" */'../../compose');
+}
+
+export function Notifications () {
+ return import(/* webpackChunkName: "features/notifications" */'../../notifications');
+}
+
+export function HomeTimeline () {
+ return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
+}
+
+export function PublicTimeline () {
+ return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
+}
+
+export function CommunityTimeline () {
+ return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
+}
+
+export function HashtagTimeline () {
+ return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
+}
+
+export function Status () {
+ return import(/* webpackChunkName: "features/status" */'../../status');
+}
+
+export function GettingStarted () {
+ return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
+}
+
+export function PinnedStatuses () {
+ return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
+}
+
+export function AccountTimeline () {
+ return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
+}
+
+export function AccountGallery () {
+ return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
+}
+
+export function Followers () {
+ return import(/* webpackChunkName: "features/followers" */'../../followers');
+}
+
+export function Following () {
+ return import(/* webpackChunkName: "features/following" */'../../following');
+}
+
+export function Reblogs () {
+ return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
+}
+
+export function Favourites () {
+ return import(/* webpackChunkName: "features/favourites" */'../../favourites');
+}
+
+export function FollowRequests () {
+ return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
+}
+
+export function GenericNotFound () {
+ return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found');
+}
+
+export function FavouritedStatuses () {
+ return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
+}
+
+export function Blocks () {
+ return import(/* webpackChunkName: "features/blocks" */'../../blocks');
+}
+
+export function Mutes () {
+ return import(/* webpackChunkName: "features/mutes" */'../../mutes');
+}
+
+export function OnboardingModal () {
+ return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
+}
+
+export function MuteModal () {
+ return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
+}
+
+export function ReportModal () {
+ return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
+}
+
+export function MediaGallery () {
+ return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
+}
+
+export function Video () {
+ return import(/* webpackChunkName: "features/video" */'../../video');
+}
+
+export function EmbedModal () {
+ return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
+}
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+ document.webkitFullscreenElement ||
+ document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ }
+};
+
+export const requestFullscreen = el => {
+ if (el.requestFullscreen) {
+ el.requestFullscreen();
+ } else if (el.webkitRequestFullscreen) {
+ el.webkitRequestFullscreen();
+ } else if (el.mozRequestFullScreen) {
+ el.mozRequestFullScreen();
+ }
+};
+
+export const attachFullscreenListener = (listener) => {
+ if ('onfullscreenchange' in document) {
+ document.addEventListener('fullscreenchange', listener);
+ } else if ('onwebkitfullscreenchange' in document) {
+ document.addEventListener('webkitfullscreenchange', listener);
+ } else if ('onmozfullscreenchange' in document) {
+ document.addEventListener('mozfullscreenchange', listener);
+ }
+};
+
+export const detachFullscreenListener = (listener) => {
+ if ('onfullscreenchange' in document) {
+ document.removeEventListener('fullscreenchange', listener);
+ } else if ('onwebkitfullscreenchange' in document) {
+ document.removeEventListener('webkitfullscreenchange', listener);
+ } else if ('onmozfullscreenchange' in document) {
+ document.removeEventListener('mozfullscreenchange', listener);
+ }
+};
diff --git a/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js b/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js
new file mode 100644
index 000000000..c266cd7dc
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/get_rect_from_entry.js
@@ -0,0 +1,21 @@
+
+// Get the bounding client rect from an IntersectionObserver entry.
+// This is to work around a bug in Chrome: https://crbug.com/737228
+
+let hasBoundingRectBug;
+
+function getRectFromEntry(entry) {
+ if (typeof hasBoundingRectBug !== 'boolean') {
+ const boundingRect = entry.target.getBoundingClientRect();
+ const observerRect = entry.boundingClientRect;
+ hasBoundingRectBug = boundingRect.height !== observerRect.height ||
+ boundingRect.top !== observerRect.top ||
+ boundingRect.width !== observerRect.width ||
+ boundingRect.bottom !== observerRect.bottom ||
+ boundingRect.left !== observerRect.left ||
+ boundingRect.right !== observerRect.right;
+ }
+ return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
+}
+
+export default getRectFromEntry;
diff --git a/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..2b24c6583
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js
@@ -0,0 +1,57 @@
+// Wrapper for IntersectionObserver in order to make working with it
+// a bit easier. We also follow this performance advice:
+// "If you need to observe multiple elements, it is both possible and
+// advised to observe multiple elements using the same IntersectionObserver
+// instance by calling observe() multiple times."
+// https://developers.google.com/web/updates/2016/04/intersectionobserver
+
+class IntersectionObserverWrapper {
+
+ callbacks = {};
+ observerBacklog = [];
+ observer = null;
+
+ connect (options) {
+ const onIntersection = (entries) => {
+ entries.forEach(entry => {
+ const id = entry.target.getAttribute('data-id');
+ if (this.callbacks[id]) {
+ this.callbacks[id](entry);
+ }
+ });
+ };
+
+ this.observer = new IntersectionObserver(onIntersection, options);
+ this.observerBacklog.forEach(([ id, node, callback ]) => {
+ this.observe(id, node, callback);
+ });
+ this.observerBacklog = null;
+ }
+
+ observe (id, node, callback) {
+ if (!this.observer) {
+ this.observerBacklog.push([ id, node, callback ]);
+ } else {
+ this.callbacks[id] = callback;
+ this.observer.observe(node);
+ }
+ }
+
+ unobserve (id, node) {
+ if (this.observer) {
+ delete this.callbacks[id];
+ this.observer.unobserve(node);
+ }
+ }
+
+ disconnect () {
+ if (this.observer) {
+ this.callbacks = {};
+ this.observer.disconnect();
+ this.observer = null;
+ }
+ }
+
+}
+
+export default IntersectionObserverWrapper;
diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js
new file mode 100644
index 000000000..df3a8b54a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/optional_motion.js
@@ -0,0 +1,5 @@
+import { reduceMotion } from '../../../initial_state';
+import ReducedMotion from './reduced_motion';
+import Motion from 'react-motion/lib/Motion';
+
+export default reduceMotion ? ReducedMotion : Motion;
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
new file mode 100644
index 000000000..43007ddc3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+
+import ColumnLoading from '../components/column_loading';
+import BundleColumnError from '../components/bundle_column_error';
+import BundleContainer from '../containers/bundle_container';
+
+// Small wrapper to pass multiColumn to the route components
+export class WrappedSwitch extends React.PureComponent {
+
+ render () {
+ const { multiColumn, children } = this.props;
+
+ return (
+
+ {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
+
+ );
+ }
+
+}
+
+WrappedSwitch.propTypes = {
+ multiColumn: PropTypes.bool,
+ children: PropTypes.node,
+};
+
+// Small Wraper to extract the params from the route and pass
+// them to the rendered component, together with the content to
+// be rendered inside (the children)
+export class WrappedRoute extends React.Component {
+
+ static propTypes = {
+ component: PropTypes.func.isRequired,
+ content: PropTypes.node,
+ multiColumn: PropTypes.bool,
+ }
+
+ renderComponent = ({ match }) => {
+ const { component, content, multiColumn } = this.props;
+
+ return (
+
+ {Component => {content} }
+
+ );
+ }
+
+ renderLoading = () => {
+ return ;
+ }
+
+ renderError = (props) => {
+ return ;
+ }
+
+ render () {
+ const { component: Component, content, ...rest } = this.props;
+
+ return ;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.js b/app/javascript/mastodon/features/ui/util/reduced_motion.js
new file mode 100644
index 000000000..95519042b
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/reduced_motion.js
@@ -0,0 +1,44 @@
+// Like react-motion's Motion, but reduces all animations to cross-fades
+// for the benefit of users with motion sickness.
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import PropTypes from 'prop-types';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+ // This is either an object with a "val" property or it's a number
+ return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+class ReducedMotion extends React.Component {
+
+ static propTypes = {
+ defaultStyle: PropTypes.object,
+ style: PropTypes.object,
+ children: PropTypes.func,
+ }
+
+ render() {
+
+ const { style, defaultStyle, children } = this.props;
+
+ Object.keys(style).forEach(key => {
+ if (stylesToKeep.includes(key)) {
+ return;
+ }
+ // If it's setting an x or height or scale or some other value, we need
+ // to preserve the end-state value without actually animating it
+ style[key] = defaultStyle[key] = extractValue(style[key]);
+ });
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+export default ReducedMotion;
diff --git a/app/javascript/mastodon/features/ui/util/schedule_idle_task.js b/app/javascript/mastodon/features/ui/util/schedule_idle_task.js
new file mode 100644
index 000000000..b04d4a8ee
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/schedule_idle_task.js
@@ -0,0 +1,29 @@
+// Wrapper to call requestIdleCallback() to schedule low-priority work.
+// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
+// for a good breakdown of the concepts behind this.
+
+import Queue from 'tiny-queue';
+
+const taskQueue = new Queue();
+let runningRequestIdleCallback = false;
+
+function runTasks(deadline) {
+ while (taskQueue.length && deadline.timeRemaining() > 0) {
+ taskQueue.shift()();
+ }
+ if (taskQueue.length) {
+ requestIdleCallback(runTasks);
+ } else {
+ runningRequestIdleCallback = false;
+ }
+}
+
+function scheduleIdleTask(task) {
+ taskQueue.push(task);
+ if (!runningRequestIdleCallback) {
+ runningRequestIdleCallback = true;
+ requestIdleCallback(runTasks);
+ }
+}
+
+export default scheduleIdleTask;
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
new file mode 100644
index 000000000..003bf23a8
--- /dev/null
+++ b/app/javascript/mastodon/features/video/index.js
@@ -0,0 +1,286 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
+
+const messages = defineMessages({
+ play: { id: 'video.play', defaultMessage: 'Play' },
+ pause: { id: 'video.pause', defaultMessage: 'Pause' },
+ mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+ unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+ hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+ expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+ close: { id: 'video.close', defaultMessage: 'Close video' },
+ fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+ exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const findElementPosition = el => {
+ let box;
+
+ if (el.getBoundingClientRect && el.parentNode) {
+ box = el.getBoundingClientRect();
+ }
+
+ if (!box) {
+ return {
+ left: 0,
+ top: 0,
+ };
+ }
+
+ const docEl = document.documentElement;
+ const body = document.body;
+
+ const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+ const scrollLeft = window.pageXOffset || body.scrollLeft;
+ const left = (box.left + scrollLeft) - clientLeft;
+
+ const clientTop = docEl.clientTop || body.clientTop || 0;
+ const scrollTop = window.pageYOffset || body.scrollTop;
+ const top = (box.top + scrollTop) - clientTop;
+
+ return {
+ left: Math.round(left),
+ top: Math.round(top),
+ };
+};
+
+const getPointerPosition = (el, event) => {
+ const position = {};
+ const box = findElementPosition(el);
+ const boxW = el.offsetWidth;
+ const boxH = el.offsetHeight;
+ const boxY = box.top;
+ const boxX = box.left;
+
+ let pageY = event.pageY;
+ let pageX = event.pageX;
+
+ if (event.changedTouches) {
+ pageX = event.changedTouches[0].pageX;
+ pageY = event.changedTouches[0].pageY;
+ }
+
+ position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+ position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+ return position;
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+ static propTypes = {
+ preview: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ startTime: PropTypes.number,
+ onOpenVideo: PropTypes.func,
+ onCloseVideo: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ progress: 0,
+ paused: true,
+ dragging: false,
+ fullscreen: false,
+ hovered: false,
+ muted: false,
+ revealed: !this.props.sensitive,
+ };
+
+ setPlayerRef = c => {
+ this.player = c;
+ }
+
+ setVideoRef = c => {
+ this.video = c;
+ }
+
+ setSeekRef = c => {
+ this.seek = c;
+ }
+
+ handlePlay = () => {
+ this.setState({ paused: false });
+ }
+
+ handlePause = () => {
+ this.setState({ paused: true });
+ }
+
+ handleTimeUpdate = () => {
+ this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
+ }
+
+ handleMouseDown = e => {
+ document.addEventListener('mousemove', this.handleMouseMove, true);
+ document.addEventListener('mouseup', this.handleMouseUp, true);
+ document.addEventListener('touchmove', this.handleMouseMove, true);
+ document.addEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: true });
+ this.video.pause();
+ this.handleMouseMove(e);
+ }
+
+ handleMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseMove, true);
+ document.removeEventListener('mouseup', this.handleMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseMove, true);
+ document.removeEventListener('touchend', this.handleMouseUp, true);
+
+ this.setState({ dragging: false });
+ this.video.play();
+ }
+
+ handleMouseMove = throttle(e => {
+ const { x } = getPointerPosition(this.seek, e);
+ this.video.currentTime = this.video.duration * x;
+ this.setState({ progress: x * 100 });
+ }, 60);
+
+ togglePlay = () => {
+ if (this.state.paused) {
+ this.video.play();
+ } else {
+ this.video.pause();
+ }
+ }
+
+ toggleFullscreen = () => {
+ if (isFullscreen()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(this.player);
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+ document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+ }
+
+ handleFullscreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ }
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ }
+
+ toggleMute = () => {
+ this.video.muted = !this.video.muted;
+ this.setState({ muted: this.video.muted });
+ }
+
+ toggleReveal = () => {
+ if (this.state.revealed) {
+ this.video.pause();
+ }
+
+ this.setState({ revealed: !this.state.revealed });
+ }
+
+ handleLoadedData = () => {
+ if (this.props.startTime) {
+ this.video.currentTime = this.props.startTime;
+ this.video.play();
+ }
+ }
+
+ handleProgress = () => {
+ if (this.video.buffered.length > 0) {
+ this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+ }
+ }
+
+ handleOpenVideo = () => {
+ this.video.pause();
+ this.props.onOpenVideo(this.video.currentTime);
+ }
+
+ handleCloseVideo = () => {
+ this.video.pause();
+ this.props.onCloseVideo();
+ }
+
+ render () {
+ const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
+ const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!onCloseVideo && }
+
+
+
+ {(!fullscreen && onOpenVideo) && }
+ {onCloseVideo && }
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
new file mode 100644
index 000000000..3fc45077d
--- /dev/null
+++ b/app/javascript/mastodon/initial_state.js
@@ -0,0 +1,13 @@
+const element = document.getElementById('initial-state');
+const initialState = element && JSON.parse(element.textContent);
+
+const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
+
+export const reduceMotion = getMeta('reduce_motion');
+export const autoPlayGif = getMeta('auto_play_gif');
+export const unfollowModal = getMeta('unfollow_modal');
+export const boostModal = getMeta('boost_modal');
+export const deleteModal = getMeta('delete_modal');
+export const me = getMeta('me');
+
+export default initialState;
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
new file mode 100644
index 000000000..f96df1ebb
--- /dev/null
+++ b/app/javascript/mastodon/is_mobile.js
@@ -0,0 +1,27 @@
+import detectPassiveEvents from 'detect-passive-events';
+
+const LAYOUT_BREAKPOINT = 630;
+
+export function isMobile(width) {
+ return width <= LAYOUT_BREAKPOINT;
+};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+let userTouching = false;
+let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+function touchListener() {
+ userTouching = true;
+ window.removeEventListener('touchstart', touchListener, listenerOptions);
+}
+
+window.addEventListener('touchstart', touchListener, listenerOptions);
+
+export function isUserTouching() {
+ return userTouching;
+}
+
+export function isIOS() {
+ return iOS;
+};
diff --git a/app/javascript/mastodon/link_header.js b/app/javascript/mastodon/link_header.js
new file mode 100644
index 000000000..a3e7ccf1c
--- /dev/null
+++ b/app/javascript/mastodon/link_header.js
@@ -0,0 +1,33 @@
+import Link from 'http-link-header';
+import querystring from 'querystring';
+
+Link.parseAttrs = (link, parts) => {
+ let match = null;
+ let attr = '';
+ let value = '';
+ let attrs = '';
+
+ let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts);
+
+ if(uriAttrs) {
+ attrs = uriAttrs[2];
+ link = Link.parseParams(link, uriAttrs[1]);
+ }
+
+ while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
+ attr = match[1].toLowerCase();
+ value = match[4] || match[3] || match[2];
+
+ if( /\*$/.test(attr)) {
+ Link.setAttr(link, attr, Link.parseExtendedValue(value));
+ } else if(/%/.test(value)) {
+ Link.setAttr(link, attr, querystring.decode(value));
+ } else {
+ Link.setAttr(link, attr, value);
+ }
+ }
+
+ return link;
+};
+
+export default Link;
diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js
new file mode 100644
index 000000000..8927b7358
--- /dev/null
+++ b/app/javascript/mastodon/load_polyfills.js
@@ -0,0 +1,39 @@
+// Convenience function to load polyfills and return a promise when it's done.
+// If there are no polyfills, then this is just Promise.resolve() which means
+// it will execute in the same tick of the event loop (i.e. near-instant).
+
+function importBasePolyfills() {
+ return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
+}
+
+function importExtraPolyfills() {
+ return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
+}
+
+function loadPolyfills() {
+ const needsBasePolyfills = !(
+ window.Intl &&
+ Object.assign &&
+ Number.isNaN &&
+ window.Symbol &&
+ Array.prototype.includes
+ );
+
+ // Latest version of Firefox and Safari do not have IntersectionObserver.
+ // Edge does not have requestIdleCallback and object-fit CSS property.
+ // This avoids shipping them all the polyfills.
+ const needsExtraPolyfills = !(
+ window.IntersectionObserver &&
+ window.IntersectionObserverEntry &&
+ 'isIntersecting' in IntersectionObserverEntry.prototype &&
+ window.requestIdleCallback &&
+ 'object-fit' in (new Image()).style
+ );
+
+ return Promise.all([
+ needsBasePolyfills && importBasePolyfills(),
+ needsExtraPolyfills && importExtraPolyfills(),
+ ]);
+}
+
+export default loadPolyfills;
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index ebb514e69..f400b283f 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -755,19 +755,6 @@
],
"path": "app/javascript/mastodon/features/compose/index.json"
},
- {
- "descriptors": [
- {
- "defaultMessage": "Direct messages",
- "id": "column.direct"
- },
- {
- "defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
- "id": "empty_column.direct"
- }
- ],
- "path": "app/javascript/mastodon/features/direct_timeline/index.json"
- },
{
"descriptors": [
{
@@ -829,10 +816,6 @@
"defaultMessage": "Local timeline",
"id": "navigation_bar.community_timeline"
},
- {
- "defaultMessage": "Direct messages",
- "id": "navigation_bar.direct"
- },
{
"defaultMessage": "Preferences",
"id": "navigation_bar.preferences"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index efe0e1de9..1d0bbcee5 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -28,7 +28,6 @@
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
- "column.direct": "Direct messages",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.home": "Home",
@@ -81,7 +80,6 @@
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
- "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
@@ -108,7 +106,6 @@
"missing_indicator.label": "Not found",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
- "navigation_bar.direct": "Direct messages",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Favourites",
"navigation_bar.follow_requests": "Follow requests",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
new file mode 100644
index 000000000..23b6b04fa
--- /dev/null
+++ b/app/javascript/mastodon/main.js
@@ -0,0 +1,34 @@
+import * as WebPushSubscription from './web_push_subscription';
+import Mastodon from './containers/mastodon';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ready from './ready';
+
+const perf = require('./performance');
+
+function main() {
+ perf.start('main()');
+
+ if (window.history && history.replaceState) {
+ const { pathname, search, hash } = window.location;
+ const path = pathname + search + hash;
+ if (!(/^\/web[$/]/).test(path)) {
+ history.replaceState(null, document.title, `/web${path}`);
+ }
+ }
+
+ ready(() => {
+ const mountNode = document.getElementById('mastodon');
+ const props = JSON.parse(mountNode.getAttribute('data-props'));
+
+ ReactDOM.render( , mountNode);
+ if (process.env.NODE_ENV === 'production') {
+ // avoid offline in dev mode because it's harder to debug
+ require('offline-plugin/runtime').install();
+ WebPushSubscription.register();
+ }
+ perf.stop('main()');
+ });
+}
+
+export default main;
diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js
new file mode 100644
index 000000000..b2c5f0898
--- /dev/null
+++ b/app/javascript/mastodon/middleware/errors.js
@@ -0,0 +1,31 @@
+import { showAlert } from '../actions/alerts';
+
+const defaultFailSuffix = 'FAIL';
+
+export default function errorsMiddleware() {
+ return ({ dispatch }) => next => action => {
+ if (action.type && !action.skipAlert) {
+ const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
+
+ if (action.type.match(isFail)) {
+ if (action.error.response) {
+ const { data, status, statusText } = action.error.response;
+
+ let message = statusText;
+ let title = `${status}`;
+
+ if (data.error) {
+ message = data.error;
+ }
+
+ dispatch(showAlert(title, message));
+ } else {
+ console.error(action.error);
+ dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
+ }
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/mastodon/middleware/loading_bar.js b/app/javascript/mastodon/middleware/loading_bar.js
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/javascript/mastodon/middleware/loading_bar.js
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+ const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+ return ({ dispatch }) => next => (action) => {
+ if (action.type && !action.skipLoading) {
+ const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+ const isPending = new RegExp(`${PENDING}$`, 'g');
+ const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+ const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+ if (action.type.match(isPending)) {
+ dispatch(showLoading());
+ } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+ dispatch(hideLoading());
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/mastodon/middleware/sounds.js b/app/javascript/mastodon/middleware/sounds.js
new file mode 100644
index 000000000..3d1e3eaba
--- /dev/null
+++ b/app/javascript/mastodon/middleware/sounds.js
@@ -0,0 +1,46 @@
+const createAudio = sources => {
+ const audio = new Audio();
+ sources.forEach(({ type, src }) => {
+ const source = document.createElement('source');
+ source.type = type;
+ source.src = src;
+ audio.appendChild(source);
+ });
+ return audio;
+};
+
+const play = audio => {
+ if (!audio.paused) {
+ audio.pause();
+ if (typeof audio.fastSeek === 'function') {
+ audio.fastSeek(0);
+ } else {
+ audio.seek(0);
+ }
+ }
+
+ audio.play();
+};
+
+export default function soundsMiddleware() {
+ const soundCache = {
+ boop: createAudio([
+ {
+ src: '/sounds/boop.ogg',
+ type: 'audio/ogg',
+ },
+ {
+ src: '/sounds/boop.mp3',
+ type: 'audio/mpeg',
+ },
+ ]),
+ };
+
+ return () => next => action => {
+ if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+ play(soundCache[action.meta.sound]);
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js
new file mode 100644
index 000000000..450a90626
--- /dev/null
+++ b/app/javascript/mastodon/performance.js
@@ -0,0 +1,31 @@
+//
+// Tools for performance debugging, only enabled in development mode.
+// Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
+// Also see config/webpack/loaders/mark.js for the webpack loader marks.
+//
+
+let marky;
+
+if (process.env.NODE_ENV === 'development') {
+ if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
+ // Increase Firefox's performance entry limit; otherwise it's capped to 150.
+ // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
+ performance.setResourceTimingBufferSize(Infinity);
+ }
+ marky = require('marky');
+ // allows us to easily do e.g. ReactPerf.printWasted() while debugging
+ //window.ReactPerf = require('react-addons-perf');
+ //window.ReactPerf.start();
+}
+
+export function start(name) {
+ if (process.env.NODE_ENV === 'development') {
+ marky.mark(name);
+ }
+}
+
+export function stop(name) {
+ if (process.env.NODE_ENV === 'development') {
+ marky.stop(name);
+ }
+}
diff --git a/app/javascript/mastodon/ready.js b/app/javascript/mastodon/ready.js
new file mode 100644
index 000000000..dd543910b
--- /dev/null
+++ b/app/javascript/mastodon/ready.js
@@ -0,0 +1,7 @@
+export default function ready(loaded) {
+ if (['interactive', 'complete'].includes(document.readyState)) {
+ loaded();
+ } else {
+ document.addEventListener('DOMContentLoaded', loaded);
+ }
+}
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
new file mode 100644
index 000000000..8a4d69f26
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -0,0 +1,135 @@
+import {
+ ACCOUNT_FETCH_SUCCESS,
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+} from '../actions/accounts';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from '../actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+ REBLOG_SUCCESS,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from '../actions/interactions';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_EXPAND_SUCCESS,
+} from '../actions/timelines';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from '../actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import emojify from '../features/emoji/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const normalizeAccount = (state, account) => {
+ account = { ...account };
+
+ delete account.followers_count;
+ delete account.following_count;
+ delete account.statuses_count;
+
+ const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+ account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+ account.note_emojified = emojify(account.note);
+
+ return state.set(account.id, fromJS(account));
+};
+
+const normalizeAccounts = (state, accounts) => {
+ accounts.forEach(account => {
+ state = normalizeAccount(state, account);
+ });
+
+ return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+ state = normalizeAccount(state, status.account);
+
+ if (status.reblog && status.reblog.account) {
+ state = normalizeAccount(state, status.reblog.account);
+ }
+
+ return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeAccountFromStatus(state, status);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accounts(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('accounts'));
+ case ACCOUNT_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeAccount(state, action.account);
+ case FOLLOWERS_FETCH_SUCCESS:
+ case FOLLOWERS_EXPAND_SUCCESS:
+ case FOLLOWING_FETCH_SUCCESS:
+ case FOLLOWING_EXPAND_SUCCESS:
+ case REBLOGS_FETCH_SUCCESS:
+ case FAVOURITES_FETCH_SUCCESS:
+ case COMPOSE_SUGGESTIONS_READY:
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ case BLOCKS_FETCH_SUCCESS:
+ case BLOCKS_EXPAND_SUCCESS:
+ case MUTES_FETCH_SUCCESS:
+ case MUTES_EXPAND_SUCCESS:
+ return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return normalizeAccountsFromStatuses(state, action.statuses);
+ case REBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ return normalizeAccountFromStatus(state, action.response);
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ return normalizeAccountFromStatus(state, action.status);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
new file mode 100644
index 000000000..1ed0fe3e3
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -0,0 +1,135 @@
+import {
+ ACCOUNT_FETCH_SUCCESS,
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ ACCOUNT_FOLLOW_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+} from '../actions/accounts';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from '../actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+ REBLOG_SUCCESS,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from '../actions/interactions';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_EXPAND_SUCCESS,
+} from '../actions/timelines';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from '../actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, fromJS({
+ followers_count: account.followers_count,
+ following_count: account.following_count,
+ statuses_count: account.statuses_count,
+}));
+
+const normalizeAccounts = (state, accounts) => {
+ accounts.forEach(account => {
+ state = normalizeAccount(state, account);
+ });
+
+ return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+ state = normalizeAccount(state, status.account);
+
+ if (status.reblog && status.reblog.account) {
+ state = normalizeAccount(state, status.reblog.account);
+ }
+
+ return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeAccountFromStatus(state, status);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function accountsCounters(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('accounts').map(item => fromJS({
+ followers_count: item.get('followers_count'),
+ following_count: item.get('following_count'),
+ statuses_count: item.get('statuses_count'),
+ })));
+ case ACCOUNT_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeAccount(state, action.account);
+ case FOLLOWERS_FETCH_SUCCESS:
+ case FOLLOWERS_EXPAND_SUCCESS:
+ case FOLLOWING_FETCH_SUCCESS:
+ case FOLLOWING_EXPAND_SUCCESS:
+ case REBLOGS_FETCH_SUCCESS:
+ case FAVOURITES_FETCH_SUCCESS:
+ case COMPOSE_SUGGESTIONS_READY:
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ case BLOCKS_FETCH_SUCCESS:
+ case BLOCKS_EXPAND_SUCCESS:
+ case MUTES_FETCH_SUCCESS:
+ case MUTES_EXPAND_SUCCESS:
+ return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return normalizeAccountsFromStatuses(state, action.statuses);
+ case REBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ return normalizeAccountFromStatus(state, action.response);
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ return normalizeAccountFromStatus(state, action.status);
+ case ACCOUNT_FOLLOW_SUCCESS:
+ return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
new file mode 100644
index 000000000..089d920c3
--- /dev/null
+++ b/app/javascript/mastodon/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+ ALERT_SHOW,
+ ALERT_DISMISS,
+ ALERT_CLEAR,
+} from '../actions/alerts';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableList([]);
+
+export default function alerts(state = initialState, action) {
+ switch(action.type) {
+ case ALERT_SHOW:
+ return state.push(ImmutableMap({
+ key: state.size > 0 ? state.last().get('key') + 1 : 0,
+ title: action.title,
+ message: action.message,
+ }));
+ case ALERT_DISMISS:
+ return state.filterNot(item => item.get('key') === action.alert.key);
+ case ALERT_CLEAR:
+ return state.clear();
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js
new file mode 100644
index 000000000..4d86b0d7e
--- /dev/null
+++ b/app/javascript/mastodon/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function cards(state = initialState, action) {
+ switch(action.type) {
+ case STATUS_CARD_FETCH_SUCCESS:
+ return state.set(action.id, fromJS(action.card));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
new file mode 100644
index 000000000..c709fb88c
--- /dev/null
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -0,0 +1,276 @@
+import {
+ COMPOSE_MOUNT,
+ COMPOSE_UNMOUNT,
+ COMPOSE_CHANGE,
+ COMPOSE_REPLY,
+ COMPOSE_REPLY_CANCEL,
+ COMPOSE_MENTION,
+ COMPOSE_SUBMIT_REQUEST,
+ COMPOSE_SUBMIT_SUCCESS,
+ COMPOSE_SUBMIT_FAIL,
+ COMPOSE_UPLOAD_REQUEST,
+ COMPOSE_UPLOAD_SUCCESS,
+ COMPOSE_UPLOAD_FAIL,
+ COMPOSE_UPLOAD_UNDO,
+ COMPOSE_UPLOAD_PROGRESS,
+ COMPOSE_SUGGESTIONS_CLEAR,
+ COMPOSE_SUGGESTIONS_READY,
+ COMPOSE_SUGGESTION_SELECT,
+ COMPOSE_SENSITIVITY_CHANGE,
+ COMPOSE_SPOILERNESS_CHANGE,
+ COMPOSE_SPOILER_TEXT_CHANGE,
+ COMPOSE_VISIBILITY_CHANGE,
+ COMPOSE_COMPOSING_CHANGE,
+ COMPOSE_EMOJI_INSERT,
+ COMPOSE_UPLOAD_CHANGE_REQUEST,
+ COMPOSE_UPLOAD_CHANGE_SUCCESS,
+ COMPOSE_UPLOAD_CHANGE_FAIL,
+ COMPOSE_RESET,
+} from '../actions/compose';
+import { TIMELINE_DELETE } from '../actions/timelines';
+import { STORE_HYDRATE } from '../actions/store';
+import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import uuid from '../uuid';
+import { me } from '../initial_state';
+
+const initialState = ImmutableMap({
+ mounted: false,
+ sensitive: false,
+ spoiler: false,
+ spoiler_text: '',
+ privacy: null,
+ text: '',
+ focusDate: null,
+ preselectDate: null,
+ in_reply_to: null,
+ is_composing: false,
+ is_submitting: false,
+ is_uploading: false,
+ progress: 0,
+ media_attachments: ImmutableList(),
+ suggestion_token: null,
+ suggestions: ImmutableList(),
+ default_privacy: 'public',
+ default_sensitive: false,
+ resetFileKey: Math.floor((Math.random() * 0x10000)),
+ idempotencyKey: null,
+});
+
+function statusToTextMentions(state, status) {
+ let set = ImmutableOrderedSet([]);
+
+ if (status.getIn(['account', 'id']) !== me) {
+ set = set.add(`@${status.getIn(['account', 'acct'])} `);
+ }
+
+ return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
+};
+
+function clearAll(state) {
+ return state.withMutations(map => {
+ map.set('text', '');
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ map.set('is_submitting', false);
+ map.set('in_reply_to', null);
+ map.set('privacy', state.get('default_privacy'));
+ map.set('sensitive', false);
+ map.update('media_attachments', list => list.clear());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+function appendMedia(state, media) {
+ const prevSize = state.get('media_attachments').size;
+
+ return state.withMutations(map => {
+ map.update('media_attachments', list => list.push(media));
+ map.set('is_uploading', false);
+ map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+ map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
+ map.set('sensitive', true);
+ }
+ });
+};
+
+function removeMedia(state, mediaId) {
+ const media = state.get('media_attachments').find(item => item.get('id') === mediaId);
+ const prevSize = state.get('media_attachments').size;
+
+ return state.withMutations(map => {
+ map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
+ map.update('text', text => text.replace(media.get('text_url'), '').trim());
+ map.set('idempotencyKey', uuid());
+
+ if (prevSize === 1) {
+ map.set('sensitive', false);
+ }
+ });
+};
+
+const insertSuggestion = (state, position, token, completion) => {
+ return state.withMutations(map => {
+ map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
+ map.set('suggestion_token', null);
+ map.update('suggestions', ImmutableList(), list => list.clear());
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+ const emoji = emojiData.native;
+
+ return state.withMutations(map => {
+ map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+ map.set('focusDate', new Date());
+ map.set('idempotencyKey', uuid());
+ });
+};
+
+const privacyPreference = (a, b) => {
+ if (a === 'direct' || b === 'direct') {
+ return 'direct';
+ } else if (a === 'private' || b === 'private') {
+ return 'private';
+ } else if (a === 'unlisted' || b === 'unlisted') {
+ return 'unlisted';
+ } else {
+ return 'public';
+ }
+};
+
+const hydrate = (state, hydratedState) => {
+ state = clearAll(state.merge(hydratedState));
+
+ if (hydratedState.has('text')) {
+ state = state.set('text', hydratedState.get('text'));
+ }
+
+ return state;
+};
+
+export default function compose(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return hydrate(state, action.state.get('compose'));
+ case COMPOSE_MOUNT:
+ return state.set('mounted', true);
+ case COMPOSE_UNMOUNT:
+ return state
+ .set('mounted', false)
+ .set('is_composing', false);
+ case COMPOSE_SENSITIVITY_CHANGE:
+ return state.withMutations(map => {
+ if (!state.get('spoiler')) {
+ map.set('sensitive', !state.get('sensitive'));
+ }
+
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_SPOILERNESS_CHANGE:
+ return state.withMutations(map => {
+ map.set('spoiler_text', '');
+ map.set('spoiler', !state.get('spoiler'));
+ map.set('idempotencyKey', uuid());
+
+ if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
+ map.set('sensitive', true);
+ }
+ });
+ case COMPOSE_SPOILER_TEXT_CHANGE:
+ return state
+ .set('spoiler_text', action.text)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_VISIBILITY_CHANGE:
+ return state
+ .set('privacy', action.value)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_CHANGE:
+ return state
+ .set('text', action.text)
+ .set('idempotencyKey', uuid());
+ case COMPOSE_COMPOSING_CHANGE:
+ return state.set('is_composing', action.value);
+ case COMPOSE_REPLY:
+ return state.withMutations(map => {
+ map.set('in_reply_to', action.status.get('id'));
+ map.set('text', statusToTextMentions(state, action.status));
+ map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+ map.set('focusDate', new Date());
+ map.set('preselectDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.status.get('spoiler_text'));
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+ });
+ case COMPOSE_REPLY_CANCEL:
+ case COMPOSE_RESET:
+ return state.withMutations(map => {
+ map.set('in_reply_to', null);
+ map.set('text', '');
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ map.set('privacy', state.get('default_privacy'));
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_SUBMIT_REQUEST:
+ case COMPOSE_UPLOAD_CHANGE_REQUEST:
+ return state.set('is_submitting', true);
+ case COMPOSE_SUBMIT_SUCCESS:
+ return clearAll(state);
+ case COMPOSE_SUBMIT_FAIL:
+ case COMPOSE_UPLOAD_CHANGE_FAIL:
+ return state.set('is_submitting', false);
+ case COMPOSE_UPLOAD_REQUEST:
+ return state.set('is_uploading', true);
+ case COMPOSE_UPLOAD_SUCCESS:
+ return appendMedia(state, fromJS(action.media));
+ case COMPOSE_UPLOAD_FAIL:
+ return state.set('is_uploading', false);
+ case COMPOSE_UPLOAD_UNDO:
+ return removeMedia(state, action.media_id);
+ case COMPOSE_UPLOAD_PROGRESS:
+ return state.set('progress', Math.round((action.loaded / action.total) * 100));
+ case COMPOSE_MENTION:
+ return state
+ .update('text', text => `${text}@${action.account.get('acct')} `)
+ .set('focusDate', new Date())
+ .set('idempotencyKey', uuid());
+ case COMPOSE_SUGGESTIONS_CLEAR:
+ return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
+ case COMPOSE_SUGGESTIONS_READY:
+ return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
+ case COMPOSE_SUGGESTION_SELECT:
+ return insertSuggestion(state, action.position, action.token, action.completion);
+ case TIMELINE_DELETE:
+ if (action.id === state.get('in_reply_to')) {
+ return state.set('in_reply_to', null);
+ } else {
+ return state;
+ }
+ case COMPOSE_EMOJI_INSERT:
+ return insertEmoji(state, action.position, action.emoji);
+ case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+ return state
+ .set('is_submitting', false)
+ .update('media_attachments', list => list.map(item => {
+ if (item.get('id') === action.media.id) {
+ return item.set('description', action.media.description);
+ }
+
+ return item;
+ }));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
new file mode 100644
index 000000000..64d584a01
--- /dev/null
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -0,0 +1,61 @@
+import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ ancestors: ImmutableMap(),
+ descendants: ImmutableMap(),
+});
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+ const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id));
+ const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+
+ return state.withMutations(map => {
+ map.setIn(['ancestors', id], ancestorsIds);
+ map.setIn(['descendants', id], descendantsIds);
+ });
+};
+
+const deleteFromContexts = (state, id) => {
+ state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
+ state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+ });
+
+ state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
+ state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
+ });
+
+ state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+
+ return state;
+};
+
+const updateContext = (state, status, references) => {
+ return state.update('descendants', map => {
+ references.forEach(parentId => {
+ map = map.update(parentId, ImmutableList(), list => {
+ if (list.includes(status.id)) {
+ return list;
+ }
+
+ return list.push(status.id);
+ });
+ });
+
+ return map;
+ });
+};
+
+export default function contexts(state = initialState, action) {
+ switch(action.type) {
+ case CONTEXT_FETCH_SUCCESS:
+ return normalizeContext(state, action.id, action.ancestors, action.descendants);
+ case TIMELINE_DELETE:
+ return deleteFromContexts(state, action.id);
+ case TIMELINE_CONTEXT_UPDATE:
+ return updateContext(state, action.status, action.references);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
new file mode 100644
index 000000000..307bcc7dc
--- /dev/null
+++ b/app/javascript/mastodon/reducers/custom_emojis.js
@@ -0,0 +1,16 @@
+import { List as ImmutableList } from 'immutable';
+import { STORE_HYDRATE } from '../actions/store';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from '../features/emoji/emoji';
+
+const initialState = ImmutableList();
+
+export default function custom_emojis(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+ return action.state.get('custom_emojis');
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/height_cache.js b/app/javascript/mastodon/reducers/height_cache.js
new file mode 100644
index 000000000..2f5716fae
--- /dev/null
+++ b/app/javascript/mastodon/reducers/height_cache.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from '../actions/height_cache';
+
+const initialState = ImmutableMap();
+
+const setHeight = (state, key, id, height) => {
+ return state.update(key, ImmutableMap(), map => map.set(id, height));
+};
+
+const clearHeights = () => {
+ return ImmutableMap();
+};
+
+export default function statuses(state = initialState, action) {
+ switch(action.type) {
+ case HEIGHT_CACHE_SET:
+ return setHeight(state, action.key, action.id, action.height);
+ case HEIGHT_CACHE_CLEAR:
+ return clearHeights();
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
new file mode 100644
index 000000000..17c870351
--- /dev/null
+++ b/app/javascript/mastodon/reducers/index.js
@@ -0,0 +1,52 @@
+import { combineReducers } from 'redux-immutable';
+import timelines from './timelines';
+import meta from './meta';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import settings from './settings';
+import push_notifications from './push_notifications';
+import status_lists from './status_lists';
+import cards from './cards';
+import mutes from './mutes';
+import reports from './reports';
+import contexts from './contexts';
+import compose from './compose';
+import search from './search';
+import media_attachments from './media_attachments';
+import notifications from './notifications';
+import height_cache from './height_cache';
+import custom_emojis from './custom_emojis';
+
+const reducers = {
+ timelines,
+ meta,
+ alerts,
+ loadingBar: loadingBarReducer,
+ modal,
+ user_lists,
+ status_lists,
+ accounts,
+ accounts_counters,
+ statuses,
+ relationships,
+ settings,
+ push_notifications,
+ cards,
+ mutes,
+ reports,
+ contexts,
+ compose,
+ search,
+ media_attachments,
+ notifications,
+ height_cache,
+ custom_emojis,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/media_attachments.js b/app/javascript/mastodon/reducers/media_attachments.js
new file mode 100644
index 000000000..24119f628
--- /dev/null
+++ b/app/javascript/mastodon/reducers/media_attachments.js
@@ -0,0 +1,15 @@
+import { STORE_HYDRATE } from '../actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+ accept_content_types: [],
+});
+
+export default function meta(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('media_attachments'));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
new file mode 100644
index 000000000..36a5a1c35
--- /dev/null
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -0,0 +1,16 @@
+import { STORE_HYDRATE } from '../actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+ streaming_api_base_url: null,
+ access_token: null,
+});
+
+export default function meta(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return state.merge(action.state.get('meta'));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
new file mode 100644
index 000000000..599a2443e
--- /dev/null
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -0,0 +1,17 @@
+import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
+
+const initialState = {
+ modalType: null,
+ modalProps: {},
+};
+
+export default function modal(state = initialState, action) {
+ switch(action.type) {
+ case MODAL_OPEN:
+ return { modalType: action.modalType, modalProps: action.modalProps };
+ case MODAL_CLOSE:
+ return initialState;
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js
new file mode 100644
index 000000000..a96232dbd
--- /dev/null
+++ b/app/javascript/mastodon/reducers/mutes.js
@@ -0,0 +1,29 @@
+import Immutable from 'immutable';
+
+import {
+ MUTES_INIT_MODAL,
+ MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+} from '../actions/mutes';
+
+const initialState = Immutable.Map({
+ new: Immutable.Map({
+ isSubmitting: false,
+ account: null,
+ notifications: true,
+ }),
+});
+
+export default function mutes(state = initialState, action) {
+ switch (action.type) {
+ case MUTES_INIT_MODAL:
+ return state.withMutations((state) => {
+ state.setIn(['new', 'isSubmitting'], false);
+ state.setIn(['new', 'account'], action.account);
+ state.setIn(['new', 'notifications'], true);
+ });
+ case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+ return state.updateIn(['new', 'notifications'], (old) => !old);
+ default:
+ return state;
+ }
+}
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
new file mode 100644
index 000000000..264db4f55
--- /dev/null
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -0,0 +1,124 @@
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+ NOTIFICATIONS_REFRESH_REQUEST,
+ NOTIFICATIONS_EXPAND_REQUEST,
+ NOTIFICATIONS_REFRESH_FAIL,
+ NOTIFICATIONS_EXPAND_FAIL,
+ NOTIFICATIONS_CLEAR,
+ NOTIFICATIONS_SCROLL_TOP,
+} from '../actions/notifications';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
+import { TIMELINE_DELETE } from '../actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ next: null,
+ top: true,
+ unread: 0,
+ loaded: false,
+ isLoading: true,
+});
+
+const notificationToMap = notification => ImmutableMap({
+ id: notification.id,
+ type: notification.type,
+ account: notification.account.id,
+ status: notification.status ? notification.status.id : null,
+});
+
+const normalizeNotification = (state, notification) => {
+ const top = state.get('top');
+
+ if (!top) {
+ state = state.update('unread', unread => unread + 1);
+ }
+
+ return state.update('items', list => {
+ if (top && list.size > 40) {
+ list = list.take(20);
+ }
+
+ return list.unshift(notificationToMap(notification));
+ });
+};
+
+const normalizeNotifications = (state, notifications, next) => {
+ let items = ImmutableList();
+ const loaded = state.get('loaded');
+
+ notifications.forEach((n, i) => {
+ items = items.set(i, notificationToMap(n));
+ });
+
+ if (state.get('next') === null) {
+ state = state.set('next', next);
+ }
+
+ return state
+ .update('items', list => loaded ? items.concat(list) : list.concat(items))
+ .set('loaded', true)
+ .set('isLoading', false);
+};
+
+const appendNormalizedNotifications = (state, notifications, next) => {
+ let items = ImmutableList();
+
+ notifications.forEach((n, i) => {
+ items = items.set(i, notificationToMap(n));
+ });
+
+ return state
+ .update('items', list => list.concat(items))
+ .set('next', next)
+ .set('isLoading', false);
+};
+
+const filterNotifications = (state, relationship) => {
+ return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
+const updateTop = (state, top) => {
+ if (top) {
+ state = state.set('unread', 0);
+ }
+
+ return state.set('top', top);
+};
+
+const deleteByStatus = (state, statusId) => {
+ return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+};
+
+export default function notifications(state = initialState, action) {
+ switch(action.type) {
+ case NOTIFICATIONS_REFRESH_REQUEST:
+ case NOTIFICATIONS_EXPAND_REQUEST:
+ return state.set('isLoading', true);
+ case NOTIFICATIONS_REFRESH_FAIL:
+ case NOTIFICATIONS_EXPAND_FAIL:
+ return state.set('isLoading', false);
+ case NOTIFICATIONS_SCROLL_TOP:
+ return updateTop(state, action.top);
+ case NOTIFICATIONS_UPDATE:
+ return normalizeNotification(state, action.notification);
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ return normalizeNotifications(state, action.notifications, action.next);
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ return appendNormalizedNotifications(state, action.notifications, action.next);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterNotifications(state, action.relationship);
+ case NOTIFICATIONS_CLEAR:
+ return state.set('items', ImmutableList()).set('next', null);
+ case TIMELINE_DELETE:
+ return deleteByStatus(state, action.id);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
new file mode 100644
index 000000000..31a40d246
--- /dev/null
+++ b/app/javascript/mastodon/reducers/push_notifications.js
@@ -0,0 +1,51 @@
+import { STORE_HYDRATE } from '../actions/store';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+ subscription: null,
+ alerts: new Immutable.Map({
+ follow: false,
+ favourite: false,
+ reblog: false,
+ mention: false,
+ }),
+ isSubscribed: false,
+ browserSupport: false,
+});
+
+export default function push_subscriptions(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE: {
+ const push_subscription = action.state.get('push_subscription');
+
+ if (push_subscription) {
+ return state
+ .set('subscription', new Immutable.Map({
+ id: push_subscription.get('id'),
+ endpoint: push_subscription.get('endpoint'),
+ }))
+ .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
+ .set('isSubscribed', true);
+ }
+
+ return state;
+ }
+ case SET_SUBSCRIPTION:
+ return state
+ .set('subscription', new Immutable.Map({
+ id: action.subscription.id,
+ endpoint: action.subscription.endpoint,
+ }))
+ .set('alerts', new Immutable.Map(action.subscription.alerts))
+ .set('isSubscribed', true);
+ case SET_BROWSER_SUPPORT:
+ return state.set('browserSupport', action.value);
+ case CLEAR_SUBSCRIPTION:
+ return initialState;
+ case ALERTS_CHANGE:
+ return state.setIn(action.key, action.value);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
new file mode 100644
index 000000000..c7b04a668
--- /dev/null
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -0,0 +1,46 @@
+import {
+ ACCOUNT_FOLLOW_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_UNBLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+ ACCOUNT_UNMUTE_SUCCESS,
+ RELATIONSHIPS_FETCH_SUCCESS,
+} from '../actions/accounts';
+import {
+ DOMAIN_BLOCK_SUCCESS,
+ DOMAIN_UNBLOCK_SUCCESS,
+} from '../actions/domain_blocks';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+ relationships.forEach(relationship => {
+ state = normalizeRelationship(state, relationship);
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function relationships(state = initialState, action) {
+ switch(action.type) {
+ case ACCOUNT_FOLLOW_SUCCESS:
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_UNBLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ case ACCOUNT_UNMUTE_SUCCESS:
+ return normalizeRelationship(state, action.relationship);
+ case RELATIONSHIPS_FETCH_SUCCESS:
+ return normalizeRelationships(state, action.relationships);
+ case DOMAIN_BLOCK_SUCCESS:
+ return state.setIn([action.accountId, 'domain_blocking'], true);
+ case DOMAIN_UNBLOCK_SUCCESS:
+ return state.setIn([action.accountId, 'domain_blocking'], false);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
new file mode 100644
index 000000000..a08bbec38
--- /dev/null
+++ b/app/javascript/mastodon/reducers/reports.js
@@ -0,0 +1,60 @@
+import {
+ REPORT_INIT,
+ REPORT_SUBMIT_REQUEST,
+ REPORT_SUBMIT_SUCCESS,
+ REPORT_SUBMIT_FAIL,
+ REPORT_CANCEL,
+ REPORT_STATUS_TOGGLE,
+ REPORT_COMMENT_CHANGE,
+} from '../actions/reports';
+import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
+
+const initialState = ImmutableMap({
+ new: ImmutableMap({
+ isSubmitting: false,
+ account_id: null,
+ status_ids: ImmutableSet(),
+ comment: '',
+ }),
+});
+
+export default function reports(state = initialState, action) {
+ switch(action.type) {
+ case REPORT_INIT:
+ return state.withMutations(map => {
+ map.setIn(['new', 'isSubmitting'], false);
+ map.setIn(['new', 'account_id'], action.account.get('id'));
+
+ if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+ map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
+ map.setIn(['new', 'comment'], '');
+ } else if (action.status) {
+ map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+ }
+ });
+ case REPORT_STATUS_TOGGLE:
+ return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => {
+ if (action.checked) {
+ return set.add(action.statusId);
+ }
+
+ return set.remove(action.statusId);
+ });
+ case REPORT_COMMENT_CHANGE:
+ return state.setIn(['new', 'comment'], action.comment);
+ case REPORT_SUBMIT_REQUEST:
+ return state.setIn(['new', 'isSubmitting'], true);
+ case REPORT_SUBMIT_FAIL:
+ return state.setIn(['new', 'isSubmitting'], false);
+ case REPORT_CANCEL:
+ case REPORT_SUBMIT_SUCCESS:
+ return state.withMutations(map => {
+ map.setIn(['new', 'account_id'], null);
+ map.setIn(['new', 'status_ids'], ImmutableSet());
+ map.setIn(['new', 'comment'], '');
+ map.setIn(['new', 'isSubmitting'], false);
+ });
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
new file mode 100644
index 000000000..08d90e4e8
--- /dev/null
+++ b/app/javascript/mastodon/reducers/search.js
@@ -0,0 +1,42 @@
+import {
+ SEARCH_CHANGE,
+ SEARCH_CLEAR,
+ SEARCH_FETCH_SUCCESS,
+ SEARCH_SHOW,
+} from '../actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ value: '',
+ submitted: false,
+ hidden: false,
+ results: ImmutableMap(),
+});
+
+export default function search(state = initialState, action) {
+ switch(action.type) {
+ case SEARCH_CHANGE:
+ return state.set('value', action.value);
+ case SEARCH_CLEAR:
+ return state.withMutations(map => {
+ map.set('value', '');
+ map.set('results', ImmutableMap());
+ map.set('submitted', false);
+ map.set('hidden', false);
+ });
+ case SEARCH_SHOW:
+ return state.set('hidden', false);
+ case COMPOSE_REPLY:
+ case COMPOSE_MENTION:
+ return state.set('hidden', true);
+ case SEARCH_FETCH_SUCCESS:
+ return state.set('results', ImmutableMap({
+ accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+ statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+ hashtags: ImmutableList(action.results.hashtags),
+ })).set('submitted', true);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
new file mode 100644
index 000000000..a9f3f9529
--- /dev/null
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -0,0 +1,112 @@
+import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
+import { STORE_HYDRATE } from '../actions/store';
+import { EMOJI_USE } from '../actions/emojis';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import uuid from '../uuid';
+
+const initialState = ImmutableMap({
+ saved: true,
+
+ onboarded: false,
+
+ skinTone: 1,
+
+ home: ImmutableMap({
+ shows: ImmutableMap({
+ reblog: true,
+ reply: true,
+ }),
+
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ notifications: ImmutableMap({
+ alerts: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+
+ shows: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+
+ sounds: ImmutableMap({
+ follow: true,
+ favourite: true,
+ reblog: true,
+ mention: true,
+ }),
+ }),
+
+ community: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ public: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+});
+
+const defaultColumns = fromJS([
+ { id: 'COMPOSE', uuid: uuid(), params: {} },
+ { id: 'HOME', uuid: uuid(), params: {} },
+ { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
+]);
+
+const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
+
+const moveColumn = (state, uuid, direction) => {
+ const columns = state.get('columns');
+ const index = columns.findIndex(item => item.get('uuid') === uuid);
+ const newIndex = index + direction;
+
+ let newColumns;
+
+ newColumns = columns.splice(index, 1);
+ newColumns = newColumns.splice(newIndex, 0, columns.get(index));
+
+ return state
+ .set('columns', newColumns)
+ .set('saved', false);
+};
+
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
+export default function settings(state = initialState, action) {
+ switch(action.type) {
+ case STORE_HYDRATE:
+ return hydrate(state, action.state.get('settings'));
+ case SETTING_CHANGE:
+ return state
+ .setIn(action.key, action.value)
+ .set('saved', false);
+ case COLUMN_ADD:
+ return state
+ .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+ .set('saved', false);
+ case COLUMN_REMOVE:
+ return state
+ .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+ .set('saved', false);
+ case COLUMN_MOVE:
+ return moveColumn(state, action.uuid, action.direction);
+ case EMOJI_USE:
+ return updateFrequentEmojis(state, action.emoji);
+ case SETTING_SAVE:
+ return state.set('saved', true);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
new file mode 100644
index 000000000..c4aeb338f
--- /dev/null
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -0,0 +1,75 @@
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from '../actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
+} from '../actions/interactions';
+
+const initialState = ImmutableMap({
+ favourites: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
+ pins: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('loaded', true);
+ map.set('items', ImmutableList(statuses.map(item => item.id)));
+ }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('next', next);
+ map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+ }));
+};
+
+const prependOneToList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').unshift(status.get('id')));
+ }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').filter(item => item !== status.get('id')));
+ }));
+};
+
+export default function statusLists(state = initialState, action) {
+ switch(action.type) {
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ return appendToList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITE_SUCCESS:
+ return prependOneToList(state, 'favourites', action.status);
+ case UNFAVOURITE_SUCCESS:
+ return removeOneFromList(state, 'favourites', action.status);
+ case PINNED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'pins', action.statuses, action.next);
+ case PIN_SUCCESS:
+ return prependOneToList(state, 'pins', action.status);
+ case UNPIN_SUCCESS:
+ return removeOneFromList(state, 'pins', action.status);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
new file mode 100644
index 000000000..b1fb4c5da
--- /dev/null
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -0,0 +1,148 @@
+import {
+ REBLOG_REQUEST,
+ REBLOG_SUCCESS,
+ REBLOG_FAIL,
+ UNREBLOG_SUCCESS,
+ FAVOURITE_REQUEST,
+ FAVOURITE_SUCCESS,
+ FAVOURITE_FAIL,
+ UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
+} from '../actions/interactions';
+import {
+ STATUS_FETCH_SUCCESS,
+ CONTEXT_FETCH_SUCCESS,
+ STATUS_MUTE_SUCCESS,
+ STATUS_UNMUTE_SUCCESS,
+} from '../actions/statuses';
+import {
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_UPDATE,
+ TIMELINE_DELETE,
+ TIMELINE_EXPAND_SUCCESS,
+} from '../actions/timelines';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
+import {
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_REFRESH_SUCCESS,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+} from '../actions/notifications';
+import {
+ FAVOURITED_STATUSES_FETCH_SUCCESS,
+ FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from '../actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import emojify from '../features/emoji/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
+
+const normalizeStatus = (state, status) => {
+ if (!status) {
+ return state;
+ }
+
+ const normalStatus = { ...status };
+ normalStatus.account = status.account.id;
+
+ if (status.reblog && status.reblog.id) {
+ state = normalizeStatus(state, status.reblog);
+ normalStatus.reblog = status.reblog.id;
+ }
+
+ const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/ /g, '\n').replace(/<\/p>/g, '\n\n');
+
+ const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
+ obj[`:${emoji.shortcode}:`] = emoji;
+ return obj;
+ }, {});
+
+ normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
+ normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
+
+ return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
+};
+
+const normalizeStatuses = (state, statuses) => {
+ statuses.forEach(status => {
+ state = normalizeStatus(state, status);
+ });
+
+ return state;
+};
+
+const deleteStatus = (state, id, references) => {
+ references.forEach(ref => {
+ state = deleteStatus(state, ref[0], []);
+ });
+
+ return state.delete(id);
+};
+
+const filterStatuses = (state, relationship) => {
+ state.forEach(status => {
+ if (status.get('account') !== relationship.id) {
+ return;
+ }
+
+ state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+ });
+
+ return state;
+};
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+ switch(action.type) {
+ case TIMELINE_UPDATE:
+ case STATUS_FETCH_SUCCESS:
+ case NOTIFICATIONS_UPDATE:
+ return normalizeStatus(state, action.status);
+ case REBLOG_SUCCESS:
+ case UNREBLOG_SUCCESS:
+ case FAVOURITE_SUCCESS:
+ case UNFAVOURITE_SUCCESS:
+ case PIN_SUCCESS:
+ case UNPIN_SUCCESS:
+ return normalizeStatus(state, action.response);
+ case FAVOURITE_REQUEST:
+ return state.setIn([action.status.get('id'), 'favourited'], true);
+ case FAVOURITE_FAIL:
+ return state.setIn([action.status.get('id'), 'favourited'], false);
+ case REBLOG_REQUEST:
+ return state.setIn([action.status.get('id'), 'reblogged'], true);
+ case REBLOG_FAIL:
+ return state.setIn([action.status.get('id'), 'reblogged'], false);
+ case STATUS_MUTE_SUCCESS:
+ return state.setIn([action.id, 'muted'], true);
+ case STATUS_UNMUTE_SUCCESS:
+ return state.setIn([action.id, 'muted'], false);
+ case TIMELINE_REFRESH_SUCCESS:
+ case TIMELINE_EXPAND_SUCCESS:
+ case CONTEXT_FETCH_SUCCESS:
+ case NOTIFICATIONS_REFRESH_SUCCESS:
+ case NOTIFICATIONS_EXPAND_SUCCESS:
+ case FAVOURITED_STATUSES_FETCH_SUCCESS:
+ case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ case PINNED_STATUSES_FETCH_SUCCESS:
+ case SEARCH_FETCH_SUCCESS:
+ return normalizeStatuses(state, action.statuses);
+ case TIMELINE_DELETE:
+ return deleteStatus(state, action.id, action.references);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterStatuses(state, action.relationship);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
new file mode 100644
index 000000000..bee4c4ef9
--- /dev/null
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -0,0 +1,149 @@
+import {
+ TIMELINE_REFRESH_REQUEST,
+ TIMELINE_REFRESH_SUCCESS,
+ TIMELINE_REFRESH_FAIL,
+ TIMELINE_UPDATE,
+ TIMELINE_DELETE,
+ TIMELINE_EXPAND_SUCCESS,
+ TIMELINE_EXPAND_REQUEST,
+ TIMELINE_EXPAND_FAIL,
+ TIMELINE_SCROLL_TOP,
+ TIMELINE_CONNECT,
+ TIMELINE_DISCONNECT,
+} from '../actions/timelines';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+ ACCOUNT_UNFOLLOW_SUCCESS,
+} from '../actions/accounts';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const initialTimeline = ImmutableMap({
+ unread: 0,
+ online: false,
+ top: true,
+ loaded: false,
+ isLoading: false,
+ next: false,
+ items: ImmutableList(),
+});
+
+const normalizeTimeline = (state, timeline, statuses, next) => {
+ const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+ const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+ const wasLoaded = state.getIn([timeline, 'loaded']);
+ const hadNext = state.getIn([timeline, 'next']);
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ mMap.set('loaded', true);
+ mMap.set('isLoading', false);
+ if (!hadNext) mMap.set('next', next);
+ mMap.set('items', wasLoaded ? ids.concat(oldIds) : ids);
+ }));
+};
+
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
+ const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+ const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ mMap.set('isLoading', false);
+ mMap.set('next', next);
+ mMap.set('items', oldIds.concat(ids));
+ }));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+ const top = state.getIn([timeline, 'top']);
+ const ids = state.getIn([timeline, 'items'], ImmutableList());
+ const includesId = ids.includes(status.get('id'));
+ const unread = state.getIn([timeline, 'unread'], 0);
+
+ if (includesId) {
+ return state;
+ }
+
+ let newIds = ids;
+
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ if (!top) mMap.set('unread', unread + 1);
+ if (top && ids.size > 40) newIds = newIds.take(20);
+ if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
+ mMap.set('items', newIds.unshift(status.get('id')));
+ }));
+};
+
+const deleteStatus = (state, id, accountId, references) => {
+ state.keySeq().forEach(timeline => {
+ state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
+ });
+
+ // Remove reblogs of deleted status
+ references.forEach(ref => {
+ state = deleteStatus(state, ref[0], ref[1], []);
+ });
+
+ return state;
+};
+
+const filterTimelines = (state, relationship, statuses) => {
+ let references;
+
+ statuses.forEach(status => {
+ if (status.get('account') !== relationship.id) {
+ return;
+ }
+
+ references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+ state = deleteStatus(state, status.get('id'), status.get('account'), references);
+ });
+
+ return state;
+};
+
+const filterTimeline = (timeline, state, relationship, statuses) =>
+ state.updateIn([timeline, 'items'], ImmutableList(), list =>
+ list.filterNot(statusId =>
+ statuses.getIn([statusId, 'account']) === relationship.id
+ ));
+
+const updateTop = (state, timeline, top) => {
+ return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+ if (top) mMap.set('unread', 0);
+ mMap.set('top', top);
+ }));
+};
+
+export default function timelines(state = initialState, action) {
+ switch(action.type) {
+ case TIMELINE_REFRESH_REQUEST:
+ case TIMELINE_EXPAND_REQUEST:
+ return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
+ case TIMELINE_REFRESH_FAIL:
+ case TIMELINE_EXPAND_FAIL:
+ return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
+ case TIMELINE_REFRESH_SUCCESS:
+ return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+ case TIMELINE_EXPAND_SUCCESS:
+ return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+ case TIMELINE_UPDATE:
+ return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
+ case TIMELINE_DELETE:
+ return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return filterTimelines(state, action.relationship, action.statuses);
+ case ACCOUNT_UNFOLLOW_SUCCESS:
+ return filterTimeline('home', state, action.relationship, action.statuses);
+ case TIMELINE_SCROLL_TOP:
+ return updateTop(state, action.timeline, action.top);
+ case TIMELINE_CONNECT:
+ return state.update(action.timeline, initialTimeline, map => map.set('online', true));
+ case TIMELINE_DISCONNECT:
+ return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
new file mode 100644
index 000000000..8db18c5dc
--- /dev/null
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -0,0 +1,80 @@
+import {
+ FOLLOWERS_FETCH_SUCCESS,
+ FOLLOWERS_EXPAND_SUCCESS,
+ FOLLOWING_FETCH_SUCCESS,
+ FOLLOWING_EXPAND_SUCCESS,
+ FOLLOW_REQUESTS_FETCH_SUCCESS,
+ FOLLOW_REQUESTS_EXPAND_SUCCESS,
+ FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+ FOLLOW_REQUEST_REJECT_SUCCESS,
+} from '../actions/accounts';
+import {
+ REBLOGS_FETCH_SUCCESS,
+ FAVOURITES_FETCH_SUCCESS,
+} from '../actions/interactions';
+import {
+ BLOCKS_FETCH_SUCCESS,
+ BLOCKS_EXPAND_SUCCESS,
+} from '../actions/blocks';
+import {
+ MUTES_FETCH_SUCCESS,
+ MUTES_EXPAND_SUCCESS,
+} from '../actions/mutes';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+ followers: ImmutableMap(),
+ following: ImmutableMap(),
+ reblogged_by: ImmutableMap(),
+ favourited_by: ImmutableMap(),
+ follow_requests: ImmutableMap(),
+ blocks: ImmutableMap(),
+ mutes: ImmutableMap(),
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+ return state.setIn([type, id], ImmutableMap({
+ next,
+ items: ImmutableList(accounts.map(item => item.id)),
+ }));
+};
+
+const appendToList = (state, type, id, accounts, next) => {
+ return state.updateIn([type, id], map => {
+ return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
+ });
+};
+
+export default function userLists(state = initialState, action) {
+ switch(action.type) {
+ case FOLLOWERS_FETCH_SUCCESS:
+ return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWERS_EXPAND_SUCCESS:
+ return appendToList(state, 'followers', action.id, action.accounts, action.next);
+ case FOLLOWING_FETCH_SUCCESS:
+ return normalizeList(state, 'following', action.id, action.accounts, action.next);
+ case FOLLOWING_EXPAND_SUCCESS:
+ return appendToList(state, 'following', action.id, action.accounts, action.next);
+ case REBLOGS_FETCH_SUCCESS:
+ return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+ case FAVOURITES_FETCH_SUCCESS:
+ return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+ case FOLLOW_REQUESTS_FETCH_SUCCESS:
+ return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+ case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+ return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+ case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+ case FOLLOW_REQUEST_REJECT_SUCCESS:
+ return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+ case BLOCKS_FETCH_SUCCESS:
+ return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+ case BLOCKS_EXPAND_SUCCESS:
+ return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+ case MUTES_FETCH_SUCCESS:
+ return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ case MUTES_EXPAND_SUCCESS:
+ return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
new file mode 100644
index 000000000..00870a15d
--- /dev/null
+++ b/app/javascript/mastodon/rtl.js
@@ -0,0 +1,31 @@
+// U+0590 to U+05FF - Hebrew
+// U+0600 to U+06FF - Arabic
+// U+0700 to U+074F - Syriac
+// U+0750 to U+077F - Arabic Supplement
+// U+0780 to U+07BF - Thaana
+// U+07C0 to U+07FF - N'Ko
+// U+0800 to U+083F - Samaritan
+// U+08A0 to U+08FF - Arabic Extended-A
+// U+FB1D to U+FB4F - Hebrew presentation forms
+// U+FB50 to U+FDFF - Arabic presentation forms A
+// U+FE70 to U+FEFF - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+ if (text.length === 0) {
+ return false;
+ }
+
+ text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
+ text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
+ text = text.replace(/\s+/g, '');
+
+ const matches = text.match(rtlChars);
+
+ if (!matches) {
+ return false;
+ }
+
+ return matches.length / text.length > 0.3;
+};
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
new file mode 100644
index 000000000..2af07e0fb
--- /dev/null
+++ b/app/javascript/mastodon/scroll.js
@@ -0,0 +1,30 @@
+const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
+
+const scroll = (node, key, target) => {
+ const startTime = Date.now();
+ const offset = node[key];
+ const gap = target - offset;
+ const duration = 1000;
+ let interrupt = false;
+
+ const step = () => {
+ const elapsed = Date.now() - startTime;
+ const percentage = elapsed / duration;
+
+ if (percentage > 1 || interrupt) {
+ return;
+ }
+
+ node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
+ requestAnimationFrame(step);
+ };
+
+ step();
+
+ return () => {
+ interrupt = true;
+ };
+};
+
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
new file mode 100644
index 000000000..d26d1b727
--- /dev/null
+++ b/app/javascript/mastodon/selectors/index.js
@@ -0,0 +1,87 @@
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+
+const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
+const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
+
+export const makeGetAccount = () => {
+ return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
+ if (base === null) {
+ return null;
+ }
+
+ return base.merge(counters).set('relationship', relationship);
+ });
+};
+
+export const makeGetStatus = () => {
+ return createSelector(
+ [
+ (state, id) => state.getIn(['statuses', id]),
+ (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+ (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+ (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+ ],
+
+ (statusBase, statusReblog, accountBase, accountReblog) => {
+ if (!statusBase) {
+ return null;
+ }
+
+ if (statusReblog) {
+ statusReblog = statusReblog.set('account', accountReblog);
+ } else {
+ statusReblog = null;
+ }
+
+ return statusBase.withMutations(map => {
+ map.set('reblog', statusReblog);
+ map.set('account', accountBase);
+ });
+ }
+ );
+};
+
+const getAlertsBase = state => state.get('alerts');
+
+export const getAlerts = createSelector([getAlertsBase], (base) => {
+ let arr = [];
+
+ base.forEach(item => {
+ arr.push({
+ message: item.get('message'),
+ title: item.get('title'),
+ key: item.get('key'),
+ dismissAfter: 5000,
+ barStyle: {
+ zIndex: 200,
+ },
+ });
+ });
+
+ return arr;
+});
+
+export const makeGetNotification = () => {
+ return createSelector([
+ (_, base) => base,
+ (state, _, accountId) => state.getIn(['accounts', accountId]),
+ ], (base, account) => {
+ return base.set('account', account);
+ });
+};
+
+export const getAccountGallery = createSelector([
+ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
+ state => state.get('statuses'),
+], (statusIds, statuses) => {
+ let medias = ImmutableList();
+
+ statusIds.forEach(statusId => {
+ const status = statuses.get(statusId);
+ medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
+ });
+
+ return medias;
+});
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
new file mode 100644
index 000000000..eea4cfc3c
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -0,0 +1,10 @@
+import './web_push_notifications';
+
+// Cause a new version of a registered Service Worker to replace an existing one
+// that is already installed, and replace the currently active worker on open pages.
+self.addEventListener('install', function(event) {
+ event.waitUntil(self.skipWaiting());
+});
+self.addEventListener('activate', function(event) {
+ event.waitUntil(self.clients.claim());
+});
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
new file mode 100644
index 000000000..f63cff335
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -0,0 +1,159 @@
+const MAX_NOTIFICATIONS = 5;
+const GROUP_TAG = 'tag';
+
+// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
+const formatGroupTitle = (message, count) => message.replace('%{count}', count);
+
+const notify = options =>
+ self.registration.getNotifications().then(notifications => {
+ if (notifications.length === MAX_NOTIFICATIONS) {
+ // Reached the maximum number of notifications, proceed with grouping
+ const group = {
+ title: formatGroupTitle(options.data.message, notifications.length + 1),
+ body: notifications
+ .sort((n1, n2) => n1.timestamp < n2.timestamp)
+ .map(notification => notification.title).join('\n'),
+ badge: '/badge.png',
+ icon: '/android-chrome-192x192.png',
+ tag: GROUP_TAG,
+ data: {
+ url: (new URL('/web/notifications', self.location)).href,
+ count: notifications.length + 1,
+ message: options.data.message,
+ },
+ };
+
+ notifications.forEach(notification => notification.close());
+
+ return self.registration.showNotification(group.title, group);
+ } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
+ // Already grouped, proceed with appending the notification to the group
+ const group = cloneNotification(notifications[0]);
+
+ group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+ group.body = `${options.title}\n${group.body}`;
+ group.data = { ...group.data, count: group.data.count + 1 };
+
+ return self.registration.showNotification(group.title, group);
+ }
+
+ return self.registration.showNotification(options.title, options);
+ });
+
+const handlePush = (event) => {
+ const options = event.data.json();
+
+ options.body = options.data.nsfw || options.data.content;
+ options.dir = options.data.dir;
+ options.image = options.image || undefined; // Null results in a network request (404)
+ options.timestamp = options.timestamp && new Date(options.timestamp);
+
+ const expandAction = options.data.actions.find(action => action.todo === 'expand');
+
+ if (expandAction) {
+ options.actions = [expandAction];
+ options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
+ options.data.hiddenImage = options.image;
+ options.image = undefined;
+ } else {
+ options.actions = options.data.actions;
+ }
+
+ event.waitUntil(notify(options));
+};
+
+const cloneNotification = (notification) => {
+ const clone = { };
+
+ for(var k in notification) {
+ clone[k] = notification[k];
+ }
+
+ return clone;
+};
+
+const expandNotification = (notification) => {
+ const nextNotification = cloneNotification(notification);
+
+ nextNotification.body = notification.data.content;
+ nextNotification.image = notification.data.hiddenImage;
+ nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+
+ return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const makeRequest = (notification, action) =>
+ fetch(action.action, {
+ headers: {
+ 'Authorization': `Bearer ${notification.data.access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ method: action.method,
+ credentials: 'include',
+ });
+
+const findBestClient = clients => {
+ const focusedClient = clients.find(client => client.focused);
+ const visibleClient = clients.find(client => client.visibilityState === 'visible');
+
+ return focusedClient || visibleClient || clients[0];
+};
+
+const openUrl = url =>
+ self.clients.matchAll({ type: 'window' }).then(clientList => {
+ if (clientList.length !== 0) {
+ const webClients = clientList.filter(client => /\/web\//.test(client.url));
+
+ if (webClients.length !== 0) {
+ const client = findBestClient(webClients);
+ const { pathname } = new URL(url);
+
+ if (pathname.startsWith('/web/')) {
+ return client.focus().then(client => client.postMessage({
+ type: 'navigate',
+ path: pathname.slice('/web/'.length - 1),
+ }));
+ }
+ } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
+ const client = findBestClient(clientList);
+
+ return client.navigate(url).then(client => client.focus());
+ }
+ }
+
+ return self.clients.openWindow(url);
+ });
+
+const removeActionFromNotification = (notification, action) => {
+ const actions = notification.actions.filter(act => act.action !== action.action);
+ const nextNotification = cloneNotification(notification);
+
+ nextNotification.actions = actions;
+
+ return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const handleNotificationClick = (event) => {
+ const reactToNotificationClick = new Promise((resolve, reject) => {
+ if (event.action) {
+ const action = event.notification.data.actions.find(({ action }) => action === event.action);
+
+ if (action.todo === 'expand') {
+ resolve(expandNotification(event.notification));
+ } else if (action.todo === 'request') {
+ resolve(makeRequest(event.notification, action)
+ .then(() => removeActionFromNotification(event.notification, action)));
+ } else {
+ reject(`Unknown action: ${action.todo}`);
+ }
+ } else {
+ event.notification.close();
+ resolve(openUrl(event.notification.data.url));
+ }
+ });
+
+ event.waitUntil(reactToNotificationClick);
+};
+
+self.addEventListener('push', handlePush);
+self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
new file mode 100644
index 000000000..1376d4cba
--- /dev/null
+++ b/app/javascript/mastodon/store/configureStore.js
@@ -0,0 +1,15 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import appReducer from '../reducers';
+import loadingBarMiddleware from '../middleware/loading_bar';
+import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from '../middleware/sounds';
+
+export default function configureStore() {
+ return createStore(appReducer, compose(applyMiddleware(
+ thunk,
+ loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+ errorsMiddleware(),
+ soundsMiddleware()
+ ), window.devToolsExtension ? window.devToolsExtension() : f => f));
+};
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
new file mode 100644
index 000000000..36c68ffc5
--- /dev/null
+++ b/app/javascript/mastodon/stream.js
@@ -0,0 +1,73 @@
+import WebSocketClient from 'websocket.js';
+
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+ return (dispatch, getState) => {
+ const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+ const accessToken = getState().getIn(['meta', 'access_token']);
+ const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+ let polling = null;
+
+ const setupPolling = () => {
+ polling = setInterval(() => {
+ pollingRefresh(dispatch);
+ }, 20000);
+ };
+
+ const clearPolling = () => {
+ if (polling) {
+ clearInterval(polling);
+ polling = null;
+ }
+ };
+
+ const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
+ connected () {
+ if (pollingRefresh) {
+ clearPolling();
+ }
+ onConnect();
+ },
+
+ disconnected () {
+ if (pollingRefresh) {
+ setupPolling();
+ }
+ onDisconnect();
+ },
+
+ received (data) {
+ onReceive(data);
+ },
+
+ reconnected () {
+ if (pollingRefresh) {
+ clearPolling();
+ pollingRefresh(dispatch);
+ }
+ onConnect();
+ },
+
+ });
+
+ const disconnect = () => {
+ if (subscription) {
+ subscription.close();
+ }
+ clearPolling();
+ };
+
+ return disconnect;
+ };
+}
+
+
+export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
+ const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+ ws.onopen = connected;
+ ws.onmessage = e => received(JSON.parse(e.data));
+ ws.onclose = disconnected;
+ ws.onreconnect = reconnected;
+
+ return ws;
+};
diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js
new file mode 100644
index 000000000..80148379b
--- /dev/null
+++ b/app/javascript/mastodon/test_setup.js
@@ -0,0 +1,5 @@
+import { configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+const adapter = new Adapter();
+configure({ adapter });
diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js
new file mode 100644
index 000000000..be1899305
--- /dev/null
+++ b/app/javascript/mastodon/uuid.js
@@ -0,0 +1,3 @@
+export default function uuid(a) {
+ return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
+};
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
new file mode 100644
index 000000000..3dbed09ea
--- /dev/null
+++ b/app/javascript/mastodon/web_push_subscription.js
@@ -0,0 +1,105 @@
+import axios from 'axios';
+import { store } from './containers/mastodon';
+import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/\-/g, '+')
+ .replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+ registration.pushManager.getSubscription()
+ .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+ });
+
+const unsubscribe = ({ registration, subscription }) =>
+ subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (subscription) =>
+ axios.post('/api/web/push_subscriptions', {
+ subscription,
+ }).then(response => response.data);
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+ store.dispatch(setBrowserSupport(supportsPushNotifications));
+
+ if (supportsPushNotifications) {
+ if (!getApplicationServerKey()) {
+ console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+ return;
+ }
+
+ getRegistration()
+ .then(getPushSubscription)
+ .then(({ registration, subscription }) => {
+ if (subscription !== null) {
+ // We have a subscription, check if it is still valid
+ const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+ const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+ const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+ // If the VAPID public key did not change and the endpoint corresponds
+ // to the endpoint saved in the backend, the subscription is valid
+ if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+ return subscription;
+ } else {
+ // Something went wrong, try to subscribe again
+ return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
+ }
+ }
+
+ // No subscription, try to subscribe
+ return subscribe(registration).then(sendSubscriptionToBackend);
+ })
+ .then(subscription => {
+ // If we got a PushSubscription (and not a subscription object from the backend)
+ // it means that the backend subscription is valid (and was set during hydration)
+ if (!(subscription instanceof PushSubscription)) {
+ store.dispatch(setSubscription(subscription));
+ }
+ })
+ .catch(error => {
+ if (error.code === 20 && error.name === 'AbortError') {
+ console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+ } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+ console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+ }
+
+ // Clear alerts and hide UI settings
+ store.dispatch(clearSubscription());
+
+ try {
+ getRegistration()
+ .then(getPushSubscription)
+ .then(unsubscribe);
+ } catch (e) {
+
+ }
+ });
+ } else {
+ console.warn('Your browser does not support Web Push Notifications.');
+ }
+}
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
new file mode 100644
index 000000000..44aa10564
--- /dev/null
+++ b/app/javascript/styles/application.scss
@@ -0,0 +1,22 @@
+@import 'mastodon/mixins';
+@import 'mastodon/variables';
+@import 'fonts/roboto';
+@import 'fonts/roboto-mono';
+@import 'fonts/montserrat';
+
+@import 'mastodon/reset';
+@import 'mastodon/basics';
+@import 'mastodon/containers';
+@import 'mastodon/lists';
+@import 'mastodon/footer';
+@import 'mastodon/compact_header';
+@import 'mastodon/landing_strip';
+@import 'mastodon/forms';
+@import 'mastodon/accounts';
+@import 'mastodon/stream_entries';
+@import 'mastodon/components';
+@import 'mastodon/emoji_picker';
+@import 'mastodon/about';
+@import 'mastodon/tables';
+@import 'mastodon/admin';
+@import 'mastodon/rtl';
diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss
new file mode 100644
index 000000000..67d768a6c
--- /dev/null
+++ b/app/javascript/styles/mastodon/_mixins.scss
@@ -0,0 +1,12 @@
+@mixin avatar-radius() {
+ border-radius: 4px;
+ background: transparent no-repeat;
+ background-position: 50%;
+ background-clip: padding-box;
+}
+
+@mixin avatar-size($size:48px) {
+ width: $size;
+ height: $size;
+ background-size: $size $size;
+}
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
new file mode 100644
index 000000000..358d86eec
--- /dev/null
+++ b/app/javascript/styles/mastodon/about.scss
@@ -0,0 +1,824 @@
+.landing-page {
+ p,
+ li {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ margin-bottom: 12px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ em {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ font-weight: 500;
+ background: transparent;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: lighten($ui-primary-color, 10%);
+ }
+
+ h1 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 26px;
+ line-height: 30px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+
+ small {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ display: block;
+ font-size: 18px;
+ font-weight: 400;
+ color: $ui-base-lighter-color;
+ }
+ }
+
+ h2 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 22px;
+ line-height: 26px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h3 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 18px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h4 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h5 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h6 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 12px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ ul,
+ ol {
+ margin-left: 20px;
+
+ &[type='a'] {
+ list-style-type: lower-alpha;
+ }
+
+ &[type='i'] {
+ list-style-type: lower-roman;
+ }
+ }
+
+ ul {
+ list-style: disc;
+ }
+
+ ol {
+ list-style: decimal;
+ }
+
+ li > ol,
+ li > ul {
+ margin-top: 6px;
+ }
+
+ hr {
+ border-color: rgba($ui-base-lighter-color, .6);
+ }
+
+ .container {
+ width: 100%;
+ box-sizing: border-box;
+ max-width: 800px;
+ margin: 0 auto;
+ word-wrap: break-word;
+ }
+
+ .header-wrapper {
+ padding-top: 15px;
+ background: $ui-base-color;
+ background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
+ position: relative;
+
+ &.compact {
+ background: $ui-base-color;
+ padding-bottom: 15px;
+
+ .hero .heading {
+ padding-bottom: 20px;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .mascot-container {
+ max-width: 800px;
+ margin: 0 auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ }
+
+ .mascot {
+ position: absolute;
+ bottom: -14px;
+ width: auto;
+ height: auto;
+ left: 60px;
+ z-index: 3;
+ }
+ }
+
+ .header {
+ line-height: 30px;
+ overflow: hidden;
+
+ .container {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .links {
+ position: relative;
+ z-index: 4;
+
+ a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $ui-primary-color;
+ text-decoration: none;
+ padding: 12px 16px;
+ line-height: 32px;
+ font-family: 'mastodon-font-display', sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+
+ &:hover {
+ color: $ui-secondary-color;
+ }
+ }
+
+ .brand {
+ a {
+ padding-left: 0;
+ padding-right: 0;
+ color: $white;
+ }
+
+ img {
+ height: 32px;
+ position: relative;
+ top: 4px;
+ left: -10px;
+ }
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+
+ li {
+ display: inline-block;
+ vertical-align: bottom;
+ margin: 0;
+
+ &:first-child a {
+ padding-left: 0;
+ }
+
+ &:last-child a {
+ padding-right: 0;
+ }
+ }
+ }
+ }
+
+ .hero {
+ margin-top: 50px;
+ align-items: center;
+ position: relative;
+
+ .floats {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+
+ div {
+ position: absolute;
+ transition: all 0.1s linear;
+ animation-name: floating;
+ animation-iteration-count: infinite;
+ animation-direction: alternate;
+ animation-timing-function: ease-in-out;
+ z-index: 2;
+ }
+
+ .float-1 {
+ width: 324px;
+ height: 170px;
+ right: -120px;
+ bottom: 0;
+ animation-duration: 3s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+
+ .float-2 {
+ width: 241px;
+ height: 100px;
+ right: 210px;
+ bottom: 0;
+ animation-duration: 3.5s;
+ animation-delay: 0.2s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+
+ .float-3 {
+ width: 267px;
+ height: 140px;
+ right: 110px;
+ top: -30px;
+ animation-duration: 4s;
+ animation-delay: 0.5s;
+ background-image: url('data:image/svg+xml;utf8, ');
+ }
+ }
+
+ .heading {
+ position: relative;
+ z-index: 4;
+ padding-bottom: 150px;
+ }
+
+ .simple_form,
+ .closed-registrations-message {
+ background: darken($ui-base-color, 4%);
+ width: 280px;
+ padding: 15px 20px;
+ border-radius: 4px 4px 0 0;
+ line-height: initial;
+ position: relative;
+ z-index: 4;
+
+ .actions {
+ margin-bottom: 0;
+
+ button,
+ .button,
+ .block-button {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .closed-registrations-message {
+ min-height: 330px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+ }
+ }
+
+ .about-short {
+ background: darken($ui-base-color, 4%);
+ padding: 50px 0 30px;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ .information-board {
+ background: darken($ui-base-color, 4%);
+ padding: 20px 0;
+
+ .container {
+ position: relative;
+ padding-right: 280px + 15px;
+ }
+
+ .information-board-sections {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ }
+
+ .section {
+ flex: 1 0 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ line-height: 28px;
+ color: $primary-text-color;
+ text-align: right;
+ padding: 10px 15px;
+
+ span,
+ strong {
+ display: block;
+ }
+
+ span {
+ &:last-child {
+ color: $ui-secondary-color;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ font-size: 32px;
+ line-height: 48px;
+ }
+ }
+
+ .panel {
+ position: absolute;
+ width: 280px;
+ box-sizing: border-box;
+ background: darken($ui-base-color, 8%);
+ padding: 20px;
+ padding-top: 10px;
+ border-radius: 4px 4px 0 0;
+ right: 0;
+ bottom: -40px;
+
+ .panel-header {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ color: $ui-primary-color;
+ padding-bottom: 5px;
+ margin-bottom: 15px;
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+
+ a,
+ span {
+ font-weight: 400;
+ color: darken($ui-primary-color, 10%);
+ }
+
+ a {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .owner {
+ text-align: center;
+
+ .avatar {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto;
+ margin-bottom: 15px;
+
+ img {
+ display: block;
+ width: 80px;
+ height: 80px;
+ border-radius: 48px;
+ }
+ }
+
+ .name {
+ font-size: 14px;
+
+ a {
+ display: block;
+ color: $primary-text-color;
+ text-decoration: none;
+
+ &:hover {
+ .display_name {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .username {
+ display: block;
+ color: $ui-primary-color;
+ }
+ }
+ }
+ }
+
+ .features {
+ padding: 50px 0;
+
+ .container {
+ display: flex;
+ }
+
+ #mastodon-timeline {
+ display: flex;
+ -webkit-overflow-scrolling: touch;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 400;
+ color: $primary-text-color;
+ width: 330px;
+ margin-right: 30px;
+ flex: 0 0 auto;
+ background: $ui-base-color;
+ overflow: hidden;
+ border-radius: 4px;
+ box-shadow: 0 0 6px rgba($black, 0.1);
+
+ .column-header {
+ color: inherit;
+ font-family: inherit;
+ font-size: 16px;
+ line-height: inherit;
+ font-weight: inherit;
+ margin: 0;
+ padding: 15px;
+ }
+
+ .column {
+ padding: 0;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .scrollable {
+ height: 400px;
+ }
+
+ p {
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: inherit;
+ color: $primary-text-color;
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ a {
+ color: $ui-secondary-color;
+ text-decoration: none;
+ }
+ }
+ }
+
+ .about-mastodon {
+ max-width: 675px;
+
+ p {
+ margin-bottom: 20px;
+ }
+
+ .features-list {
+ margin-top: 20px;
+
+ .features-list__row {
+ display: flex;
+ padding: 10px 0;
+ justify-content: space-between;
+
+ &:first-child {
+ padding-top: 0;
+ }
+
+ .visual {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ margin-left: 15px;
+
+ .fa {
+ display: block;
+ color: $ui-primary-color;
+ font-size: 48px;
+ }
+ }
+
+ .text {
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ h6 {
+ font-size: inherit;
+ line-height: inherit;
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .extended-description {
+ padding: 50px 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
+
+ .footer-links {
+ padding-bottom: 50px;
+ text-align: right;
+ color: $ui-base-lighter-color;
+
+ p {
+ font-size: 14px;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+ }
+
+ @media screen and (max-width: 840px) {
+ .container {
+ padding: 0 20px;
+ }
+
+ .information-board {
+
+ .container {
+ padding-right: 20px;
+ }
+
+ .section {
+ text-align: center;
+ }
+
+ .panel {
+ position: static;
+ margin-top: 20px;
+ width: 100%;
+ border-radius: 4px;
+
+ .panel-header {
+ text-align: center;
+ }
+ }
+ }
+
+ .header-wrapper .mascot {
+ left: 20px;
+ }
+ }
+
+ @media screen and (max-width: 689px) {
+ .header-wrapper .mascot {
+ display: none;
+ }
+ }
+
+ @media screen and (max-width: 675px) {
+ .header-wrapper {
+ padding-top: 0;
+
+ &.compact {
+ padding-bottom: 0;
+ }
+
+ &.compact .hero .heading {
+ text-align: initial;
+ }
+ }
+
+ .header .container,
+ .features .container {
+ display: block;
+ }
+
+ .header {
+
+ .links {
+ padding-top: 15px;
+ background: darken($ui-base-color, 4%);
+
+ a {
+ padding: 12px 8px;
+ }
+
+ .nav {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-around;
+ }
+
+ .brand img {
+ left: 0;
+ top: 0;
+ }
+ }
+
+ .hero {
+ margin-top: 30px;
+ padding: 0;
+
+ .floats {
+ display: none;
+ }
+
+ .heading {
+ padding: 30px 20px;
+ text-align: center;
+ }
+
+ .simple_form,
+ .closed-registrations-message {
+ background: darken($ui-base-color, 8%);
+ width: 100%;
+ border-radius: 0;
+ box-sizing: border-box;
+ }
+ }
+ }
+
+ .features #mastodon-timeline {
+ height: 70vh;
+ width: 100%;
+ margin-bottom: 50px;
+
+ .column {
+ width: 100%;
+ }
+ }
+ }
+
+ .cta {
+ margin: 20px;
+ }
+
+ &.tag-page {
+ .features {
+ padding: 30px 0;
+
+ .container {
+ max-width: 820px;
+
+ #mastodon-timeline {
+ margin-right: 0;
+ border-top-right-radius: 0;
+ }
+
+ .about-mastodon {
+ .about-hashtag {
+ background: darken($ui-base-color, 4%);
+ padding: 0 20px 20px 30px;
+ border-radius: 0 5px 5px 0;
+
+ .brand {
+ padding-top: 20px;
+ margin-bottom: 20px;
+
+ img {
+ height: 48px;
+ width: auto;
+ }
+ }
+
+ p {
+ strong {
+ color: $ui-secondary-color;
+ font-weight: 700;
+ }
+ }
+
+ .cta {
+ margin: 0;
+
+ .button {
+ margin-right: 4px;
+ }
+ }
+ }
+
+ .features-list {
+ margin-left: 30px;
+ margin-right: 10px;
+ }
+ }
+ }
+ }
+
+ @media screen and (max-width: 675px) {
+ .features {
+ padding: 10px 0;
+
+ .container {
+ display: flex;
+ flex-direction: column;
+
+ #mastodon-timeline {
+ order: 2;
+ flex: 0 0 auto;
+ height: 60vh;
+ margin-bottom: 20px;
+ border-top-right-radius: 4px;
+ }
+
+ .about-mastodon {
+ order: 1;
+ flex: 0 0 auto;
+ max-width: 100%;
+
+ .about-hashtag {
+ background: unset;
+ padding: 0;
+ border-radius: 0;
+
+ .cta {
+ margin: 20px 0;
+ }
+ }
+
+ .features-list {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@keyframes floating {
+ from {
+ transform: translate(0, 0);
+ }
+
+ 65% {
+ transform: translate(0, 4px);
+ }
+
+ to {
+ transform: translate(0, -0);
+ }
+}
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
new file mode 100644
index 000000000..23e20a366
--- /dev/null
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -0,0 +1,549 @@
+.card {
+ background-color: lighten($ui-base-color, 4%);
+ background-size: cover;
+ background-position: center;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ overflow: hidden;
+ position: relative;
+ display: flex;
+
+ &::after {
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ display: block;
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+
+ @media screen and (max-width: 740px) {
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .card__illustration {
+ padding: 60px 0;
+ position: relative;
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .card__bio {
+ max-width: 260px;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: rgba(darken($ui-base-color, 8%), 0.8);
+ position: relative;
+ z-index: 2;
+ }
+
+ &.compact {
+ padding: 30px 0;
+ border-radius: 4px;
+
+ .avatar {
+ margin-bottom: 0;
+
+ img {
+ object-fit: cover;
+ }
+ }
+ }
+
+ .name {
+ display: block;
+ font-size: 20px;
+ line-height: 18px * 1.5;
+ color: $primary-text-color;
+ padding: 10px 15px;
+ padding-bottom: 0;
+ font-weight: 500;
+ position: relative;
+ z-index: 2;
+ margin-bottom: 30px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ small {
+ display: block;
+ font-size: 14px;
+ color: $ui-highlight-color;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .avatar {
+ width: 120px;
+ margin: 0 auto;
+ position: relative;
+ z-index: 2;
+
+ img {
+ width: 120px;
+ height: 120px;
+ display: block;
+ border-radius: 120px;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ }
+ }
+
+ .controls {
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ z-index: 2;
+
+ .icon-button {
+ color: rgba($white, 0.8);
+ text-decoration: none;
+ font-size: 13px;
+ line-height: 13px;
+ font-weight: 500;
+
+ .fa {
+ font-weight: 400;
+ margin-right: 5px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $white;
+ }
+ }
+ }
+
+ .roles {
+ margin-bottom: 30px;
+ padding: 0 15px;
+ }
+
+ .details-counters {
+ margin-top: 30px;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ }
+
+ .counter {
+ width: 33.3%;
+ box-sizing: border-box;
+ flex: 0 0 auto;
+ color: $ui-primary-color;
+ padding: 5px 10px 0;
+ margin-bottom: 10px;
+ border-right: 1px solid lighten($ui-base-color, 4%);
+ cursor: default;
+ text-align: center;
+ position: relative;
+
+ a {
+ display: block;
+ }
+
+ &:last-child {
+ border-right: 0;
+ }
+
+ &::after {
+ display: block;
+ content: "";
+ position: absolute;
+ bottom: -10px;
+ left: 0;
+ width: 100%;
+ border-bottom: 4px solid $ui-primary-color;
+ opacity: 0.5;
+ transition: all 400ms ease;
+ }
+
+ &.active {
+ &::after {
+ border-bottom: 4px solid $ui-highlight-color;
+ opacity: 1;
+ }
+ }
+
+ &:hover {
+ &::after {
+ opacity: 1;
+ transition-duration: 100ms;
+ }
+ }
+
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .counter-label {
+ font-size: 12px;
+ display: block;
+ margin-bottom: 5px;
+ }
+
+ .counter-number {
+ font-weight: 500;
+ font-size: 18px;
+ color: $primary-text-color;
+ font-family: 'mastodon-font-display', sans-serif;
+ }
+ }
+
+ .bio {
+ font-size: 14px;
+ line-height: 18px;
+ padding: 0 15px;
+ color: $ui-secondary-color;
+ }
+
+ @media screen and (max-width: 480px) {
+ display: block;
+
+ .card__bio {
+ max-width: none;
+ }
+
+ .name,
+ .roles {
+ text-align: center;
+ margin-bottom: 15px;
+ }
+
+ .bio {
+ margin-bottom: 15px;
+ }
+ }
+}
+
+.pagination {
+ padding: 30px 0;
+ text-align: center;
+ overflow: hidden;
+
+ a,
+ .current,
+ .next,
+ .prev,
+ .page,
+ .gap {
+ font-size: 14px;
+ color: $primary-text-color;
+ font-weight: 500;
+ display: inline-block;
+ padding: 6px 10px;
+ text-decoration: none;
+ }
+
+ .current {
+ background: $simple-background-color;
+ border-radius: 100px;
+ color: $ui-base-color;
+ cursor: default;
+ margin: 0 10px;
+ }
+
+ .gap {
+ cursor: default;
+ }
+
+ .prev,
+ .next {
+ text-transform: uppercase;
+ color: $ui-secondary-color;
+ }
+
+ .prev {
+ float: left;
+ padding-left: 0;
+
+ .fa {
+ display: inline-block;
+ margin-right: 5px;
+ }
+ }
+
+ .next {
+ float: right;
+ padding-right: 0;
+
+ .fa {
+ display: inline-block;
+ margin-left: 5px;
+ }
+ }
+
+ .disabled {
+ cursor: default;
+ color: lighten($ui-base-color, 10%);
+ }
+
+ @media screen and (max-width: 700px) {
+ padding: 30px 20px;
+
+ .page {
+ display: none;
+ }
+
+ .next,
+ .prev {
+ display: inline-block;
+ }
+ }
+}
+
+.accounts-grid {
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ background: darken($simple-background-color, 8%);
+ border-radius: 0 0 4px 4px;
+ padding: 20px 5px;
+ padding-bottom: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: wrap;
+ z-index: 2;
+ position: relative;
+
+ @media screen and (max-width: 740px) {
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .account-grid-card {
+ box-sizing: border-box;
+ width: 335px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ color: $ui-base-color;
+ margin: 0 5px 10px;
+ position: relative;
+
+ @media screen and (max-width: 740px) {
+ width: calc(100% - 10px);
+ }
+
+ .account-grid-card__header {
+ overflow: hidden;
+ height: 100px;
+ border-radius: 4px 4px 0 0;
+ background-color: lighten($ui-base-color, 4%);
+ background-size: cover;
+ background-position: center;
+ position: relative;
+
+ &::after {
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ display: block;
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+ }
+
+ .account-grid-card__avatar {
+ box-sizing: border-box;
+ padding: 15px;
+ position: absolute;
+ z-index: 2;
+ top: 100px - (40px + 2px);
+ left: -2px;
+ }
+
+ .avatar {
+ width: 80px;
+ height: 80px;
+
+ img {
+ display: block;
+ width: 80px;
+ height: 80px;
+ border-radius: 80px;
+ border: 2px solid $simple-background-color;
+ background: $simple-background-color;
+ }
+ }
+
+ .name {
+ padding: 15px;
+ padding-top: 10px;
+ padding-left: 15px + 80px + 15px;
+
+ a {
+ display: block;
+ color: $ui-base-color;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ font-weight: 500;
+
+ &:hover {
+ .display_name {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ .display_name {
+ font-size: 16px;
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .username {
+ color: lighten($ui-base-color, 34%);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ .note {
+ padding: 10px 15px;
+ padding-top: 15px;
+ box-sizing: border-box;
+ color: lighten($ui-base-color, 26%);
+ word-wrap: break-word;
+ min-height: 80px;
+ }
+ }
+}
+
+.nothing-here {
+ width: 100%;
+ display: block;
+ color: $ui-primary-color;
+ font-size: 14px;
+ font-weight: 500;
+ text-align: center;
+ padding: 60px 0;
+ padding-top: 55px;
+ cursor: default;
+}
+
+.account-card {
+ padding: 14px 10px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ text-align: left;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+ .detailed-status__display-name {
+ display: block;
+ overflow: hidden;
+ margin-bottom: 15px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ & > div {
+ float: left;
+ margin-right: 10px;
+ width: 48px;
+ height: 48px;
+ }
+
+ .avatar {
+ display: block;
+ border-radius: 4px;
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ cursor: default;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+
+ &:hover {
+ .display-name {
+ strong {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ .account__header__content {
+ font-size: 14px;
+ color: $ui-base-color;
+ }
+}
+
+.activity-stream-tabs {
+ background: $simple-background-color;
+ border-bottom: 1px solid $ui-secondary-color;
+ position: relative;
+ z-index: 2;
+
+ a {
+ display: inline-block;
+ padding: 15px;
+ text-decoration: none;
+ color: $ui-highlight-color;
+ text-transform: uppercase;
+ font-weight: 500;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-highlight-color, 8%);
+ }
+
+ &.active {
+ color: $ui-base-color;
+ cursor: default;
+ }
+ }
+}
+
+.account-role {
+ display: inline-block;
+ padding: 4px 6px;
+ cursor: default;
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: 500;
+ color: $ui-secondary-color;
+ background-color: rgba($ui-secondary-color, 0.1);
+ border: 1px solid rgba($ui-secondary-color, 0.5);
+
+ &.moderator {
+ color: $success-green;
+ background-color: rgba($success-green, 0.1);
+ border-color: rgba($success-green, 0.5);
+ }
+
+ &.admin {
+ color: lighten($error-red, 12%);
+ background-color: rgba(lighten($error-red, 12%), 0.1);
+ border-color: rgba(lighten($error-red, 12%), 0.5);
+ }
+}
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
new file mode 100644
index 000000000..87bc710af
--- /dev/null
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -0,0 +1,349 @@
+.admin-wrapper {
+ display: flex;
+ justify-content: center;
+ height: 100%;
+
+ .sidebar-wrapper {
+ flex: 1;
+ height: 100%;
+ background: $ui-base-color;
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .sidebar {
+ width: 240px;
+ height: 100%;
+ padding: 0;
+ overflow-y: auto;
+
+ .logo {
+ display: block;
+ margin: 40px auto;
+ width: 100px;
+ height: 100px;
+ }
+
+ ul {
+ list-style: none;
+ border-radius: 4px 0 0 4px;
+ overflow: hidden;
+ margin-bottom: 20px;
+
+ a {
+ display: block;
+ padding: 15px;
+ color: rgba($primary-text-color, 0.7);
+ text-decoration: none;
+ transition: all 200ms linear;
+ border-radius: 4px 0 0 4px;
+
+ i.fa {
+ margin-right: 5px;
+ }
+
+ &:hover {
+ color: $primary-text-color;
+ background-color: darken($ui-base-color, 5%);
+ transition: all 100ms linear;
+ }
+
+ &.selected {
+ background: darken($ui-base-color, 2%);
+ border-radius: 4px 0 0;
+ }
+ }
+
+ ul {
+ background: darken($ui-base-color, 4%);
+ border-radius: 0 0 0 4px;
+ margin: 0;
+
+ a {
+ border: 0;
+ padding: 15px 35px;
+
+ &.selected {
+ color: $primary-text-color;
+ background-color: $ui-highlight-color;
+ border-bottom: 0;
+ border-radius: 0;
+
+ &:hover {
+ background-color: lighten($ui-highlight-color, 5%);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .content-wrapper {
+ flex: 2;
+ overflow: auto;
+ }
+
+ .content {
+ max-width: 700px;
+ padding: 20px 15px;
+ padding-top: 60px;
+ padding-left: 25px;
+
+ h2 {
+ color: $ui-secondary-color;
+ font-size: 24px;
+ line-height: 28px;
+ font-weight: 400;
+ margin-bottom: 40px;
+ }
+
+ h3 {
+ color: $ui-secondary-color;
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: 400;
+ margin-bottom: 30px;
+ }
+
+ h6 {
+ font-size: 16px;
+ color: $ui-secondary-color;
+ line-height: 28px;
+ font-weight: 400;
+ }
+
+ & > p {
+ font-size: 14px;
+ line-height: 18px;
+ color: $ui-secondary-color;
+ margin-bottom: 20px;
+
+ strong {
+ color: $primary-text-color;
+ font-weight: 500;
+ }
+ }
+
+ hr {
+ margin: 20px 0;
+ border: 0;
+ background: transparent;
+ border-bottom: 1px solid $ui-base-color;
+ }
+
+ .muted-hint {
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+ }
+
+ .positive-hint {
+ color: $valid-value-color;
+ font-weight: 500;
+ }
+ }
+
+ .simple_form {
+ max-width: 400px;
+
+ &.edit_user,
+ &.new_form_admin_settings,
+ &.new_form_two_factor_confirmation,
+ &.new_form_delete_confirmation,
+ &.new_import,
+ &.new_domain_block,
+ &.edit_domain_block {
+ max-width: none;
+ }
+
+ .form_two_factor_confirmation_code,
+ .form_delete_confirmation_password {
+ max-width: 400px;
+ }
+
+ .actions {
+ max-width: 400px;
+ }
+ }
+
+ @media screen and (max-width: 600px) {
+ display: block;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+
+ .sidebar-wrapper,
+ .content-wrapper {
+ flex: 0 0 auto;
+ height: auto;
+ overflow: initial;
+ }
+
+ .sidebar {
+ width: 100%;
+ padding: 10px 0;
+ height: auto;
+
+ .logo {
+ margin: 20px auto;
+ }
+ }
+
+ .content {
+ padding-top: 20px;
+ }
+ }
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+
+ .filter-subset {
+ flex: 0 0 auto;
+ margin: 0 40px 10px 0;
+
+ &:last-child {
+ margin-bottom: 20px;
+ }
+
+ ul {
+ margin-top: 5px;
+ list-style: none;
+
+ li {
+ display: inline-block;
+ margin-right: 5px;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ text-transform: uppercase;
+ font-size: 12px;
+ }
+
+ a {
+ display: inline-block;
+ color: rgba($primary-text-color, 0.7);
+ text-decoration: none;
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: 500;
+ border-bottom: 2px solid $ui-base-color;
+
+ &:hover {
+ color: $primary-text-color;
+ border-bottom: 2px solid lighten($ui-base-color, 5%);
+ }
+
+ &.selected {
+ color: $ui-highlight-color;
+ border-bottom: 2px solid $ui-highlight-color;
+ }
+ }
+ }
+}
+
+.report-accounts {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.report-accounts__item {
+ display: flex;
+ flex: 250px;
+ flex-direction: column;
+ margin: 0 5px;
+
+ & > strong {
+ display: block;
+ margin: 0 0 10px -5px;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 18px;
+ color: $ui-secondary-color;
+ }
+
+ .account-card {
+ flex: 1 1 auto;
+ }
+}
+
+.report-status,
+.account-status {
+ display: flex;
+ margin-bottom: 10px;
+
+ .activity-stream {
+ flex: 2 0 0;
+ margin-right: 20px;
+ max-width: calc(100% - 60px);
+
+ .entry {
+ border-radius: 4px;
+ }
+ }
+}
+
+.report-status__actions,
+.account-status__actions {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+
+ .icon-button {
+ font-size: 24px;
+ width: 24px;
+ text-align: center;
+ margin-bottom: 10px;
+ }
+}
+
+.batch-form-box {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 5px;
+
+ #form_status_batch_action {
+ margin: 0 5px 5px 0;
+ font-size: 14px;
+ }
+
+ input.button {
+ margin: 0 5px 5px 0;
+ }
+
+ .media-spoiler-toggle-buttons {
+ margin-left: auto;
+
+ .button {
+ overflow: visible;
+ margin: 0 0 5px 5px;
+ float: right;
+ }
+ }
+}
+
+.batch-checkbox,
+.batch-checkbox-all {
+ display: flex;
+ align-items: center;
+ margin-right: 5px;
+}
+
+.back-link {
+ margin-bottom: 10px;
+ font-size: 14px;
+
+ a {
+ color: $classic-highlight-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
new file mode 100644
index 000000000..b5d77ff63
--- /dev/null
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -0,0 +1,122 @@
+body {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ background: $ui-base-color;
+ background-size: cover;
+ background-attachment: fixed;
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 400;
+ color: $primary-text-color;
+ padding-bottom: 20px;
+ text-rendering: optimizelegibility;
+ font-feature-settings: "kern";
+ text-size-adjust: none;
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ -webkit-tap-highlight-color: transparent;
+
+ &.system-font {
+ // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+ // -apple-system => Safari <11 specific
+ // BlinkMacSystemFont => Chrome <56 on macOS specific
+ // Segoe UI => Windows 7/8/10
+ // Oxygen => KDE
+ // Ubuntu => Unity/Ubuntu
+ // Cantarell => GNOME
+ // Fira Sans => Firefox OS
+ // Droid Sans => Older Androids (<4.0)
+ // Helvetica Neue => Older macOS <10.11
+ // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+ }
+
+ &.app-body {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ background: $ui-base-color;
+ }
+
+ &.about-body {
+ background: darken($ui-base-color, 8%);
+ padding-bottom: 0;
+ }
+
+ &.tag-body {
+ background: darken($ui-base-color, 8%);
+ padding-bottom: 0;
+ }
+
+ &.embed {
+ background: transparent;
+ margin: 0;
+ padding-bottom: 0;
+
+ .container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+ }
+
+ &.admin {
+ background: darken($ui-base-color, 4%);
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ }
+
+ &.error {
+ position: absolute;
+ text-align: center;
+ color: $ui-primary-color;
+ background: $ui-base-color;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ .dialog {
+ vertical-align: middle;
+ margin: 20px;
+
+ img {
+ display: block;
+ max-width: 470px;
+ width: 100%;
+ height: auto;
+ margin-top: -120px;
+ }
+
+ h1 {
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: 400;
+ }
+ }
+ }
+}
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.app-holder {
+ &,
+ & > div {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ }
+}
diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss
new file mode 100644
index 000000000..31053decc
--- /dev/null
+++ b/app/javascript/styles/mastodon/boost.scss
@@ -0,0 +1,18 @@
+@function hex-color($color) {
+ @if type-of($color) == 'color' {
+ $color: str-slice(ie-hex-str($color), 4);
+ }
+ @return '%23' + unquote($color)
+}
+
+button.icon-button i.fa-retweet {
+ background-image: url("data:image/svg+xml;utf8, ");
+
+ &:hover {
+ background-image: url("data:image/svg+xml;utf8, ");
+ }
+}
+
+button.icon-button.disabled i.fa-retweet {
+ background-image: url("data:image/svg+xml;utf8, ");
+}
diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss
new file mode 100644
index 000000000..90d98cc8c
--- /dev/null
+++ b/app/javascript/styles/mastodon/compact_header.scss
@@ -0,0 +1,34 @@
+.compact-header {
+ h1 {
+ font-size: 24px;
+ line-height: 28px;
+ color: $ui-primary-color;
+ font-weight: 500;
+ margin-bottom: 20px;
+ padding: 0 10px;
+ word-wrap: break-word;
+
+ @media screen and (max-width: 740px) {
+ text-align: center;
+ padding: 20px 10px 0;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ small {
+ font-weight: 400;
+ color: $ui-secondary-color;
+ }
+
+ img {
+ display: inline-block;
+ margin-bottom: -5px;
+ margin-right: 15px;
+ width: 36px;
+ height: 36px;
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
new file mode 100644
index 000000000..0ded6f159
--- /dev/null
+++ b/app/javascript/styles/mastodon/components.scss
@@ -0,0 +1,4377 @@
+@import 'variables';
+
+.app-body {
+ -webkit-overflow-scrolling: touch;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+}
+
+.button {
+ background-color: darken($ui-highlight-color, 3%);
+ border: 10px none;
+ border-radius: 4px;
+ box-sizing: border-box;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: inline-block;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ height: 36px;
+ letter-spacing: 0;
+ line-height: 36px;
+ overflow: hidden;
+ padding: 0 16px;
+ position: relative;
+ text-align: center;
+ text-transform: uppercase;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ transition: all 100ms ease-in;
+ white-space: nowrap;
+ width: auto;
+
+ &:active,
+ &:focus,
+ &:hover {
+ background-color: lighten($ui-highlight-color, 7%);
+ transition: all 200ms ease-out;
+ }
+
+ &:disabled {
+ background-color: $ui-primary-color;
+ cursor: default;
+ }
+
+ &.button-alternative {
+ font-size: 16px;
+ line-height: 36px;
+ height: auto;
+ color: $ui-base-color;
+ background: $ui-primary-color;
+ text-transform: none;
+ padding: 4px 16px;
+
+ &:active,
+ &:focus,
+ &:hover {
+ background-color: lighten($ui-primary-color, 4%);
+ }
+ }
+
+ &.button-secondary {
+ font-size: 16px;
+ line-height: 36px;
+ height: auto;
+ color: $ui-primary-color;
+ text-transform: none;
+ background: transparent;
+ padding: 3px 15px;
+ border-radius: 4px;
+ border: 1px solid $ui-primary-color;
+
+ &:active,
+ &:focus,
+ &:hover {
+ border-color: lighten($ui-primary-color, 4%);
+ color: lighten($ui-primary-color, 4%);
+ }
+ }
+
+ &.button--block {
+ display: block;
+ width: 100%;
+ }
+}
+
+.column__wrapper {
+ display: flex;
+ flex: 1 1 auto;
+ position: relative;
+}
+
+.column-icon {
+ background: lighten($ui-base-color, 4%);
+ color: $ui-primary-color;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 15px;
+ position: absolute;
+ right: 0;
+ top: -48px;
+ z-index: 3;
+
+ &:hover {
+ color: lighten($ui-primary-color, 7%);
+ }
+}
+
+.icon-button {
+ display: inline-block;
+ padding: 0;
+ color: $ui-base-lighter-color;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: color 100ms ease-in;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-base-color, 33%);
+ transition: color 200ms ease-out;
+ }
+
+ &.disabled {
+ color: lighten($ui-base-color, 13%);
+ cursor: default;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+ }
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &.inverted {
+ color: lighten($ui-base-color, 33%);
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $ui-base-lighter-color;
+ }
+
+ &.disabled {
+ color: $ui-primary-color;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+
+ &.disabled {
+ color: lighten($ui-highlight-color, 13%);
+ }
+ }
+ }
+
+ &.overlayed {
+ box-sizing: content-box;
+ background: rgba($base-overlay-background, 0.6);
+ color: rgba($primary-text-color, 0.7);
+ border-radius: 4px;
+ padding: 2px;
+
+ &:hover {
+ background: rgba($base-overlay-background, 0.9);
+ }
+ }
+}
+
+.text-icon-button {
+ color: lighten($ui-base-color, 33%);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 11px;
+ padding: 0 3px;
+ line-height: 27px;
+ outline: 0;
+ transition: color 100ms ease-in;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $ui-base-lighter-color;
+ transition: color 200ms ease-out;
+ }
+
+ &.disabled {
+ color: lighten($ui-base-color, 13%);
+ cursor: default;
+ }
+
+ &.active {
+ color: $ui-highlight-color;
+ }
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+}
+
+.dropdown-menu {
+ position: absolute;
+}
+
+.dropdown--active .icon-button {
+ color: $ui-highlight-color;
+}
+
+.dropdown--active::after {
+ @media screen and (min-width: 631px) {
+ content: "";
+ display: block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 4.5px 7.8px;
+ border-color: transparent transparent $ui-secondary-color;
+ bottom: 8px;
+ right: 104px;
+ }
+}
+
+.invisible {
+ font-size: 0;
+ line-height: 0;
+ display: inline-block;
+ width: 0;
+ height: 0;
+ position: absolute;
+
+ img,
+ svg {
+ margin: 0 !important;
+ border: 0 !important;
+ padding: 0 !important;
+ width: 0 !important;
+ height: 0 !important;
+ }
+}
+
+.ellipsis {
+ &::after {
+ content: "…";
+ }
+}
+
+.lightbox .icon-button {
+ color: $ui-base-color;
+}
+
+.compose-form {
+ padding: 10px;
+}
+
+.compose-form__warning {
+ color: darken($ui-secondary-color, 65%);
+ margin-bottom: 15px;
+ background: $ui-primary-color;
+ box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
+ padding: 8px 10px;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 400;
+
+ strong {
+ color: darken($ui-secondary-color, 65%);
+ font-weight: 500;
+ }
+
+ a {
+ color: darken($ui-primary-color, 33%);
+ font-weight: 500;
+ text-decoration: underline;
+
+ &:hover,
+ &:active,
+ &:focus {
+ text-decoration: none;
+ }
+ }
+}
+
+.compose-form__modifiers {
+ color: $ui-base-color;
+ font-family: inherit;
+ font-size: 14px;
+ background: $simple-background-color;
+ border-radius: 0 0 4px;
+}
+
+.compose-form__buttons-wrapper {
+ display: flex;
+ justify-content: space-between;
+}
+
+.compose-form__buttons {
+ padding: 10px;
+ background: darken($simple-background-color, 8%);
+ box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
+ border-radius: 0 0 4px 4px;
+ display: flex;
+
+ .icon-button {
+ box-sizing: content-box;
+ padding: 0 3px;
+ }
+}
+
+.compose-form__upload-button-icon {
+ line-height: 27px;
+}
+
+.compose-form__sensitive-button {
+ display: none;
+
+ &.compose-form__sensitive-button--visible {
+ display: block;
+ }
+
+ .compose-form__sensitive-button__icon {
+ line-height: 27px;
+ }
+}
+
+.compose-form__upload-wrapper {
+ overflow: hidden;
+}
+
+.compose-form__uploads-wrapper {
+ display: flex;
+ flex-direction: row;
+ padding: 5px;
+ flex-wrap: wrap;
+}
+
+.compose-form__upload {
+ flex: 1 1 0;
+ min-width: 40%;
+ margin: 5px;
+
+ &-description {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+ padding: 10px;
+ opacity: 0;
+ transition: opacity .1s ease;
+
+ input {
+ background: transparent;
+ color: $ui-secondary-color;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+
+ &:focus {
+ color: $white;
+ }
+
+ &::placeholder {
+ opacity: 0.54;
+ color: $ui-secondary-color;
+ }
+ }
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ .icon-button {
+ mix-blend-mode: difference;
+ }
+}
+
+.compose-form__upload-thumbnail {
+ border-radius: 4px;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ height: 100px;
+ width: 100%;
+}
+
+.compose-form__label {
+ display: block;
+ line-height: 24px;
+ vertical-align: middle;
+
+ &.with-border {
+ border-top: 1px solid $ui-base-color;
+ padding-top: 10px;
+ }
+
+ .compose-form__label__text {
+ display: inline-block;
+ vertical-align: middle;
+ margin-bottom: 14px;
+ margin-left: 8px;
+ color: $ui-primary-color;
+ }
+}
+
+.compose-form__textarea,
+.follow-form__input {
+ background: $simple-background-color;
+
+ &:disabled {
+ background: $ui-secondary-color;
+ }
+}
+
+.compose-form__autosuggest-wrapper {
+ position: relative;
+
+ .emoji-picker-dropdown {
+ position: absolute;
+ right: 5px;
+ top: 5px;
+
+ ::-webkit-scrollbar-track:hover,
+ ::-webkit-scrollbar-track:active {
+ background-color: rgba($base-overlay-background, 0.3);
+ }
+ }
+}
+
+.compose-form__publish {
+ display: flex;
+ min-width: 0;
+}
+
+.compose-form__publish-button-wrapper {
+ overflow: hidden;
+ padding-top: 10px;
+}
+
+.emojione {
+ display: inline-block;
+ font-size: inherit;
+ vertical-align: middle;
+ object-fit: contain;
+ margin: -.2ex .15em .2ex;
+ width: 16px;
+ height: 16px;
+
+ img {
+ width: auto;
+ }
+}
+
+.reply-indicator {
+ border-radius: 4px 4px 0 0;
+ position: relative;
+ bottom: -2px;
+ background: $ui-primary-color;
+ padding: 10px;
+}
+
+.reply-indicator__header {
+ margin-bottom: 5px;
+ overflow: hidden;
+}
+
+.reply-indicator__cancel {
+ float: right;
+ line-height: 24px;
+}
+
+.reply-indicator__display-name {
+ color: $ui-base-color;
+ display: block;
+ max-width: 100%;
+ line-height: 24px;
+ overflow: hidden;
+ padding-right: 25px;
+ text-decoration: none;
+}
+
+.reply-indicator__display-avatar {
+ float: left;
+ margin-right: 5px;
+}
+
+.status__content--with-action {
+ cursor: pointer;
+}
+
+.status__content,
+.reply-indicator__content {
+ font-size: 15px;
+ line-height: 20px;
+ word-wrap: break-word;
+ font-weight: 400;
+ overflow: hidden;
+ white-space: pre-wrap;
+ padding-top: 5px;
+
+ &.status__content--with-spoiler {
+ white-space: normal;
+
+ .status__content__text {
+ white-space: pre-wrap;
+ }
+ }
+
+ .emojione {
+ width: 20px;
+ height: 20px;
+ margin: -5px 0 0;
+ }
+
+ p {
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $ui-secondary-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+
+ .fa {
+ color: lighten($ui-base-color, 40%);
+ }
+ }
+
+ &.mention {
+ &:hover {
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .fa {
+ color: lighten($ui-base-color, 30%);
+ }
+ }
+
+ .status__content__spoiler-link {
+ background: lighten($ui-base-color, 30%);
+
+ &:hover {
+ background: lighten($ui-base-color, 33%);
+ text-decoration: none;
+ }
+ }
+
+ .status__content__text {
+ display: none;
+
+ &.status__content__text--visible {
+ display: block;
+ }
+ }
+}
+
+.status__content__spoiler-link {
+ display: inline-block;
+ border-radius: 2px;
+ background: transparent;
+ border: 0;
+ color: lighten($ui-base-color, 8%);
+ font-weight: 500;
+ font-size: 11px;
+ padding: 0 6px;
+ text-transform: uppercase;
+ line-height: inherit;
+ cursor: pointer;
+}
+
+.status__prepend-icon-wrapper {
+ left: -26px;
+ position: absolute;
+}
+
+.focusable {
+ &:focus {
+ outline: 0;
+ background: lighten($ui-base-color, 4%);
+
+ .status.status-direct {
+ background: lighten($ui-base-color, 12%);
+ }
+
+ .detailed-status,
+ .detailed-status__action-bar {
+ background: lighten($ui-base-color, 8%);
+ }
+ }
+}
+
+.status {
+ padding: 8px 10px;
+ padding-left: 68px;
+ position: relative;
+ min-height: 48px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ cursor: default;
+
+ @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
+ // Add margin to avoid Edge auto-hiding scrollbar appearing over content.
+ // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
+ padding-right: 26px; // 10px + 16px
+ }
+
+ @keyframes fade {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+
+ opacity: 1;
+ animation: fade 150ms linear;
+
+ .video-player {
+ margin-top: 8px;
+ }
+
+ &.status-direct {
+ background: lighten($ui-base-color, 8%);
+
+ .icon-button.disabled {
+ color: lighten($ui-base-color, 16%);
+ }
+ }
+
+ &.light {
+ .status__relative-time {
+ color: $ui-primary-color;
+ }
+
+ .status__display-name {
+ color: $ui-base-color;
+ }
+
+ .display-name {
+ strong {
+ color: $ui-base-color;
+ }
+
+ span {
+ color: $ui-primary-color;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+ }
+}
+
+.notification-favourite {
+ .status.status-direct {
+ background: transparent;
+
+ .icon-button.disabled {
+ color: lighten($ui-base-color, 13%);
+ }
+ }
+}
+
+.status__relative-time {
+ color: $ui-base-lighter-color;
+ float: right;
+ font-size: 14px;
+}
+
+.status__display-name {
+ color: $ui-base-lighter-color;
+}
+
+.status__info .status__display-name {
+ display: block;
+ max-width: 100%;
+ padding-right: 25px;
+}
+
+.status__info {
+ font-size: 15px;
+}
+
+.status-check-box {
+ border-bottom: 1px solid $ui-secondary-color;
+ display: flex;
+
+ .status__content {
+ flex: 1 1 auto;
+ padding: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.status-check-box-toggle {
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ justify-content: center;
+ padding: 10px;
+}
+
+.status__prepend {
+ margin-left: 68px;
+ color: $ui-base-lighter-color;
+ padding: 8px 0;
+ padding-bottom: 2px;
+ font-size: 14px;
+ position: relative;
+
+ .status__display-name strong {
+ color: $ui-base-lighter-color;
+ }
+
+ > span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.status__action-bar {
+ align-items: center;
+ display: flex;
+ margin-top: 5px;
+}
+
+.status__action-bar-button {
+ float: left;
+ margin-right: 18px;
+}
+
+.status__action-bar-dropdown {
+ float: left;
+ height: 23.15px;
+ width: 23.15px;
+}
+
+.detailed-status__action-bar-dropdown {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.detailed-status {
+ background: lighten($ui-base-color, 4%);
+ padding: 14px 10px;
+
+ .status__content {
+ font-size: 19px;
+ line-height: 24px;
+
+ .emojione {
+ width: 24px;
+ height: 24px;
+ margin: -5px 0 0;
+ }
+ }
+
+ .video-player {
+ margin-top: 8px;
+ }
+}
+
+.detailed-status__meta {
+ margin-top: 15px;
+ color: $ui-base-lighter-color;
+ font-size: 14px;
+ line-height: 18px;
+}
+
+.detailed-status__action-bar {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: row;
+ padding: 10px 0;
+}
+
+.detailed-status__link {
+ color: inherit;
+ text-decoration: none;
+}
+
+.detailed-status__favorites,
+.detailed-status__reblogs {
+ display: inline-block;
+ font-weight: 500;
+ font-size: 12px;
+ margin-left: 6px;
+}
+
+.reply-indicator__content {
+ color: $ui-base-color;
+ font-size: 14px;
+
+ a {
+ color: lighten($ui-base-color, 20%);
+ }
+}
+
+.account {
+ padding: 10px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ .account__display-name {
+ flex: 1 1 auto;
+ display: block;
+ color: $ui-primary-color;
+ overflow: hidden;
+ text-decoration: none;
+ font-size: 14px;
+ }
+}
+
+.account__wrapper {
+ display: flex;
+}
+
+.account__avatar-wrapper {
+ float: left;
+ margin-left: 12px;
+ margin-right: 12px;
+}
+
+.account__avatar {
+ @include avatar-radius();
+ position: relative;
+ cursor: pointer;
+
+ &-inline {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 5px;
+ }
+}
+
+.account__avatar-overlay {
+ @include avatar-size(48px);
+
+ &-base {
+ @include avatar-radius();
+ @include avatar-size(36px);
+ }
+
+ &-overlay {
+ @include avatar-radius();
+ @include avatar-size(24px);
+
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 1;
+ }
+}
+
+.account__relationship {
+ height: 18px;
+ padding: 10px;
+ white-space: nowrap;
+}
+
+.account__header {
+ flex: 0 0 auto;
+ background: lighten($ui-base-color, 4%);
+ text-align: center;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+
+ & > div {
+ background: rgba(lighten($ui-base-color, 4%), 0.9);
+ padding: 20px 10px;
+ }
+
+ .account__header__content {
+ color: $ui-secondary-color;
+ }
+
+ .account__header__display-name {
+ color: $primary-text-color;
+ display: inline-block;
+ width: 100%;
+ font-size: 20px;
+ line-height: 27px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .account__header__username {
+ color: $ui-highlight-color;
+ font-size: 14px;
+ font-weight: 400;
+ display: block;
+ margin-bottom: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.account__disclaimer {
+ padding: 10px;
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ color: $ui-base-lighter-color;
+
+ strong {
+ font-weight: 500;
+ }
+
+ a {
+ font-weight: 500;
+ color: inherit;
+ text-decoration: underline;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: none;
+ }
+ }
+}
+
+.account__header__content {
+ color: $ui-primary-color;
+ font-size: 14px;
+ font-weight: 400;
+ overflow: hidden;
+ word-break: normal;
+ word-wrap: break-word;
+
+ p {
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+}
+
+.account__header__display-name {
+ .emojione {
+ width: 25px;
+ height: 25px;
+ }
+}
+
+.account__action-bar {
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ line-height: 36px;
+ overflow: hidden;
+ flex: 0 0 auto;
+ display: flex;
+}
+
+.account__action-bar-dropdown {
+ flex: 0 1 calc(50% - 140px);
+ padding: 10px;
+
+ .dropdown--active {
+ .dropdown__content.dropdown__right {
+ left: 6px;
+ right: initial;
+ }
+
+ &::after {
+ bottom: initial;
+ margin-left: 11px;
+ margin-top: -7px;
+ right: initial;
+ }
+ }
+}
+
+.account__action-bar-links {
+ display: flex;
+ flex: 1 1 auto;
+ line-height: 18px;
+}
+
+.account__action-bar__tab {
+ text-decoration: none;
+ overflow: hidden;
+ flex: 0 1 80px;
+ border-left: 1px solid lighten($ui-base-color, 8%);
+ padding: 10px 5px;
+
+ & > span {
+ display: block;
+ text-transform: uppercase;
+ font-size: 11px;
+ color: $ui-primary-color;
+ }
+
+ strong {
+ display: block;
+ font-size: 15px;
+ font-weight: 500;
+ color: $primary-text-color;
+ }
+
+ abbr {
+ color: $ui-base-lighter-color;
+ }
+}
+
+.account__header__avatar {
+ background-size: 90px 90px;
+ display: block;
+ height: 90px;
+ margin: 0 auto 10px;
+ overflow: hidden;
+ width: 90px;
+}
+
+.account-authorize {
+ padding: 14px 10px;
+
+ .detailed-status__display-name {
+ display: block;
+ margin-bottom: 15px;
+ overflow: hidden;
+ }
+}
+
+.account-authorize__avatar {
+ float: left;
+ margin-right: 10px;
+}
+
+.status__display-name,
+.status__relative-time,
+.detailed-status__display-name,
+.detailed-status__datetime,
+.detailed-status__application,
+.account__display-name {
+ text-decoration: none;
+}
+
+.status__display-name,
+.account__display-name {
+ strong {
+ color: $primary-text-color;
+ }
+}
+
+.muted {
+ .emojione {
+ opacity: 0.5;
+ }
+}
+
+.status__display-name,
+.reply-indicator__display-name,
+.detailed-status__display-name,
+.account__display-name {
+ &:hover strong {
+ text-decoration: underline;
+ }
+}
+
+.account__display-name strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.detailed-status__application,
+.detailed-status__datetime {
+ color: inherit;
+}
+
+.detailed-status__display-name {
+ color: $ui-secondary-color;
+ display: block;
+ line-height: 24px;
+ margin-bottom: 15px;
+ overflow: hidden;
+
+ strong,
+ span {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ strong {
+ font-size: 16px;
+ color: $primary-text-color;
+ }
+}
+
+.detailed-status__display-avatar {
+ float: left;
+ margin-right: 10px;
+}
+
+.status__avatar {
+ height: 48px;
+ left: 10px;
+ position: absolute;
+ top: 10px;
+ width: 48px;
+}
+
+.muted {
+ .status__content p,
+ .status__content a {
+ color: $ui-base-lighter-color;
+ }
+
+ .status__display-name strong {
+ color: $ui-base-lighter-color;
+ }
+
+ .status__avatar {
+ opacity: 0.5;
+ }
+
+ a.status__content__spoiler-link {
+ background: $ui-base-lighter-color;
+ color: lighten($ui-base-color, 4%);
+
+ &:hover {
+ background: lighten($ui-base-color, 29%);
+ text-decoration: none;
+ }
+ }
+}
+
+.notification__message {
+ margin-left: 68px;
+ padding: 8px 0;
+ padding-bottom: 0;
+ cursor: default;
+ color: $ui-primary-color;
+ font-size: 15px;
+ position: relative;
+
+ .fa {
+ color: $ui-highlight-color;
+ }
+
+ > span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.notification__favourite-icon-wrapper {
+ left: -26px;
+ position: absolute;
+
+ .star-icon {
+ color: $gold-star;
+ }
+}
+
+.star-icon.active {
+ color: $gold-star;
+}
+
+.notification__display-name {
+ color: inherit;
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover {
+ color: $primary-text-color;
+ text-decoration: underline;
+ }
+}
+
+.display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.display-name__html {
+ font-weight: 500;
+}
+
+.display-name__account {
+ font-size: 14px;
+}
+
+.status__relative-time,
+.detailed-status__datetime {
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.image-loader {
+ position: relative;
+
+ &.image-loader--loading {
+ .image-loader__preview-canvas {
+ filter: blur(2px);
+ }
+ }
+
+ .image-loader__img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ max-width: 100%;
+ max-height: 100%;
+ background-image: none;
+ }
+
+ &.image-loader--amorphous {
+ position: static;
+
+ .image-loader__preview-canvas {
+ display: none;
+ }
+
+ .image-loader__img {
+ position: static;
+ width: auto;
+ height: auto;
+ }
+ }
+}
+
+.navigation-bar {
+ padding: 10px;
+ display: flex;
+ flex-shrink: 0;
+ cursor: default;
+ color: $ui-primary-color;
+
+ strong {
+ color: $primary-text-color;
+ }
+
+ .permalink {
+ text-decoration: none;
+ }
+
+ .icon-button {
+ pointer-events: none;
+ opacity: 0;
+ }
+}
+
+.navigation-bar__profile {
+ flex: 1 1 auto;
+ margin-left: 8px;
+ overflow: hidden;
+}
+
+.navigation-bar__profile-account {
+ display: block;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.navigation-bar__profile-edit {
+ color: inherit;
+ text-decoration: none;
+}
+
+.dropdown {
+ display: inline-block;
+}
+
+.dropdown__content {
+ display: none;
+ position: absolute;
+}
+
+.dropdown-menu__separator {
+ border-bottom: 1px solid darken($ui-secondary-color, 8%);
+ margin: 5px 7px 6px;
+ height: 0;
+}
+
+.dropdown-menu {
+ background: $ui-secondary-color;
+ padding: 4px 0;
+ border-radius: 4px;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+ ul {
+ list-style: none;
+ }
+}
+
+.dropdown-menu__arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border: 0 solid transparent;
+
+ &.left {
+ right: -5px;
+ margin-top: -5px;
+ border-width: 5px 0 5px 5px;
+ border-left-color: $ui-secondary-color;
+ }
+
+ &.top {
+ bottom: -5px;
+ margin-left: -13px;
+ border-width: 5px 7px 0;
+ border-top-color: $ui-secondary-color;
+ }
+
+ &.bottom {
+ top: -5px;
+ margin-left: -13px;
+ border-width: 0 7px 5px;
+ border-bottom-color: $ui-secondary-color;
+ }
+
+ &.right {
+ left: -5px;
+ margin-top: -5px;
+ border-width: 5px 5px 5px 0;
+ border-right-color: $ui-secondary-color;
+ }
+}
+
+.dropdown-menu__item {
+ a {
+ font-size: 13px;
+ line-height: 18px;
+ display: block;
+ padding: 4px 14px;
+ box-sizing: border-box;
+ text-decoration: none;
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus,
+ &:hover,
+ &:active {
+ background: $ui-highlight-color;
+ color: $ui-secondary-color;
+ outline: 0;
+ }
+ }
+}
+
+.dropdown--active .dropdown__content {
+ display: block;
+ line-height: 18px;
+ max-width: 311px;
+ right: 0;
+ text-align: left;
+ z-index: 9999;
+
+ & > ul {
+ list-style: none;
+ background: $ui-secondary-color;
+ padding: 4px 0;
+ border-radius: 4px;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+ min-width: 140px;
+ position: relative;
+ }
+
+ &.dropdown__right {
+ right: 0;
+ }
+
+ &.dropdown__left {
+ & > ul {
+ left: -98px;
+ }
+ }
+
+ & > ul > li > a {
+ font-size: 13px;
+ line-height: 18px;
+ display: block;
+ padding: 4px 14px;
+ box-sizing: border-box;
+ text-decoration: none;
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus {
+ outline: 0;
+ }
+
+ &:hover {
+ background: $ui-highlight-color;
+ color: $ui-secondary-color;
+ }
+ }
+}
+
+.dropdown__icon {
+ vertical-align: middle;
+}
+
+.static-content {
+ padding: 10px;
+ padding-top: 20px;
+ color: $ui-base-lighter-color;
+
+ h1 {
+ font-size: 16px;
+ font-weight: 500;
+ margin-bottom: 40px;
+ text-align: center;
+ }
+
+ p {
+ font-size: 13px;
+ margin-bottom: 20px;
+ }
+}
+
+.columns-area {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: row;
+ justify-content: flex-start;
+ overflow-x: auto;
+ position: relative;
+}
+
+@media screen and (min-width: 360px) {
+ .columns-area {
+ padding: 10px;
+ }
+
+ .react-swipeable-view-container .columns-area {
+ height: calc(100% - 20px) !important;
+ }
+}
+
+.react-swipeable-view-container {
+ &,
+ .columns-area,
+ .drawer,
+ .column {
+ height: 100%;
+ }
+}
+
+.react-swipeable-view-container > * {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+.column {
+ width: 330px;
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+
+ > .scrollable {
+ background: $ui-base-color;
+ }
+}
+
+.ui {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ background: darken($ui-base-color, 7%);
+}
+
+.drawer {
+ width: 300px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow-y: hidden;
+}
+
+.drawer__tab {
+ display: block;
+ flex: 1 1 auto;
+ padding: 15px 5px 13px;
+ color: $ui-primary-color;
+ text-decoration: none;
+ text-align: center;
+ font-size: 16px;
+ border-bottom: 2px solid transparent;
+}
+
+.column,
+.drawer {
+ flex: 1 1 100%;
+ overflow: hidden;
+}
+
+@media screen and (min-width: 360px) {
+ .tabs-bar {
+ margin: 10px;
+ margin-bottom: 0;
+ }
+
+ .search {
+ margin-bottom: 10px;
+ }
+}
+
+@media screen and (max-width: 630px) {
+ .column,
+ .drawer {
+ width: 100%;
+ padding: 0;
+ }
+
+ .columns-area {
+ flex-direction: column;
+ }
+
+ .search__input,
+ .autosuggest-textarea__textarea {
+ font-size: 16px;
+ }
+}
+
+@media screen and (min-width: 631px) {
+ .columns-area {
+ padding: 0;
+ }
+
+ .column,
+ .drawer {
+ flex: 0 0 auto;
+ padding: 10px;
+ padding-left: 5px;
+ padding-right: 5px;
+
+ &:first-child {
+ padding-left: 10px;
+ }
+
+ &:last-child {
+ padding-right: 10px;
+ }
+ }
+
+ .columns-area > div {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+ }
+}
+
+.drawer__pager {
+ box-sizing: border-box;
+ padding: 0;
+ flex-grow: 1;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+}
+
+.drawer__inner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: lighten($ui-base-color, 13%);
+ box-sizing: border-box;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ overflow-y: auto;
+ width: 100%;
+ height: 100%;
+
+ &.darker {
+ background: $ui-base-color;
+ }
+}
+
+.pseudo-drawer {
+ background: lighten($ui-base-color, 13%);
+ font-size: 13px;
+ text-align: left;
+}
+
+.drawer__header {
+ flex: 0 0 auto;
+ font-size: 16px;
+ background: lighten($ui-base-color, 8%);
+ margin-bottom: 10px;
+ display: flex;
+ flex-direction: row;
+
+ a {
+ transition: background 100ms ease-in;
+
+ &:hover {
+ background: lighten($ui-base-color, 3%);
+ transition: background 200ms ease-out;
+ }
+ }
+}
+
+.tabs-bar {
+ display: flex;
+ background: lighten($ui-base-color, 8%);
+ flex: 0 0 auto;
+ overflow-y: auto;
+}
+
+.tabs-bar__link {
+ display: block;
+ flex: 1 1 auto;
+ padding: 15px 10px;
+ color: $primary-text-color;
+ text-decoration: none;
+ text-align: center;
+ font-size: 14px;
+ font-weight: 500;
+ border-bottom: 2px solid lighten($ui-base-color, 8%);
+ transition: all 200ms linear;
+
+ .fa {
+ font-weight: 400;
+ font-size: 16px;
+ }
+
+ &.active {
+ border-bottom: 2px solid $ui-highlight-color;
+ color: $ui-highlight-color;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ @media screen and (min-width: 631px) {
+ background: lighten($ui-base-color, 14%);
+ transition: all 100ms linear;
+ }
+ }
+
+ span {
+ margin-left: 5px;
+ display: none;
+ }
+}
+
+@media screen and (min-width: 600px) {
+ .tabs-bar__link {
+ span {
+ display: inline;
+ }
+ }
+}
+
+@media screen and (min-width: 631px) {
+ .tabs-bar {
+ display: none;
+ }
+}
+
+.scrollable {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ flex: 1 1 auto;
+ -webkit-overflow-scrolling: touch;
+ will-change: transform; // improves perf in mobile Chrome
+
+ &.optionally-scrollable {
+ overflow-y: auto;
+ }
+
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: strict;
+ }
+}
+
+.scrollable.fullscreen {
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: none;
+ }
+}
+
+.column-back-button {
+ background: lighten($ui-base-color, 4%);
+ color: $ui-highlight-color;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ border: 0;
+ text-align: unset;
+ padding: 15px;
+ margin: 0;
+ z-index: 3;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.column-header__back-button {
+ background: lighten($ui-base-color, 4%);
+ border: 0;
+ font-family: inherit;
+ color: $ui-highlight-color;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ padding: 0 5px 0 0;
+ z-index: 3;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:last-child {
+ padding: 0 15px 0 0;
+ }
+}
+
+.column-back-button__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.column-back-button--slim {
+ position: relative;
+}
+
+.column-back-button--slim-button {
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 16px;
+ padding: 15px;
+ position: absolute;
+ right: 0;
+ top: -48px;
+}
+
+.react-toggle {
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+ user-select: none;
+ -webkit-tap-highlight-color: rgba($base-overlay-background, 0);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.react-toggle-screenreader-only {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
+
+.react-toggle--disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ transition: opacity 0.25s;
+}
+
+.react-toggle-track {
+ width: 50px;
+ height: 24px;
+ padding: 0;
+ border-radius: 30px;
+ background-color: $ui-base-color;
+ transition: all 0.2s ease;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background-color: darken($ui-base-color, 10%);
+}
+
+.react-toggle--checked .react-toggle-track {
+ background-color: $ui-highlight-color;
+}
+
+.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background-color: lighten($ui-highlight-color, 10%);
+}
+
+.react-toggle-track-check {
+ position: absolute;
+ width: 14px;
+ height: 10px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ line-height: 0;
+ left: 8px;
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-check {
+ opacity: 1;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle-track-x {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ line-height: 0;
+ right: 10px;
+ opacity: 1;
+ transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-x {
+ opacity: 0;
+}
+
+.react-toggle-thumb {
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 22px;
+ height: 22px;
+ border: 1px solid $ui-base-color;
+ border-radius: 50%;
+ background-color: darken($simple-background-color, 2%);
+ box-sizing: border-box;
+ transition: all 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-thumb {
+ left: 27px;
+ border-color: $ui-highlight-color;
+}
+
+.column-link {
+ background: lighten($ui-base-color, 8%);
+ color: $primary-text-color;
+ display: block;
+ font-size: 16px;
+ padding: 15px;
+ text-decoration: none;
+
+ &:hover {
+ background: lighten($ui-base-color, 11%);
+ }
+}
+
+.column-link__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.column-subheading {
+ background: $ui-base-color;
+ color: $ui-base-lighter-color;
+ padding: 8px 20px;
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+ cursor: default;
+}
+
+.autosuggest-textarea,
+.spoiler-input {
+ position: relative;
+}
+
+.autosuggest-textarea__textarea,
+.spoiler-input__input {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ color: $ui-base-color;
+ background: $simple-background-color;
+ padding: 10px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ border: 0;
+ outline: 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+}
+
+.spoiler-input__input {
+ border-radius: 4px;
+}
+
+.autosuggest-textarea__textarea {
+ min-height: 100px;
+ border-radius: 4px 4px 0 0;
+ padding-bottom: 0;
+ padding-right: 10px + 22px;
+ resize: none;
+
+ @media screen and (max-width: 600px) {
+ height: 100px !important; // prevent auto-resize textarea
+ resize: vertical;
+ }
+}
+
+.autosuggest-textarea__suggestions {
+ box-sizing: border-box;
+ display: none;
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ z-index: 99;
+ box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+ background: $ui-secondary-color;
+ border-radius: 0 0 4px 4px;
+ color: $ui-base-color;
+ font-size: 14px;
+ padding: 6px;
+
+ &.autosuggest-textarea__suggestions--visible {
+ display: block;
+ }
+}
+
+.autosuggest-textarea__suggestions__item {
+ padding: 10px;
+ cursor: pointer;
+ border-radius: 4px;
+
+ &:hover,
+ &:focus,
+ &:active,
+ &.selected {
+ background: darken($ui-secondary-color, 10%);
+ }
+}
+
+.autosuggest-account,
+.autosuggest-emoji {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ line-height: 18px;
+ font-size: 14px;
+}
+
+.autosuggest-account-icon,
+.autosuggest-emoji img {
+ display: block;
+ margin-right: 8px;
+ width: 16px;
+ height: 16px;
+}
+
+.autosuggest-account .display-name__account {
+ color: lighten($ui-base-color, 36%);
+}
+
+.character-counter__wrapper {
+ line-height: 36px;
+ margin: 0 16px 0 8px;
+ padding-top: 10px;
+}
+
+.character-counter {
+ cursor: default;
+ font-size: 16px;
+}
+
+.character-counter--over {
+ color: $warning-red;
+}
+
+.getting-started__wrapper {
+ position: relative;
+ overflow-y: auto;
+}
+
+.getting-started__footer {
+ display: flex;
+ flex-direction: column;
+}
+
+.getting-started {
+ box-sizing: border-box;
+ padding-bottom: 235px;
+ background: url('../images/mastodon-getting-started.png') no-repeat 0 100%;
+ flex: 1 0 auto;
+
+ p {
+ color: $ui-secondary-color;
+ }
+
+ a {
+ color: $ui-base-lighter-color;
+ }
+}
+
+.setting-text {
+ color: $ui-primary-color;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid $ui-primary-color;
+ box-sizing: border-box;
+ display: block;
+ font-family: inherit;
+ margin-bottom: 10px;
+ padding: 7px 0;
+ width: 100%;
+
+ &:focus,
+ &:active {
+ color: $primary-text-color;
+ border-bottom-color: $ui-highlight-color;
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+
+ &.light {
+ color: $ui-base-color;
+ border-bottom: 2px solid lighten($ui-base-color, 27%);
+
+ &:focus,
+ &:active {
+ color: $ui-base-color;
+ border-bottom-color: $ui-highlight-color;
+ }
+ }
+}
+
+@import 'boost';
+
+button.icon-button i.fa-retweet {
+ background-position: 0 0;
+ height: 19px;
+ transition: background-position 0.9s steps(10);
+ transition-duration: 0s;
+ vertical-align: middle;
+ width: 22px;
+
+ &::before {
+ display: none !important;
+ }
+}
+
+button.icon-button.active i.fa-retweet {
+ transition-duration: 0.9s;
+ background-position: 0 100%;
+}
+
+.status-card {
+ display: flex;
+ cursor: pointer;
+ font-size: 14px;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-radius: 4px;
+ color: $ui-base-lighter-color;
+ margin-top: 14px;
+ text-decoration: none;
+ overflow: hidden;
+
+ &:hover {
+ background: lighten($ui-base-color, 8%);
+ }
+}
+
+.status-card-video,
+.status-card-rich,
+.status-card-photo {
+ margin-top: 14px;
+ overflow: hidden;
+
+ iframe {
+ width: 100%;
+ height: auto;
+ }
+}
+
+.status-card-photo {
+ display: block;
+ text-decoration: none;
+
+ img {
+ display: block;
+ width: 100%;
+ height: auto;
+ margin: 0;
+ }
+}
+
+.status-card-video {
+ iframe {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.status-card__title {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 5px;
+ color: $ui-primary-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status-card__content {
+ flex: 1 1 auto;
+ overflow: hidden;
+ padding: 14px 14px 14px 8px;
+}
+
+.status-card__description {
+ color: $ui-primary-color;
+}
+
+.status-card__host {
+ display: block;
+ margin-top: 5px;
+ font-size: 13px;
+}
+
+.status-card__image {
+ flex: 0 0 100px;
+ background: lighten($ui-base-color, 8%);
+}
+
+.status-card.horizontal {
+ display: block;
+
+ .status-card__image {
+ width: 100%;
+ }
+
+ .status-card__image-image {
+ border-radius: 4px 4px 0 0;
+ }
+}
+
+.status-card__image-image {
+ border-radius: 4px 0 0 4px;
+ display: block;
+ height: auto;
+ margin: 0;
+ width: 100%;
+}
+
+.load-more {
+ display: block;
+ color: $ui-base-lighter-color;
+ background-color: transparent;
+ border: 0;
+ font-size: inherit;
+ text-align: center;
+ line-height: inherit;
+ margin: 0;
+ padding: 15px;
+ width: 100%;
+ clear: both;
+
+ &:hover {
+ background: lighten($ui-base-color, 2%);
+ }
+}
+
+.missing-indicator {
+ text-align: center;
+ font-size: 16px;
+ font-weight: 500;
+ color: lighten($ui-base-color, 16%);
+ background: $ui-base-color;
+ cursor: default;
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
+
+ & > div {
+ background: url('../images/mastodon-not-found.png') no-repeat center -50px;
+ padding-top: 210px;
+ width: 100%;
+ }
+}
+
+.column-header__wrapper {
+ position: relative;
+ flex: 0 0 auto;
+
+ &.active {
+ &::before {
+ display: block;
+ content: "";
+ position: absolute;
+ top: 35px;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ width: 60%;
+ pointer-events: none;
+ height: 28px;
+ z-index: 1;
+ background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
+ }
+ }
+}
+
+.column-header {
+ display: flex;
+ padding: 15px;
+ font-size: 16px;
+ background: lighten($ui-base-color, 4%);
+ flex: 0 0 auto;
+ cursor: pointer;
+ position: relative;
+ z-index: 2;
+ outline: 0;
+
+ &.active {
+ box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
+
+ .column-header__icon {
+ color: $ui-highlight-color;
+ text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);
+ }
+ }
+
+ &:focus,
+ &:active {
+ outline: 0;
+ }
+}
+
+.column-header__buttons {
+ height: 48px;
+ display: flex;
+ margin: -15px;
+ margin-left: 0;
+}
+
+.column-header__button {
+ background: lighten($ui-base-color, 4%);
+ border: 0;
+ color: $ui-primary-color;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0 15px;
+
+ &:hover {
+ color: lighten($ui-primary-color, 7%);
+ }
+
+ &.active {
+ color: $primary-text-color;
+ background: lighten($ui-base-color, 8%);
+
+ &:hover {
+ color: $primary-text-color;
+ background: lighten($ui-base-color, 8%);
+ }
+ }
+}
+
+.column-header__collapsible {
+ max-height: 70vh;
+ overflow: hidden;
+ overflow-y: auto;
+ color: $ui-primary-color;
+ transition: max-height 150ms ease-in-out, opacity 300ms linear;
+ opacity: 1;
+
+ &.collapsed {
+ max-height: 0;
+ opacity: 0.5;
+ }
+
+ &.animating {
+ overflow-y: hidden;
+ }
+}
+
+.column-header__collapsible-inner {
+ background: lighten($ui-base-color, 8%);
+ padding: 15px;
+}
+
+.column-header__setting-btn {
+ &:hover {
+ color: lighten($ui-primary-color, 4%);
+ text-decoration: underline;
+ }
+}
+
+.column-header__setting-arrows {
+ float: right;
+
+ .column-header__setting-btn {
+ padding: 0 10px;
+
+ &:last-child {
+ padding-right: 0;
+ }
+ }
+}
+
+.column-header__title {
+ display: inline-block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ flex: 1;
+}
+
+.text-btn {
+ display: inline-block;
+ padding: 0;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.column-header__icon {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+.loading-indicator {
+ color: lighten($ui-base-color, 26%);
+ font-size: 12px;
+ font-weight: 400;
+ text-transform: uppercase;
+ overflow: visible;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+
+ span {
+ display: block;
+ float: left;
+ margin-left: 50%;
+ transform: translateX(-50%);
+ margin: 82px 0 0 50%;
+ white-space: nowrap;
+ animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+ }
+}
+
+.loading-indicator__figure {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 0;
+ height: 0;
+ box-sizing: border-box;
+ border: 0 solid lighten($ui-base-color, 26%);
+ border-radius: 50%;
+ animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
+}
+
+@keyframes loader-figure {
+ 0% {
+ width: 0;
+ height: 0;
+ background-color: lighten($ui-base-color, 26%);
+ }
+
+ 29% {
+ background-color: lighten($ui-base-color, 26%);
+ }
+
+ 30% {
+ width: 42px;
+ height: 42px;
+ background-color: transparent;
+ border-width: 21px;
+ opacity: 1;
+ }
+
+ 100% {
+ width: 42px;
+ height: 42px;
+ border-width: 0;
+ opacity: 0;
+ background-color: transparent;
+ }
+}
+
+@keyframes loader-label {
+ 0% { opacity: 0.25; }
+ 30% { opacity: 1; }
+ 100% { opacity: 0.25; }
+}
+
+.video-error-cover {
+ align-items: center;
+ background: $base-overlay-background;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ margin-top: 8px;
+ position: relative;
+ text-align: center;
+ z-index: 100;
+}
+
+.media-spoiler {
+ background: $base-overlay-background;
+ color: $ui-primary-color;
+ border: 0;
+ width: 100%;
+ height: 100%;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-primary-color, 8%);
+ }
+}
+
+.media-spoiler__warning {
+ display: block;
+ font-size: 14px;
+}
+
+.media-spoiler__trigger {
+ display: block;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.spoiler-button {
+ display: none;
+ left: 4px;
+ position: absolute;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+ top: 4px;
+ z-index: 100;
+
+ &.spoiler-button--visible {
+ display: block;
+ }
+}
+
+.modal-container--preloader {
+ background: lighten($ui-base-color, 8%);
+}
+
+.account--panel {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: row;
+ padding: 10px 0;
+}
+
+.account--panel__button,
+.detailed-status__button {
+ flex: 1 1 auto;
+ text-align: center;
+}
+
+.column-settings__outer {
+ background: lighten($ui-base-color, 8%);
+ padding: 15px;
+}
+
+.column-settings__section {
+ color: $ui-primary-color;
+ cursor: default;
+ display: block;
+ font-weight: 500;
+ margin-bottom: 10px;
+}
+
+.column-settings__row {
+ .text-btn {
+ margin-bottom: 15px;
+ }
+}
+
+.modal-container__nav {
+ align-items: center;
+ background: rgba($base-overlay-background, 0.5);
+ box-sizing: border-box;
+ border: 0;
+ color: $primary-text-color;
+ cursor: pointer;
+ display: flex;
+ font-size: 24px;
+ height: 100%;
+ padding: 30px 15px;
+ position: absolute;
+ top: 0;
+}
+
+.modal-container__nav--left {
+ left: -61px;
+}
+
+.modal-container__nav--right {
+ right: -61px;
+}
+
+.account--follows-info {
+ color: $primary-text-color;
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ opacity: 0.7;
+ display: inline-block;
+ vertical-align: top;
+ background-color: rgba($base-overlay-background, 0.4);
+ text-transform: uppercase;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 4px;
+ border-radius: 4px;
+}
+
+.account--action-button {
+ position: absolute;
+ top: 10px;
+ right: 20px;
+}
+
+.setting-toggle {
+ display: block;
+ line-height: 24px;
+}
+
+.setting-toggle__label,
+.setting-meta__label {
+ color: $ui-primary-color;
+ display: inline-block;
+ margin-bottom: 14px;
+ margin-left: 8px;
+ vertical-align: middle;
+}
+
+.setting-meta__label {
+ color: $ui-primary-color;
+ float: right;
+}
+
+.empty-column-indicator,
+.error-column {
+ color: lighten($ui-base-color, 20%);
+ background: $ui-base-color;
+ text-align: center;
+ padding: 20px;
+ font-size: 15px;
+ font-weight: 400;
+ cursor: default;
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
+ @supports(display: grid) { // hack to fix Chrome <57
+ contain: strict;
+ }
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.error-column {
+ flex-direction: column;
+}
+
+@keyframes heartbeat {
+ from {
+ transform: scale(1);
+ transform-origin: center center;
+ animation-timing-function: ease-out;
+ }
+
+ 10% {
+ transform: scale(0.91);
+ animation-timing-function: ease-in;
+ }
+
+ 17% {
+ transform: scale(0.98);
+ animation-timing-function: ease-out;
+ }
+
+ 33% {
+ transform: scale(0.87);
+ animation-timing-function: ease-in;
+ }
+
+ 45% {
+ transform: scale(1);
+ animation-timing-function: ease-out;
+ }
+}
+
+.pulse-loading {
+ animation: heartbeat 1.5s ease-in-out infinite both;
+}
+
+.emoji-picker-dropdown__menu {
+ background: $simple-background-color;
+ position: absolute;
+ box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ margin-top: 5px;
+
+ .emoji-mart-scroll {
+ transition: opacity 200ms ease;
+ }
+
+ &.selecting .emoji-mart-scroll {
+ opacity: 0.5;
+ }
+}
+
+.emoji-picker-dropdown__modifiers {
+ position: absolute;
+ top: 60px;
+ right: 11px;
+ cursor: pointer;
+}
+
+.emoji-picker-dropdown__modifiers__menu {
+ position: absolute;
+ z-index: 4;
+ top: -4px;
+ left: -8px;
+ background: $simple-background-color;
+ border-radius: 4px;
+ box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+ overflow: hidden;
+
+ button {
+ display: block;
+ cursor: pointer;
+ border: 0;
+ padding: 4px 8px;
+ background: transparent;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: rgba($ui-secondary-color, 0.4);
+ }
+ }
+
+ .emoji-mart-emoji {
+ height: 22px;
+ }
+}
+
+.emoji-mart-emoji {
+ span {
+ background-repeat: no-repeat;
+ }
+}
+
+.upload-area {
+ align-items: center;
+ background: rgba($base-overlay-background, 0.8);
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ left: 0;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ visibility: hidden;
+ width: 100%;
+ z-index: 2000;
+
+ * {
+ pointer-events: none;
+ }
+}
+
+.upload-area__drop {
+ width: 320px;
+ height: 160px;
+ display: flex;
+ box-sizing: border-box;
+ position: relative;
+ padding: 8px;
+}
+
+.upload-area__background {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: -1;
+ border-radius: 4px;
+ background: $ui-base-color;
+ box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+}
+
+.upload-area__content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $ui-secondary-color;
+ font-size: 18px;
+ font-weight: 500;
+ border: 2px dashed $ui-base-lighter-color;
+ border-radius: 4px;
+}
+
+.upload-progress {
+ padding: 10px;
+ color: $ui-base-lighter-color;
+ overflow: hidden;
+ display: flex;
+
+ .fa {
+ font-size: 34px;
+ margin-right: 10px;
+ }
+
+ span {
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: 500;
+ display: block;
+ }
+}
+
+.upload-progess__message {
+ flex: 1 1 auto;
+}
+
+.upload-progress__backdrop {
+ width: 100%;
+ height: 6px;
+ border-radius: 6px;
+ background: $ui-base-lighter-color;
+ position: relative;
+ margin-top: 5px;
+}
+
+.upload-progress__tracker {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 6px;
+ background: $ui-highlight-color;
+ border-radius: 6px;
+}
+
+.emoji-button {
+ display: block;
+ font-size: 24px;
+ line-height: 24px;
+ margin-left: 2px;
+ width: 24px;
+ outline: 0;
+ cursor: pointer;
+
+ &:active,
+ &:focus {
+ outline: 0 !important;
+ }
+
+ img {
+ filter: grayscale(100%);
+ opacity: 0.8;
+ display: block;
+ margin: 0;
+ width: 22px;
+ height: 22px;
+ margin-top: 2px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ img {
+ opacity: 1;
+ filter: none;
+ }
+ }
+}
+
+.dropdown--active .emoji-button img {
+ opacity: 1;
+ filter: none;
+}
+
+.privacy-dropdown__dropdown {
+ position: absolute;
+ background: $simple-background-color;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ margin-left: 40px;
+ overflow: hidden;
+}
+
+.privacy-dropdown__option {
+ color: $ui-base-color;
+ padding: 10px;
+ cursor: pointer;
+ display: flex;
+
+ &:hover,
+ &.active {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+
+ .privacy-dropdown__option__content {
+ color: $primary-text-color;
+
+ strong {
+ color: $primary-text-color;
+ }
+ }
+ }
+
+ &.active:hover {
+ background: lighten($ui-highlight-color, 4%);
+ }
+}
+
+.privacy-dropdown__option__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 10px;
+}
+
+.privacy-dropdown__option__content {
+ flex: 1 1 auto;
+ color: darken($ui-primary-color, 24%);
+
+ strong {
+ font-weight: 500;
+ display: block;
+ color: $ui-base-color;
+ }
+}
+
+.privacy-dropdown.active {
+ .privacy-dropdown__value {
+ background: $simple-background-color;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+
+ .icon-button {
+ transition: none;
+ }
+
+ &.active {
+ background: $ui-highlight-color;
+
+ .icon-button {
+ color: $primary-text-color;
+ }
+ }
+ }
+
+ .privacy-dropdown__dropdown {
+ display: block;
+ box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
+ }
+}
+
+.search {
+ position: relative;
+}
+
+.search__input {
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ padding-right: 30px;
+ font-family: inherit;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+}
+
+.search__icon {
+ .fa {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 2;
+ display: inline-block;
+ opacity: 0;
+ transition: all 100ms linear;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: $ui-secondary-color;
+ cursor: default;
+ pointer-events: none;
+
+ &.active {
+ pointer-events: auto;
+ opacity: 0.3;
+ }
+ }
+
+ .fa-search {
+ transform: rotate(90deg);
+
+ &.active {
+ pointer-events: none;
+ transform: rotate(0deg);
+ }
+ }
+
+ .fa-times-circle {
+ top: 11px;
+ transform: rotate(0deg);
+ cursor: pointer;
+
+ &.active {
+ transform: rotate(90deg);
+ }
+
+ &:hover {
+ color: $primary-text-color;
+ }
+ }
+}
+
+.search-results__header {
+ color: $ui-base-lighter-color;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid darken($ui-base-color, 4%);
+ padding: 15px 10px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.search-results__hashtag {
+ display: block;
+ padding: 10px;
+ color: $ui-secondary-color;
+ text-decoration: none;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-secondary-color, 4%);
+ text-decoration: underline;
+ }
+}
+
+.modal-root {
+ transition: opacity 0.3s linear;
+ will-change: opacity;
+ z-index: 9999;
+}
+
+.modal-root__overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba($base-overlay-background, 0.7);
+}
+
+.modal-root__container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ align-content: space-around;
+ z-index: 9999;
+ pointer-events: none;
+ user-select: none;
+}
+
+.modal-root__modal {
+ pointer-events: auto;
+ display: flex;
+ z-index: 9999;
+}
+
+.media-modal {
+ max-width: 80vw;
+ max-height: 80vh;
+ position: relative;
+
+ .extended-video-player,
+ img,
+ canvas,
+ video {
+ max-width: 80vw;
+ max-height: 80vh;
+ width: auto;
+ height: auto;
+ margin: auto;
+ }
+
+ .extended-video-player,
+ video {
+ display: flex;
+ width: 80vw;
+ height: 80vh;
+ }
+
+ img,
+ canvas {
+ display: block;
+ background: url('../images/void.png') repeat;
+ object-fit: contain;
+ }
+
+ .react-swipeable-view-container {
+ max-width: 80vw;
+ }
+}
+
+.media-modal__content {
+ background: $base-overlay-background;
+}
+
+.media-modal__pagination {
+ width: 100%;
+ text-align: center;
+ position: absolute;
+ left: 0;
+ bottom: -40px;
+}
+
+.media-modal__page-dot {
+ display: inline-block;
+}
+
+.media-modal__button {
+ background-color: $white;
+ height: 12px;
+ width: 12px;
+ border-radius: 6px;
+ margin: 10px;
+ padding: 0;
+ border: 0;
+ font-size: 0;
+}
+
+.media-modal__button--active {
+ background-color: $ui-highlight-color;
+}
+
+.media-modal__close {
+ position: absolute;
+ right: 4px;
+ top: 4px;
+ z-index: 100;
+}
+
+.onboarding-modal,
+.error-modal,
+.embed-modal {
+ background: $ui-secondary-color;
+ color: $ui-base-color;
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.onboarding-modal__pager {
+ height: 80vh;
+ width: 80vw;
+ max-width: 520px;
+ max-height: 420px;
+
+ .react-swipeable-view-container > div {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ padding: 25px;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ user-select: text;
+ }
+}
+
+.error-modal__body {
+ height: 80vh;
+ width: 80vw;
+ max-width: 520px;
+ max-height: 420px;
+ position: relative;
+
+ & > div {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ padding: 25px;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ opacity: 0;
+ user-select: text;
+ }
+}
+
+.error-modal__body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+@media screen and (max-width: 550px) {
+ .onboarding-modal {
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ }
+
+ .onboarding-modal__pager {
+ width: 100%;
+ height: auto;
+ max-width: none;
+ max-height: none;
+ flex: 1 1 auto;
+ }
+}
+
+.onboarding-modal__paginator,
+.error-modal__footer {
+ flex: 0 0 auto;
+ background: darken($ui-secondary-color, 8%);
+ display: flex;
+ padding: 25px;
+
+ & > div {
+ min-width: 33px;
+ }
+
+ .onboarding-modal__nav,
+ .error-modal__nav {
+ color: darken($ui-secondary-color, 34%);
+ background-color: transparent;
+ border: 0;
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0;
+ line-height: inherit;
+ height: auto;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: darken($ui-secondary-color, 38%);
+ }
+
+ &.onboarding-modal__done,
+ &.onboarding-modal__next {
+ color: $ui-highlight-color;
+ }
+ }
+}
+
+.error-modal__footer {
+ justify-content: center;
+}
+
+.onboarding-modal__dots {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.onboarding-modal__dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 14px;
+ background: darken($ui-secondary-color, 16%);
+ margin: 0 3px;
+ cursor: pointer;
+
+ &:hover {
+ background: darken($ui-secondary-color, 18%);
+ }
+
+ &.active {
+ cursor: default;
+ background: darken($ui-secondary-color, 24%);
+ }
+}
+
+.onboarding-modal__page__wrapper {
+ pointer-events: none;
+
+ &.onboarding-modal__page__wrapper--active {
+ pointer-events: auto;
+ }
+}
+
+.onboarding-modal__page {
+ cursor: default;
+ line-height: 21px;
+
+ h1 {
+ font-size: 18px;
+ font-weight: 500;
+ color: $ui-base-color;
+ margin-bottom: 20px;
+ }
+
+ a {
+ color: $ui-highlight-color;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: lighten($ui-highlight-color, 4%);
+ }
+ }
+
+ p {
+ font-size: 16px;
+ color: lighten($ui-base-color, 8%);
+ margin-top: 10px;
+ margin-bottom: 10px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ strong {
+ font-weight: 500;
+ background: $ui-base-color;
+ color: $ui-secondary-color;
+ border-radius: 4px;
+ font-size: 14px;
+ padding: 3px 6px;
+ }
+ }
+}
+
+.onboarding-modal__page-one {
+ display: flex;
+ align-items: center;
+}
+
+.onboarding-modal__page-one__elephant-friend {
+ background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
+ width: 155px;
+ height: 193px;
+ margin-right: 15px;
+}
+
+@media screen and (max-width: 400px) {
+ .onboarding-modal__page-one {
+ flex-direction: column;
+ align-items: normal;
+ }
+
+ .onboarding-modal__page-one__elephant-friend {
+ width: 100%;
+ height: 30vh;
+ max-height: 160px;
+ margin-bottom: 5vh;
+ }
+}
+
+.onboarding-modal__page-two,
+.onboarding-modal__page-three,
+.onboarding-modal__page-four,
+.onboarding-modal__page-five {
+ p {
+ text-align: left;
+ }
+
+ .figure {
+ background: darken($ui-base-color, 8%);
+ color: $ui-secondary-color;
+ margin-bottom: 20px;
+ border-radius: 4px;
+ padding: 10px;
+ text-align: center;
+ font-size: 14px;
+ box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);
+
+ .onboarding-modal__image {
+ border-radius: 4px;
+ margin-bottom: 10px;
+ }
+
+ &.non-interactive {
+ pointer-events: none;
+ text-align: left;
+ }
+ }
+}
+
+.onboarding-modal__page-four__columns {
+ .row {
+ display: flex;
+ margin-bottom: 20px;
+
+ & > div {
+ flex: 1 1 0;
+ margin: 0 10px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ p {
+ text-align: center;
+ }
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .column-header {
+ color: $primary-text-color;
+ }
+}
+
+@media screen and (max-width: 320px) and (max-height: 600px) {
+ .onboarding-modal__page p {
+ font-size: 14px;
+ line-height: 20px;
+ }
+
+ .onboarding-modal__page-two .figure,
+ .onboarding-modal__page-three .figure,
+ .onboarding-modal__page-four .figure,
+ .onboarding-modal__page-five .figure {
+ font-size: 12px;
+ margin-bottom: 10px;
+ }
+
+ .onboarding-modal__page-four__columns .row {
+ margin-bottom: 10px;
+ }
+
+ .onboarding-modal__page-four__columns .column-header {
+ padding: 5px;
+ font-size: 12px;
+ }
+}
+
+.onboarding-modal__image {
+ border-radius: 8px;
+ width: 70vw;
+ max-width: 450px;
+ max-height: auto;
+ display: block;
+ margin: auto;
+ margin-bottom: 20px;
+}
+
+.onboard-sliders {
+ display: inline-block;
+ max-width: 30px;
+ max-height: auto;
+ margin-left: 10px;
+}
+
+.boost-modal,
+.confirmation-modal,
+.report-modal,
+.actions-modal,
+.mute-modal {
+ background: lighten($ui-secondary-color, 8%);
+ color: $ui-base-color;
+ border-radius: 8px;
+ overflow: hidden;
+ max-width: 90vw;
+ width: 480px;
+ position: relative;
+ flex-direction: column;
+
+ .status__display-name {
+ display: block;
+ max-width: 100%;
+ padding-right: 25px;
+ }
+
+ .status__avatar {
+ height: 28px;
+ left: 10px;
+ position: absolute;
+ top: 10px;
+ width: 48px;
+ }
+}
+
+.actions-modal {
+ .status {
+ background: $white;
+ border-bottom-color: $ui-secondary-color;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .dropdown-menu__separator {
+ border-bottom-color: $ui-secondary-color;
+ }
+}
+
+.boost-modal__container {
+ overflow-x: scroll;
+ padding: 10px;
+
+ .status {
+ user-select: text;
+ border-bottom: 0;
+ }
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.report-modal__action-bar {
+ display: flex;
+ justify-content: space-between;
+ background: $ui-secondary-color;
+ padding: 10px;
+ line-height: 36px;
+
+ & > div {
+ flex: 1 1 auto;
+ text-align: right;
+ color: lighten($ui-base-color, 33%);
+ padding-right: 10px;
+ }
+
+ .button {
+ flex: 0 0 auto;
+ }
+}
+
+.boost-modal__status-header {
+ font-size: 15px;
+}
+
+.boost-modal__status-time {
+ float: right;
+ font-size: 14px;
+}
+
+.confirmation-modal {
+ max-width: 85vw;
+
+ @media screen and (min-width: 480px) {
+ max-width: 380px;
+ }
+}
+
+.mute-modal {
+ line-height: 24px;
+}
+
+.mute-modal .react-toggle {
+ vertical-align: middle;
+}
+
+.report-modal__statuses,
+.report-modal__comment {
+ padding: 10px;
+}
+
+.report-modal__statuses {
+ min-height: 20vh;
+ max-height: 40vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.report-modal__comment {
+ .setting-text {
+ margin-top: 10px;
+ }
+}
+
+.actions-modal {
+ .status {
+ overflow-y: auto;
+ max-height: 300px;
+ }
+
+ max-height: 80vh;
+ max-width: 80vw;
+
+ .actions-modal__item-label {
+ font-weight: 500;
+ }
+
+ ul {
+ overflow-y: auto;
+ flex-shrink: 0;
+
+ li:empty {
+ margin: 0;
+ }
+
+ li:not(:empty) {
+ a {
+ color: $ui-base-color;
+ display: flex;
+ padding: 12px 16px;
+ font-size: 15px;
+ align-items: center;
+ text-decoration: none;
+
+ &,
+ button {
+ transition: none;
+ }
+
+ &.active,
+ &:hover,
+ &:active,
+ &:focus {
+ &,
+ button {
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ }
+ }
+
+ button:first-child {
+ margin-right: 10px;
+ }
+ }
+ }
+ }
+}
+
+.confirmation-modal__action-bar,
+.mute-modal__action-bar {
+ .confirmation-modal__cancel-button,
+ .mute-modal__cancel-button {
+ background-color: transparent;
+ color: darken($ui-secondary-color, 34%);
+ font-size: 14px;
+ font-weight: 500;
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: darken($ui-secondary-color, 38%);
+ }
+ }
+}
+
+.confirmation-modal__container,
+.mute-modal__container,
+.report-modal__target {
+ padding: 30px;
+ font-size: 16px;
+ text-align: center;
+
+ strong {
+ font-weight: 500;
+ }
+}
+
+.loading-bar {
+ background-color: $ui-highlight-color;
+ height: 3px;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.media-gallery__gifv__label {
+ display: block;
+ position: absolute;
+ color: $primary-text-color;
+ background: rgba($base-overlay-background, 0.5);
+ bottom: 6px;
+ left: 6px;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ z-index: 1;
+ pointer-events: none;
+ opacity: 0.9;
+ transition: opacity 0.1s ease;
+}
+
+.media-gallery__gifv {
+ &.autoplay {
+ .media-gallery__gifv__label {
+ display: none;
+ }
+ }
+
+ &:hover {
+ .media-gallery__gifv__label {
+ opacity: 1;
+ }
+ }
+}
+
+.attachment-list {
+ display: flex;
+ font-size: 14px;
+ border: 1px solid lighten($ui-base-color, 8%);
+ border-radius: 4px;
+ margin-top: 14px;
+ overflow: hidden;
+}
+
+.attachment-list__icon {
+ flex: 0 0 auto;
+ color: $ui-base-lighter-color;
+ padding: 8px 18px;
+ cursor: default;
+ border-right: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+
+ .fa {
+ display: block;
+ }
+}
+
+.attachment-list__list {
+ list-style: none;
+ padding: 4px 0;
+ padding-left: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ li {
+ display: block;
+ padding: 4px 0;
+ }
+
+ a {
+ text-decoration: none;
+ color: $ui-base-lighter-color;
+ font-weight: 500;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+/* Media Gallery */
+.media-gallery {
+ box-sizing: border-box;
+ margin-top: 8px;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+}
+
+.media-gallery__item {
+ border: none;
+ box-sizing: border-box;
+ display: block;
+ float: left;
+ position: relative;
+
+ &.standalone {
+ .media-gallery__item-gifv-thumbnail {
+ transform: none;
+ }
+ }
+}
+
+.media-gallery__item-thumbnail {
+ cursor: zoom-in;
+ display: block;
+ text-decoration: none;
+ height: 100%;
+ line-height: 0;
+
+ &,
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.media-gallery__gifv {
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+}
+
+.media-gallery__item-gifv-thumbnail {
+ cursor: zoom-in;
+ height: 100%;
+ object-fit: cover;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 100%;
+ z-index: 1;
+}
+
+.media-gallery__item-thumbnail-label {
+ clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
+ clip: rect(1px, 1px, 1px, 1px);
+ overflow: hidden;
+ position: absolute;
+}
+/* End Media Gallery */
+
+/* Status Video Player */
+.status__video-player {
+ background: $base-overlay-background;
+ box-sizing: border-box;
+ cursor: default; /* May not be needed */
+ margin-top: 8px;
+ overflow: hidden;
+ position: relative;
+}
+
+.status__video-player-video {
+ height: 100%;
+ object-fit: cover;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 100%;
+ z-index: 1;
+}
+
+.status__video-player-expand,
+.status__video-player-mute {
+ color: $primary-text-color;
+ opacity: 0.8;
+ position: absolute;
+ right: 4px;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+}
+
+.status__video-player-spoiler {
+ display: none;
+ color: $primary-text-color;
+ left: 4px;
+ position: absolute;
+ text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
+ top: 4px;
+ z-index: 100;
+
+ &.status__video-player-spoiler--visible {
+ display: block;
+ }
+}
+
+.status__video-player-expand {
+ bottom: 4px;
+ z-index: 100;
+}
+
+.status__video-player-mute {
+ top: 4px;
+ z-index: 5;
+}
+
+.video-player {
+ overflow: hidden;
+ position: relative;
+ background: $base-shadow-color;
+ max-width: 100%;
+
+ video {
+ height: 100%;
+ width: 100%;
+ z-index: 1;
+ }
+
+ &.fullscreen {
+ width: 100% !important;
+ height: 100% !important;
+ margin: 0;
+
+ video {
+ max-width: 100% !important;
+ max-height: 100% !important;
+ }
+ }
+
+ &.inline {
+ video {
+ object-fit: cover;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+ }
+
+ &__controls {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 60%, transparent);
+ padding: 0 10px;
+ opacity: 0;
+ transition: opacity .1s ease;
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ &.inactive {
+ video,
+ .video-player__controls {
+ visibility: hidden;
+ }
+ }
+
+ &__spoiler {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 4;
+ border: 0;
+ background: $base-shadow-color;
+ color: $ui-primary-color;
+ transition: none;
+ pointer-events: none;
+
+ &.active {
+ display: block;
+ pointer-events: auto;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-primary-color, 8%);
+ }
+ }
+
+ &__title {
+ display: block;
+ font-size: 14px;
+ }
+
+ &__subtitle {
+ display: block;
+ font-size: 11px;
+ font-weight: 500;
+ }
+ }
+
+ &__buttons {
+ padding-bottom: 10px;
+ font-size: 16px;
+
+ &.left {
+ float: left;
+
+ button {
+ padding-right: 10px;
+ }
+ }
+
+ &.right {
+ float: right;
+
+ button {
+ padding-left: 10px;
+ }
+ }
+
+ button {
+ background: transparent;
+ padding: 0;
+ border: 0;
+ color: $white;
+
+ &:active,
+ &:hover,
+ &:focus {
+ color: $ui-highlight-color;
+ }
+ }
+ }
+
+ &__seek {
+ cursor: pointer;
+ height: 24px;
+ position: relative;
+
+ &::before {
+ content: "";
+ width: 100%;
+ background: rgba($white, 0.35);
+ display: block;
+ position: absolute;
+ height: 4px;
+ top: 10px;
+ }
+
+ &__progress,
+ &__buffer {
+ display: block;
+ position: absolute;
+ height: 4px;
+ top: 10px;
+ background: $ui-highlight-color;
+ }
+
+ &__buffer {
+ background: rgba($white, 0.2);
+ }
+
+ &__handle {
+ position: absolute;
+ z-index: 3;
+ opacity: 0;
+ border-radius: 50%;
+ width: 12px;
+ height: 12px;
+ top: 6px;
+ margin-left: -6px;
+ transition: opacity .1s ease;
+ background: $ui-highlight-color;
+ pointer-events: none;
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ &:hover {
+ .video-player__seek__handle {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.media-spoiler-video {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+ margin-top: 8px;
+ position: relative;
+ border: 0;
+ display: block;
+}
+
+.media-spoiler-video-play-icon {
+ border-radius: 100px;
+ color: rgba($primary-text-color, 0.8);
+ font-size: 36px;
+ left: 50%;
+ padding: 5px;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+}
+/* End Video Player */
+
+.account-gallery__container {
+ margin: -2px;
+ padding: 4px;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.account-gallery__item {
+ flex: 1 1 auto;
+ width: calc(100% / 3 - 4px);
+ height: 95px;
+ margin: 2px;
+
+ a {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: $base-overlay-background;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+ color: inherit;
+ text-decoration: none;
+
+ &:hover,
+ &:active,
+ &:focus {
+ outline: 0;
+ }
+ }
+}
+
+.account-section-headline {
+ color: $ui-base-lighter-color;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ padding: 15px 10px;
+ font-size: 14px;
+ font-weight: 500;
+ position: relative;
+ cursor: default;
+
+ &::before,
+ &::after {
+ display: block;
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 18px;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 10px 10px;
+ border-color: transparent transparent lighten($ui-base-color, 4%);
+ }
+
+ &::after {
+ bottom: -1px;
+ border-color: transparent transparent $ui-base-color;
+ }
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 0;
+}
+
+.search-popout {
+ background: $simple-background-color;
+ border-radius: 4px;
+ padding: 10px 14px;
+ padding-bottom: 14px;
+ margin-top: 10px;
+ color: $ui-primary-color;
+ box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+ h4 {
+ text-transform: uppercase;
+ color: $ui-primary-color;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ li {
+ padding: 4px 0;
+ }
+
+ ul {
+ margin-bottom: 10px;
+ }
+
+ em {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+}
+
+noscript {
+ text-align: center;
+
+ img {
+ width: 200px;
+ opacity: 0.5;
+ animation: flicker 4s infinite;
+ }
+
+ div {
+ font-size: 14px;
+ margin: 30px auto;
+ color: $ui-secondary-color;
+ max-width: 400px;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
+
+@keyframes flicker {
+ 0% { opacity: 1; }
+ 30% { opacity: 0.75; }
+ 100% { opacity: 1; }
+}
+
+@media screen and (max-width: 630px) and (max-height: 400px) {
+ $duration: 400ms;
+ $delay: 100ms;
+
+ .tabs-bar,
+ .search {
+ will-change: margin-top;
+ transition: margin-top $duration $delay;
+ }
+
+ .navigation-bar {
+ will-change: padding-bottom;
+ transition: padding-bottom $duration $delay;
+ }
+
+ .navigation-bar {
+ & > a:first-child {
+ will-change: margin-top, margin-left, width;
+ transition: margin-top $duration $delay, margin-left $duration ($duration + $delay);
+ }
+
+ & > .navigation-bar__profile-edit {
+ will-change: margin-top;
+ transition: margin-top $duration $delay;
+ }
+
+ & > .icon-button {
+ will-change: opacity;
+ transition: opacity $duration $delay;
+ }
+ }
+
+ .is-composing {
+ .tabs-bar,
+ .search {
+ margin-top: -50px;
+ }
+
+ .navigation-bar {
+ padding-bottom: 0;
+
+ & > a:first-child {
+ margin-top: -50px;
+ margin-left: -40px;
+ }
+
+ .navigation-bar__profile {
+ padding-top: 2px;
+ }
+
+ .navigation-bar__profile-edit {
+ position: absolute;
+ margin-top: -50px;
+ }
+
+ .icon-button {
+ pointer-events: auto;
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.embed-modal {
+ max-width: 80vw;
+ max-height: 80vh;
+
+ h4 {
+ padding: 30px;
+ font-weight: 500;
+ font-size: 16px;
+ text-align: center;
+ }
+
+ .embed-modal__container {
+ padding: 10px;
+
+ .hint {
+ margin-bottom: 15px;
+ }
+
+ .embed-modal__html {
+ color: $ui-secondary-color;
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ font-family: 'mastodon-font-monospace', monospace;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+ margin-bottom: 15px;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+ }
+
+ .embed-modal__iframe {
+ width: 400px;
+ max-width: 100%;
+ overflow: hidden;
+ border: 0;
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
new file mode 100644
index 000000000..af2589e23
--- /dev/null
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -0,0 +1,116 @@
+.container {
+ width: 700px;
+ margin: 0 auto;
+ margin-top: 40px;
+
+ @media screen and (max-width: 740px) {
+ width: 100%;
+ margin: 0;
+ }
+}
+
+.logo-container {
+ margin: 100px auto;
+ margin-bottom: 50px;
+
+ @media screen and (max-width: 400px) {
+ margin: 30px auto;
+ margin-bottom: 20px;
+ }
+
+ h1 {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img {
+ height: 42px;
+ margin-right: 10px;
+ }
+
+ a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $primary-text-color;
+ text-decoration: none;
+ outline: 0;
+ padding: 12px 16px;
+ line-height: 32px;
+ font-family: 'mastodon-font-display', sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+ }
+ }
+}
+
+.compose-standalone {
+ .compose-form {
+ width: 400px;
+ margin: 0 auto;
+ padding: 20px 0;
+ margin-top: 40px;
+ box-sizing: border-box;
+
+ @media screen and (max-width: 400px) {
+ width: 100%;
+ margin-top: 0;
+ padding: 20px;
+ }
+ }
+}
+
+.account-header {
+ width: 400px;
+ margin: 0 auto;
+ display: flex;
+ font-size: 13px;
+ line-height: 18px;
+ box-sizing: border-box;
+ padding: 20px 0;
+ padding-bottom: 0;
+ margin-bottom: -30px;
+ margin-top: 40px;
+
+ @media screen and (max-width: 440px) {
+ width: 100%;
+ margin: 0;
+ margin-bottom: 10px;
+ padding: 20px;
+ padding-bottom: 0;
+ }
+
+ .avatar {
+ width: 40px;
+ height: 40px;
+ margin-right: 8px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: 0;
+ border-radius: 4px;
+ }
+ }
+
+ .name {
+ flex: 1 1 auto;
+ color: $ui-secondary-color;
+ width: calc(100% - 88px);
+
+ .username {
+ display: block;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ .logout-link {
+ display: block;
+ font-size: 32px;
+ line-height: 40px;
+ margin-left: 8px;
+ }
+}
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
new file mode 100644
index 000000000..2b46d30fc
--- /dev/null
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -0,0 +1,199 @@
+.emoji-mart {
+ &,
+ * {
+ box-sizing: border-box;
+ line-height: 1.15;
+ }
+
+ font-size: 13px;
+ display: inline-block;
+ color: $ui-base-color;
+
+ .emoji-mart-emoji {
+ padding: 6px;
+ }
+}
+
+.emoji-mart-bar {
+ border: 0 solid darken($ui-secondary-color, 8%);
+
+ &:first-child {
+ border-bottom-width: 1px;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ background: $ui-secondary-color;
+ }
+
+ &:last-child {
+ border-top-width: 1px;
+ border-bottom-left-radius: 5px;
+ border-bottom-right-radius: 5px;
+ display: none;
+ }
+}
+
+.emoji-mart-anchors {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 6px;
+ color: $ui-primary-color;
+ line-height: 0;
+}
+
+.emoji-mart-anchor {
+ position: relative;
+ flex: 1;
+ text-align: center;
+ padding: 12px 4px;
+ overflow: hidden;
+ transition: color .1s ease-out;
+ cursor: pointer;
+
+ &:hover {
+ color: darken($ui-primary-color, 4%);
+ }
+}
+
+.emoji-mart-anchor-selected {
+ color: darken($ui-highlight-color, 3%);
+
+ &:hover {
+ color: darken($ui-highlight-color, 3%);
+ }
+
+ .emoji-mart-anchor-bar {
+ bottom: 0;
+ }
+}
+
+.emoji-mart-anchor-bar {
+ position: absolute;
+ bottom: -3px;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background-color: darken($ui-highlight-color, 3%);
+}
+
+.emoji-mart-anchors {
+ i {
+ display: inline-block;
+ width: 100%;
+ max-width: 22px;
+ }
+
+ svg {
+ fill: currentColor;
+ max-height: 18px;
+ }
+}
+
+.emoji-mart-scroll {
+ overflow-y: scroll;
+ height: 270px;
+ max-height: 35vh;
+ padding: 0 6px 6px;
+ background: $simple-background-color;
+ will-change: transform;
+}
+
+.emoji-mart-search {
+ padding: 10px;
+ padding-right: 45px;
+ background: $simple-background-color;
+
+ input {
+ font-size: 14px;
+ font-weight: 400;
+ padding: 7px 9px;
+ font-family: inherit;
+ display: block;
+ width: 100%;
+ background: rgba($ui-secondary-color, 0.3);
+ color: $ui-primary-color;
+ border: 1px solid $ui-secondary-color;
+ border-radius: 4px;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+ }
+}
+
+.emoji-mart-category .emoji-mart-emoji {
+ cursor: pointer;
+
+ span {
+ z-index: 1;
+ position: relative;
+ text-align: center;
+ }
+
+ &:hover::before {
+ z-index: 0;
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba($ui-secondary-color, 0.7);
+ border-radius: 100%;
+ }
+}
+
+.emoji-mart-category-label {
+ z-index: 2;
+ position: relative;
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+
+ span {
+ display: block;
+ width: 100%;
+ font-weight: 500;
+ padding: 5px 6px;
+ background: $simple-background-color;
+ }
+}
+
+.emoji-mart-emoji {
+ position: relative;
+ display: inline-block;
+ font-size: 0;
+
+ span {
+ width: 22px;
+ height: 22px;
+ }
+}
+
+.emoji-mart-no-results {
+ font-size: 14px;
+ text-align: center;
+ padding-top: 70px;
+ color: $ui-primary-color;
+
+ .emoji-mart-category-label {
+ display: none;
+ }
+
+ .emoji-mart-no-results-label {
+ margin-top: .2em;
+ }
+
+ .emoji-mart-emoji:hover::before {
+ content: none;
+ }
+}
+
+.emoji-mart-preview {
+ display: none;
+}
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
new file mode 100644
index 000000000..2d953b34e
--- /dev/null
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -0,0 +1,30 @@
+.footer {
+ text-align: center;
+ margin-top: 30px;
+ font-size: 12px;
+ color: darken($ui-secondary-color, 25%);
+
+ .domain {
+ font-weight: 500;
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+ }
+
+ .powered-by,
+ .single-user-login {
+ font-weight: 400;
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ font-weight: 500;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
new file mode 100644
index 000000000..61fcf286f
--- /dev/null
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -0,0 +1,540 @@
+code {
+ font-family: 'mastodon-font-monospace', monospace;
+ font-weight: 400;
+}
+
+.form-container {
+ max-width: 400px;
+ padding: 20px;
+ margin: 0 auto;
+}
+
+.simple_form {
+ .input {
+ margin-bottom: 15px;
+ overflow: hidden;
+ }
+
+ span.hint {
+ display: block;
+ color: $ui-primary-color;
+ font-size: 12px;
+ margin-top: 4px;
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font-size: 13px;
+ font-weight: 500;
+ color: $ui-primary-color;
+ padding-bottom: 8px;
+ margin-bottom: 8px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ }
+
+ p.hint {
+ margin-bottom: 15px;
+ color: $ui-primary-color;
+
+ &.subtle-hint {
+ text-align: center;
+ font-size: 12px;
+ line-height: 18px;
+ margin-top: 15px;
+ margin-bottom: 0;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+ }
+ }
+
+ .card {
+ margin-bottom: 15px;
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ .label_input {
+ display: flex;
+
+ label {
+ flex: 0 0 auto;
+ }
+
+ input {
+ flex: 1 1 auto;
+ }
+ }
+
+ .input.with_label {
+ padding: 15px 0;
+ margin-bottom: 0;
+
+ .label_input {
+ flex-wrap: wrap;
+ align-items: flex-start;
+ }
+
+ &.select .label_input {
+ align-items: initial;
+ }
+
+ .label_input > label {
+ font-family: inherit;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ padding-top: 5px;
+ margin-bottom: 5px;
+ flex: 1;
+ min-width: 150px;
+ word-wrap: break-word;
+
+ &.select {
+ flex: 0;
+ }
+
+ & ~ * {
+ margin-left: 10px;
+ }
+ }
+
+ ul {
+ flex: 390px;
+ }
+
+ &.boolean {
+ padding: initial;
+ margin-bottom: initial;
+
+ .label_input > label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ label.checkbox {
+ position: relative;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+ }
+ }
+
+ .input.with_block_label {
+ & > label {
+ font-family: inherit;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ padding-top: 5px;
+ }
+
+ .hint {
+ margin-bottom: 15px;
+ }
+
+ li {
+ float: left;
+ width: 50%;
+ }
+ }
+
+ .fields-group {
+ margin-bottom: 25px;
+ }
+
+ .input.radio_buttons .radio label {
+ margin-bottom: 5px;
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ .input.boolean {
+ margin-bottom: 5px;
+
+ label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ }
+
+ label.checkbox {
+ position: relative;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+
+ input[type=checkbox] {
+ position: absolute;
+ left: 0;
+ top: 5px;
+ margin: 0;
+ }
+
+ .hint {
+ padding-left: 25px;
+ margin-left: 0;
+ }
+ }
+
+ .check_boxes {
+ .checkbox {
+ label {
+ font-family: inherit;
+ font-size: 14px;
+ color: $primary-text-color;
+ display: block;
+ width: auto;
+ position: relative;
+ padding-top: 5px;
+ padding-left: 25px;
+ flex: 1 1 auto;
+ }
+
+ input[type=checkbox] {
+ position: absolute;
+ left: 0;
+ top: 5px;
+ margin: 0;
+ }
+ }
+ }
+
+ input[type=text],
+ input[type=number],
+ input[type=email],
+ input[type=password],
+ textarea {
+ background: transparent;
+ box-sizing: border-box;
+ border: 0;
+ border-bottom: 2px solid $ui-primary-color;
+ border-radius: 2px 2px 0 0;
+ padding: 7px 4px;
+ font-size: 16px;
+ color: $primary-text-color;
+ display: block;
+ width: 100%;
+ outline: 0;
+ font-family: inherit;
+ resize: vertical;
+
+ &:invalid {
+ box-shadow: none;
+ }
+
+ &:focus:invalid {
+ border-bottom-color: $error-value-color;
+ }
+
+ &:required:valid {
+ border-bottom-color: $valid-value-color;
+ }
+
+ &:active,
+ &:focus {
+ border-bottom-color: $ui-highlight-color;
+ background: rgba($base-overlay-background, 0.1);
+ }
+ }
+
+ .input.field_with_errors {
+ label {
+ color: $error-value-color;
+ }
+
+ input[type=text],
+ input[type=email],
+ input[type=password] {
+ border-bottom-color: $error-value-color;
+ }
+
+ .error {
+ display: block;
+ font-weight: 500;
+ color: $error-value-color;
+ margin-top: 4px;
+ }
+ }
+
+ .actions {
+ margin-top: 30px;
+ display: flex;
+ }
+
+ button,
+ .button,
+ .block-button {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 4px;
+ background: $ui-highlight-color;
+ color: $primary-text-color;
+ font-size: 18px;
+ line-height: inherit;
+ height: auto;
+ padding: 10px;
+ text-transform: uppercase;
+ text-decoration: none;
+ text-align: center;
+ box-sizing: border-box;
+ cursor: pointer;
+ font-weight: 500;
+ outline: 0;
+ margin-bottom: 10px;
+ margin-right: 10px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ &:hover {
+ background-color: lighten($ui-highlight-color, 5%);
+ }
+
+ &:active,
+ &:focus {
+ background-color: darken($ui-highlight-color, 5%);
+ }
+
+ &.negative {
+ background: $error-value-color;
+
+ &:hover {
+ background-color: lighten($error-value-color, 5%);
+ }
+
+ &:active,
+ &:focus {
+ background-color: darken($error-value-color, 5%);
+ }
+ }
+ }
+
+ select {
+ font-size: 16px;
+ max-height: 29px;
+ }
+
+ .input-with-append {
+ position: relative;
+
+ .input input {
+ padding-right: 127px;
+ }
+
+ .append {
+ position: absolute;
+ right: 0;
+ top: 0;
+ padding: 7px 4px;
+ padding-bottom: 9px;
+ font-size: 16px;
+ color: $ui-base-lighter-color;
+ font-family: inherit;
+ pointer-events: none;
+ cursor: default;
+ }
+ }
+}
+
+.flash-message {
+ background: lighten($ui-base-color, 8%);
+ color: $ui-primary-color;
+ border-radius: 4px;
+ padding: 15px 10px;
+ margin-bottom: 30px;
+ box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);
+ text-align: center;
+
+ p {
+ margin-bottom: 15px;
+ }
+
+ .oauth-code {
+ color: $ui-secondary-color;
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ font-family: 'mastodon-font-monospace', monospace;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ @media screen and (max-width: 740px) and (min-width: 441px) {
+ margin-top: 40px;
+ }
+}
+
+.form-footer {
+ margin-top: 30px;
+ text-align: center;
+
+ a {
+ color: $ui-primary-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.oauth-prompt,
+.follow-prompt {
+ margin-bottom: 30px;
+ text-align: center;
+ color: $ui-primary-color;
+
+ h2 {
+ font-size: 16px;
+ margin-bottom: 30px;
+ }
+
+ strong {
+ color: $ui-secondary-color;
+ font-weight: 500;
+ }
+
+ @media screen and (max-width: 740px) and (min-width: 441px) {
+ margin-top: 40px;
+ }
+}
+
+.qr-wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+}
+
+.qr-code {
+ flex: 0 0 auto;
+ background: $simple-background-color;
+ padding: 4px;
+ margin: 0 10px 20px 0;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+ display: inline-block;
+
+ svg {
+ display: block;
+ margin: 0;
+ }
+}
+
+.qr-alternative {
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ flex: 150px;
+
+ samp {
+ display: block;
+ font-size: 14px;
+ }
+}
+
+.table-form {
+ p {
+ margin-bottom: 15px;
+
+ strong {
+ font-weight: 500;
+ }
+ }
+}
+
+.simple_form,
+.table-form {
+ .warning {
+ box-sizing: border-box;
+ background: rgba($error-value-color, 0.5);
+ color: $primary-text-color;
+ text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
+ box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 15px;
+
+ a {
+ color: $primary-text-color;
+ text-decoration: underline;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: none;
+ }
+ }
+
+ strong {
+ font-weight: 600;
+ display: block;
+ margin-bottom: 5px;
+
+ .fa {
+ font-weight: 400;
+ }
+ }
+ }
+}
+
+.action-pagination {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+
+ .actions,
+ .pagination {
+ flex: 1 1 auto;
+ }
+
+ .actions {
+ padding: 30px 0;
+ padding-right: 20px;
+ flex: 0 0 auto;
+ }
+}
+
+.post-follow-actions {
+ text-align: center;
+ color: $ui-primary-color;
+
+ div {
+ margin-bottom: 4px;
+ }
+}
diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss
new file mode 100644
index 000000000..0bf9daafd
--- /dev/null
+++ b/app/javascript/styles/mastodon/landing_strip.scss
@@ -0,0 +1,36 @@
+.landing-strip,
+.memoriam-strip {
+ background: rgba(darken($ui-base-color, 7%), 0.8);
+ color: $ui-primary-color;
+ font-weight: 400;
+ padding: 14px;
+ border-radius: 4px;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+
+ strong,
+ a {
+ font-weight: 500;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+
+ .logo {
+ width: 30px;
+ height: 30px;
+ flex: 0 0 auto;
+ margin-right: 15px;
+ }
+
+ @media screen and (max-width: 740px) {
+ margin-bottom: 0;
+ }
+}
+
+.memoriam-strip {
+ background: rgba($base-shadow-color, 0.7);
+}
diff --git a/app/javascript/styles/mastodon/lists.scss b/app/javascript/styles/mastodon/lists.scss
new file mode 100644
index 000000000..6019cd800
--- /dev/null
+++ b/app/javascript/styles/mastodon/lists.scss
@@ -0,0 +1,19 @@
+.no-list {
+ list-style: none;
+
+ li {
+ display: inline-block;
+ margin: 0 5px;
+ }
+}
+
+.recovery-codes {
+ list-style: none;
+ margin: 0 auto;
+
+ li {
+ font-size: 125%;
+ line-height: 1.5;
+ letter-spacing: 1px;
+ }
+}
diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss
new file mode 100644
index 000000000..cc5ba9d7c
--- /dev/null
+++ b/app/javascript/styles/mastodon/reset.scss
@@ -0,0 +1,91 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+
+body {
+ line-height: 1;
+}
+
+ol, ul {
+ list-style: none;
+}
+
+blockquote, q {
+ quotes: none;
+}
+
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: lighten($ui-base-color, 4%);
+ border: 0px none $base-border-color;
+ border-radius: 50px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: lighten($ui-base-color, 6%);
+}
+
+::-webkit-scrollbar-thumb:active {
+ background: lighten($ui-base-color, 4%);
+}
+
+::-webkit-scrollbar-track {
+ border: 0px none $base-border-color;
+ border-radius: 0;
+ background: rgba($base-overlay-background, 0.1);
+}
+
+::-webkit-scrollbar-track:hover {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-track:active {
+ background: $ui-base-color;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
new file mode 100644
index 000000000..67bfa8a38
--- /dev/null
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -0,0 +1,254 @@
+body.rtl {
+ direction: rtl;
+
+ .column-link__icon,
+ .column-header__icon {
+ margin-right: 0;
+ margin-left: 5px;
+ }
+
+ .character-counter__wrapper {
+ margin-right: 8px;
+ margin-left: 16px;
+ }
+
+ .navigation-bar__profile {
+ margin-left: 0;
+ margin-right: 8px;
+ }
+
+ .search__input {
+ padding-right: 10px;
+ padding-left: 30px;
+ }
+
+ .search__icon .fa {
+ right: auto;
+ left: 10px;
+ }
+
+ .column-header__buttons {
+ left: 0;
+ right: auto;
+ }
+
+ .column-header__back-button {
+ padding-left: 5px;
+ padding-right: 0;
+ }
+
+ .column-header__setting-arrows {
+ float: left;
+ }
+
+ .compose-form__modifiers {
+ border-radius: 0 0 0 4px;
+ }
+
+ .setting-toggle {
+ margin-left: 0;
+ margin-right: 8px;
+ }
+
+ .setting-meta__label {
+ float: left;
+ }
+
+ .status__avatar {
+ left: auto;
+ right: 10px;
+ }
+
+ .status,
+ .activity-stream .status.light {
+ padding-left: 10px;
+ padding-right: 68px;
+ }
+
+ .status__info .status__display-name,
+ .activity-stream .status.light .status__display-name {
+ padding-left: 25px;
+ padding-right: 0;
+ }
+
+ .activity-stream .pre-header {
+ padding-right: 68px;
+ padding-left: 0;
+ }
+
+ .status__prepend {
+ margin-left: 0;
+ margin-right: 68px;
+ }
+
+ .status__prepend-icon-wrapper {
+ left: auto;
+ right: -26px;
+ }
+
+ .activity-stream .pre-header .pre-header__icon {
+ left: auto;
+ right: 42px;
+ }
+
+ .account__avatar-overlay-overlay {
+ right: auto;
+ left: 0;
+ }
+
+ .column-back-button--slim-button {
+ right: auto;
+ left: 0;
+ }
+
+ .status__relative-time,
+ .activity-stream .status.light .status__header .status__meta {
+ float: left;
+ }
+
+ .activity-stream .detailed-status.light .detailed-status__display-name > div {
+ float: right;
+ margin-right: 0;
+ margin-left: 10px;
+ }
+
+ .activity-stream .detailed-status.light .detailed-status__meta span > span {
+ margin-left: 0;
+ margin-right: 6px;
+ }
+
+ .status__action-bar-button {
+ float: right;
+ margin-right: 0;
+ margin-left: 18px;
+ }
+
+ .status__action-bar-dropdown {
+ float: right;
+ }
+
+ .privacy-dropdown__dropdown {
+ margin-left: 0;
+ margin-right: 40px;
+ }
+
+ .privacy-dropdown__option__icon {
+ margin-left: 10px;
+ margin-right: 0;
+ }
+
+ .detailed-status__display-avatar {
+ margin-right: 0;
+ margin-left: 10px;
+ float: right;
+ }
+
+ .detailed-status__favorites,
+ .detailed-status__reblogs {
+ margin-left: 0;
+ margin-right: 6px;
+ }
+
+ .fa-ul {
+ margin-left: 0;
+ margin-left: 2.14285714em;
+ }
+
+ .fa-li {
+ left: auto;
+ right: -2.14285714em;
+ }
+
+ .admin-wrapper .sidebar ul a i.fa,
+ a.table-action-link i.fa {
+ margin-right: 0;
+ margin-left: 5px;
+ }
+
+ .simple_form .check_boxes .checkbox label,
+ .simple_form .input.with_label.boolean label.checkbox {
+ padding-left: 0;
+ padding-right: 25px;
+ }
+
+ .simple_form .check_boxes .checkbox input[type="checkbox"],
+ .simple_form .input.boolean input[type="checkbox"] {
+ left: auto;
+ right: 0;
+ }
+
+ .simple_form .input-with-append .input input {
+ padding-left: 127px;
+ padding-right: 0;
+ }
+
+ .simple_form .input-with-append .append {
+ right: auto;
+ left: 0;
+ }
+
+ .table th,
+ .table td {
+ text-align: right;
+ }
+
+ .filters .filter-subset {
+ margin-right: 0;
+ margin-left: 45px;
+ }
+
+ .landing-page .header-wrapper .mascot {
+ right: 60px;
+ left: auto;
+ }
+
+ .landing-page .header .hero .floats .float-1 {
+ left: -120px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-2 {
+ left: 210px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-3 {
+ left: 110px;
+ right: auto;
+ }
+
+ .landing-page .header .links .brand img {
+ left: 0;
+ }
+
+ .landing-page .fa-external-link {
+ padding-right: 5px;
+ padding-left: 0 !important;
+ }
+
+ .landing-page .features #mastodon-timeline {
+ margin-right: 0;
+ margin-left: 30px;
+ }
+
+ @media screen and (min-width: 631px) {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+
+ &:first-child {
+ padding-left: 5px;
+ padding-right: 10px;
+ }
+ }
+
+ .columns-area > div {
+ .column,
+ .drawer {
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss
new file mode 100644
index 000000000..4f323a378
--- /dev/null
+++ b/app/javascript/styles/mastodon/stream_entries.scss
@@ -0,0 +1,339 @@
+.activity-stream {
+ clear: both;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+ .entry {
+ background: $simple-background-color;
+
+ .detailed-status.light,
+ .status.light {
+ border-bottom: 1px solid $ui-secondary-color;
+ animation: none;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-bottom: 0;
+ border-radius: 0 0 4px 4px;
+ }
+ }
+
+ &:first-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 4px 4px 0 0;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 4px;
+ }
+ }
+ }
+
+ @media screen and (max-width: 740px) {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 !important;
+ }
+ }
+ }
+
+ &.with-header {
+ .entry {
+ &:first-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0;
+ }
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 0 4px 4px;
+ }
+ }
+ }
+ }
+ }
+
+ .status.light {
+ padding: 14px 14px 14px (48px + 14px * 2);
+ position: relative;
+ min-height: 48px;
+ cursor: default;
+
+ .status__header {
+ font-size: 15px;
+
+ .status__meta {
+ float: right;
+ font-size: 14px;
+
+ .status__relative-time {
+ color: $ui-primary-color;
+ }
+ }
+ }
+
+ .status__display-name {
+ display: block;
+ max-width: 100%;
+ padding-right: 25px;
+ color: $ui-base-color;
+ }
+
+ .status__avatar {
+ position: absolute;
+ left: 14px;
+ top: 14px;
+ width: 48px;
+ height: 48px;
+
+ & > div {
+ width: 48px;
+ height: 48px;
+ }
+
+ img {
+ display: block;
+ border-radius: 4px;
+ }
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+ }
+
+ .detailed-status.light {
+ padding: 14px;
+ background: $simple-background-color;
+ cursor: default;
+
+ .detailed-status__display-name {
+ display: block;
+ overflow: hidden;
+ margin-bottom: 15px;
+
+ & > div {
+ float: left;
+ margin-right: 10px;
+ }
+
+ .display-name {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ strong {
+ font-weight: 500;
+ color: $ui-base-color;
+ }
+
+ span {
+ font-size: 14px;
+ color: $ui-primary-color;
+ }
+ }
+ }
+
+ .avatar {
+ width: 48px;
+ height: 48px;
+
+ img {
+ display: block;
+ border-radius: 4px;
+ }
+ }
+
+ .status__content {
+ color: $ui-base-color;
+
+ a {
+ color: $ui-highlight-color;
+ }
+
+ a.status__content__spoiler-link {
+ color: $primary-text-color;
+ background: $ui-primary-color;
+
+ &:hover {
+ background: lighten($ui-primary-color, 8%);
+ }
+ }
+ }
+
+ .detailed-status__meta {
+ margin-top: 15px;
+ color: $ui-primary-color;
+ font-size: 14px;
+ line-height: 18px;
+
+ a {
+ color: inherit;
+ }
+
+ span > span {
+ font-weight: 500;
+ font-size: 12px;
+ margin-left: 6px;
+ display: inline-block;
+ }
+ }
+
+ .status-card {
+ border-color: lighten($ui-secondary-color, 4%);
+ color: darken($ui-primary-color, 4%);
+
+ &:hover {
+ background: lighten($ui-secondary-color, 4%);
+ }
+ }
+
+ .status-card__title,
+ .status-card__description {
+ color: $ui-base-color;
+ }
+
+ .status-card__image {
+ background: $ui-secondary-color;
+ }
+ }
+
+ .media-spoiler {
+ background: $ui-primary-color;
+ color: $white;
+ transition: all 100ms linear;
+
+ &:hover,
+ &:active,
+ &:focus {
+ background: darken($ui-primary-color, 5%);
+ color: unset;
+ }
+ }
+
+ .pre-header {
+ padding: 14px 0;
+ padding-left: (48px + 14px * 2);
+ padding-bottom: 0;
+ margin-bottom: -4px;
+ color: $ui-primary-color;
+ font-size: 14px;
+ position: relative;
+
+ .pre-header__icon {
+ position: absolute;
+ left: (48px + 14px * 2 - 30px);
+ }
+
+ .status__display-name.muted strong {
+ color: $ui-primary-color;
+ }
+ }
+
+ .open-in-web-link {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.embed {
+ .activity-stream {
+ box-shadow: none;
+
+ .entry {
+
+ .detailed-status.light {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ .detailed-status__display-name {
+ flex: 1;
+ margin: 0 5px 15px 0;
+ }
+
+ .button.button-secondary.logo-button {
+ flex: 0 auto;
+ font-size: 14px;
+
+ svg {
+ width: 20px;
+ height: auto;
+ vertical-align: middle;
+ margin-right: 5px;
+
+ path:first-child {
+ fill: $ui-primary-color;
+ }
+
+ path:last-child {
+ fill: $simple-background-color;
+ }
+ }
+
+ &:active,
+ &:focus,
+ &:hover {
+ svg path:first-child {
+ fill: lighten($ui-primary-color, 4%);
+ }
+ }
+ }
+
+ .status__content,
+ .detailed-status__meta {
+ flex: 100%;
+ }
+ }
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
new file mode 100644
index 000000000..ad46f5f9f
--- /dev/null
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -0,0 +1,76 @@
+.table {
+ width: 100%;
+ max-width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+
+ th,
+ td {
+ padding: 8px;
+ line-height: 18px;
+ vertical-align: top;
+ border-top: 1px solid $ui-base-color;
+ text-align: left;
+ }
+
+ & > thead > tr > th {
+ vertical-align: bottom;
+ border-bottom: 2px solid $ui-base-color;
+ border-top: 0;
+ font-weight: 500;
+ }
+
+ & > tbody > tr > th {
+ font-weight: 500;
+ }
+
+ & > tbody > tr:nth-child(odd) > td,
+ & > tbody > tr:nth-child(odd) > th {
+ background: $ui-base-color;
+ }
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ &.inline-table > tbody > tr:nth-child(odd) > td,
+ &.inline-table > tbody > tr:nth-child(odd) > th {
+ background: transparent;
+ }
+}
+
+.table-wrapper {
+ overflow: auto;
+ margin-bottom: 20px;
+}
+
+samp {
+ font-family: 'mastodon-font-monospace', monospace;
+}
+
+a.table-action-link {
+ text-decoration: none;
+ display: inline-block;
+ margin-right: 5px;
+ padding: 0 10px;
+ color: rgba($primary-text-color, 0.7);
+ font-weight: 500;
+
+ &:hover {
+ color: $primary-text-color;
+ }
+
+ i.fa {
+ font-weight: 400;
+ margin-right: 5px;
+ }
+}
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
new file mode 100644
index 000000000..52c8cd1cf
--- /dev/null
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -0,0 +1,29 @@
+// Commonly used web colors
+$black: #000000; // Black
+$white: #ffffff; // White
+$success-green: #79bd9a; // Padua
+$error-red: #df405a; // Cerise
+$warning-red: #ff5050; // Sunset Orange
+$gold-star: #ca8f04; // Dark Goldenrod
+
+// Values from the classic Mastodon UI
+$classic-base-color: #282c37; // Midnight Express
+$classic-primary-color: #9baec8; // Echo Blue
+$classic-secondary-color: #d9e1e8; // Pattens Blue
+$classic-highlight-color: #2b90d9; // Summer Sky
+
+// Variables for defaults in UI
+$base-shadow-color: $black !default;
+$base-overlay-background: $black !default;
+$base-border-color: $white !default;
+$simple-background-color: $white !default;
+$primary-text-color: $white !default;
+$valid-value-color: $success-green !default;
+$error-value-color: $error-red !default;
+
+// Tell UI to use selected colors
+$ui-base-color: $classic-base-color !default; // Darkest
+$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
+$ui-primary-color: $classic-primary-color !default; // Lighter
+$ui-secondary-color: $classic-secondary-color !default; // Lightest
+$ui-highlight-color: $classic-highlight-color !default; // Vibrant
--
cgit
From fc2c8b50ddbe5dead0c68cfb470e66c6adaee05b Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Fri, 17 Nov 2017 19:25:06 -0800
Subject: Removed glitch tests
---
.../__tests__/__snapshots__/avatar-test.js.snap | 35 ------
.../__snapshots__/avatar_overlay-test.js.snap | 26 -----
.../__tests__/__snapshots__/button-test.js.snap | 130 ---------------------
.../__snapshots__/display_name-test.js.snap | 23 ----
.../glitch/components/__tests__/avatar-test.js | 36 ------
.../components/__tests__/avatar_overlay-test.js | 29 -----
.../glitch/components/__tests__/button-test.js | 82 -------------
.../components/__tests__/display_name-test.js | 18 ---
.../ui/components/__tests__/column-test.js | 34 ------
.../glitch/util/emoji/__tests__/emoji-test.js | 77 ------------
.../util/emoji/__tests__/emoji_index-test.js | 130 ---------------------
jest.config.js | 1 +
12 files changed, 1 insertion(+), 620 deletions(-)
delete mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
delete mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
delete mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
delete mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
delete mode 100644 app/javascript/themes/glitch/components/__tests__/avatar-test.js
delete mode 100644 app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
delete mode 100644 app/javascript/themes/glitch/components/__tests__/button-test.js
delete mode 100644 app/javascript/themes/glitch/components/__tests__/display_name-test.js
delete mode 100644 app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js
delete mode 100644 app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js
delete mode 100644 app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js
(limited to 'app')
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
deleted file mode 100644
index 4005c860f..000000000
--- a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
+++ /dev/null
@@ -1,35 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` Autoplay renders a animated avatar 1`] = `
-
-`;
-
-exports[` Still renders a still avatar 1`] = `
-
-`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
deleted file mode 100644
index d9e5e5252..000000000
--- a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
+++ /dev/null
@@ -1,26 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`
-
-
-
-`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
deleted file mode 100644
index 707cbf673..000000000
--- a/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
+++ /dev/null
@@ -1,130 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` adds class "button-secondary" if props.secondary given 1`] = `
-
-`;
-
-exports[` renders a button element 1`] = `
-
-`;
-
-exports[` renders a disabled attribute if props.disabled given 1`] = `
-
-`;
-
-exports[` renders class="button--block" if props.block given 1`] = `
-
-`;
-
-exports[` renders the children 1`] = `
-
-
- children
-
-
-`;
-
-exports[` renders the given text 1`] = `
-
- foo
-
-`;
-
-exports[` renders the props.text instead of children 1`] = `
-
- foo
-
-`;
-
-exports[` renders title if props.title is given 1`] = `
-
-`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
deleted file mode 100644
index 533359ffe..000000000
--- a/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
+++ /dev/null
@@ -1,23 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` renders display name + account name 1`] = `
-
- Foo
",
- }
- }
- />
-
-
- @
- bar@baz
-
-
-`;
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar-test.js b/app/javascript/themes/glitch/components/__tests__/avatar-test.js
deleted file mode 100644
index dd3f7b7d2..000000000
--- a/app/javascript/themes/glitch/components/__tests__/avatar-test.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import Avatar from '../avatar';
-
-describe(' ', () => {
- const account = fromJS({
- username: 'alice',
- acct: 'alice',
- display_name: 'Alice',
- avatar: '/animated/alice.gif',
- avatar_static: '/static/alice.jpg',
- });
-
- const size = 100;
-
- describe('Autoplay', () => {
- it('renders a animated avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- describe('Still', () => {
- it('renders a still avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
- });
-
- // TODO add autoplay test if possible
-});
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js b/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
deleted file mode 100644
index 44addea83..000000000
--- a/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import AvatarOverlay from '../avatar_overlay';
-
-describe(' {
- const account = fromJS({
- username: 'alice',
- acct: 'alice',
- display_name: 'Alice',
- avatar: '/animated/alice.gif',
- avatar_static: '/static/alice.jpg',
- });
-
- const friend = fromJS({
- username: 'eve',
- acct: 'eve@blackhat.lair',
- display_name: 'Evelyn',
- avatar: '/animated/eve.gif',
- avatar_static: '/static/eve.jpg',
- });
-
- it('renders a overlay avatar', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/themes/glitch/components/__tests__/button-test.js b/app/javascript/themes/glitch/components/__tests__/button-test.js
deleted file mode 100644
index 924ba39dc..000000000
--- a/app/javascript/themes/glitch/components/__tests__/button-test.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { shallow } from 'enzyme';
-import React from 'react';
-import renderer from 'react-test-renderer';
-import Button from '../button';
-
-describe(' ', () => {
- it('renders a button element', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the given text', () => {
- const text = 'foo';
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('handles click events using the given handler', () => {
- const handler = jest.fn();
- const button = shallow( );
- button.find('button').simulate('click');
-
- expect(handler.mock.calls.length).toEqual(1);
- });
-
- it('does not handle click events if props.disabled given', () => {
- const handler = jest.fn();
- const button = shallow( );
- button.find('button').simulate('click');
-
- expect(handler.mock.calls.length).toEqual(0);
- });
-
- it('renders a disabled attribute if props.disabled given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the children', () => {
- const children = children
;
- const component = renderer.create({children} );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders the props.text instead of children', () => {
- const text = 'foo';
- const children = children
;
- const component = renderer.create({children} );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders class="button--block" if props.block given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('adds class "button-secondary" if props.secondary given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-
- it('renders title if props.title is given', () => {
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/themes/glitch/components/__tests__/display_name-test.js b/app/javascript/themes/glitch/components/__tests__/display_name-test.js
deleted file mode 100644
index 0d040c4cd..000000000
--- a/app/javascript/themes/glitch/components/__tests__/display_name-test.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import { fromJS } from 'immutable';
-import DisplayName from '../display_name';
-
-describe(' ', () => {
- it('renders display name + account name', () => {
- const account = fromJS({
- username: 'bar',
- acct: 'bar@baz',
- display_name_html: 'Foo
',
- });
- const component = renderer.create( );
- const tree = component.toJSON();
-
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js b/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js
deleted file mode 100644
index 1e5e1d8dc..000000000
--- a/app/javascript/themes/glitch/features/ui/components/__tests__/column-test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import { mount } from 'enzyme';
-import Column from '../column';
-import ColumnHeader from '../column_header';
-
-describe(' ', () => {
- describe(' click handler', () => {
- const originalRaf = global.requestAnimationFrame;
-
- beforeEach(() => {
- global.requestAnimationFrame = jest.fn();
- });
-
- afterAll(() => {
- global.requestAnimationFrame = originalRaf;
- });
-
- it('runs the scroll animation if the column contains scrollable content', () => {
- const wrapper = mount(
-
-
-
- );
- wrapper.find(ColumnHeader).simulate('click');
- expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
- });
-
- it('does not try to scroll if there is no scrollable content', () => {
- const wrapper = mount( );
- wrapper.find(ColumnHeader).simulate('click');
- expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
- });
- });
-});
diff --git a/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js b/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js
deleted file mode 100644
index d43dd005c..000000000
--- a/app/javascript/themes/glitch/util/emoji/__tests__/emoji-test.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import emojify from '..';
-
-describe('emoji', () => {
- describe('.emojify', () => {
- it('ignores unknown shortcodes', () => {
- expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
- });
-
- it('ignores shortcodes inside of tags', () => {
- expect(emojify('
')).toEqual('
');
- });
-
- it('works with unclosed tags', () => {
- expect(emojify('hello>')).toEqual('hello>');
- expect(emojify(' {
- expect(emojify('smile:')).toEqual('smile:');
- expect(emojify(':smile')).toEqual(':smile');
- });
-
- it('does unicode', () => {
- expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
- ' ');
- expect(emojify('👨👩👧👧')).toEqual(
- ' ');
- expect(emojify('👩👩👦')).toEqual(' ');
- expect(emojify('\u2757')).toEqual(
- ' ');
- });
-
- it('does multiple unicode', () => {
- expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
- ' ');
- expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
- ' ');
- expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
- ' ');
- expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
- 'foo bar');
- });
-
- it('ignores unicode inside of tags', () => {
- expect(emojify('
')).toEqual('
');
- });
-
- it('does multiple emoji properly (issue 5188)', () => {
- expect(emojify('👌🌈💕')).toEqual(' ');
- expect(emojify('👌 🌈 💕')).toEqual(' ');
- });
-
- it('does an emoji that has no shortcode', () => {
- expect(emojify('🕉️')).toEqual(' ');
- });
-
- it('does an emoji whose filename is irregular', () => {
- expect(emojify('↙️')).toEqual(' ');
- });
-
- it('avoid emojifying on invisible text', () => {
- expect(emojify('http:// example.com/te st😄 '))
- .toEqual('http:// example.com/te st😄 ');
- expect(emojify(':luigi: ', { ':luigi:': { static_url: 'luigi.exe' } }))
- .toEqual(':luigi: ');
- });
-
- it('avoid emojifying on invisible text with nested tags', () => {
- expect(emojify('😄bar 😴 😇'))
- .toEqual('😄bar 😴 ');
- expect(emojify('😄😕 😴 😇'))
- .toEqual('😄😕 😴 ');
- expect(emojify('😄 😴 😇'))
- .toEqual('😄 😴 ');
- });
- });
-});
diff --git a/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js b/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js
deleted file mode 100644
index 53efa5743..000000000
--- a/app/javascript/themes/glitch/util/emoji/__tests__/emoji_index-test.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import { pick } from 'lodash';
-import { emojiIndex } from 'emoji-mart';
-import { search } from '../emoji_mart_search_light';
-
-const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
-
-describe('emoji_index', () => {
- it('should give same result for emoji_index_light and emoji-mart', () => {
- const expected = [
- {
- id: 'pineapple',
- unified: '1f34d',
- native: '🍍',
- },
- ];
- expect(search('pineapple').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
- });
-
- it('orders search results correctly', () => {
- const expected = [
- {
- id: 'apple',
- unified: '1f34e',
- native: '🍎',
- },
- {
- id: 'pineapple',
- unified: '1f34d',
- native: '🍍',
- },
- {
- id: 'green_apple',
- unified: '1f34f',
- native: '🍏',
- },
- {
- id: 'iphone',
- unified: '1f4f1',
- native: '📱',
- },
- ];
- expect(search('apple').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
- });
-
- it('handles custom emoji', () => {
- const custom = [
- {
- id: 'mastodon',
- name: 'mastodon',
- short_names: ['mastodon'],
- text: '',
- emoticons: [],
- keywords: ['mastodon'],
- imageUrl: 'http://example.com',
- custom: true,
- },
- ];
- search('', { custom });
- emojiIndex.search('', { custom });
- const expected = [
- {
- id: 'mastodon',
- custom: true,
- },
- ];
- expect(search('masto').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
- });
-
- it('should filter only emojis we care about, exclude pineapple', () => {
- const emojisToShowFilter = unified => unified !== '1F34D';
- expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
- .not.toContain('pineapple');
- expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
- .not.toContain('pineapple');
- });
-
- it('can include/exclude categories', () => {
- expect(search('flag', { include: ['people'] })).toEqual([]);
- expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
- });
-
- it('does an emoji whose unified name is irregular', () => {
- const expected = [
- {
- 'id': 'water_polo',
- 'unified': '1f93d',
- 'native': '🤽',
- },
- {
- 'id': 'man-playing-water-polo',
- 'unified': '1f93d-200d-2642-fe0f',
- 'native': '🤽♂️',
- },
- {
- 'id': 'woman-playing-water-polo',
- 'unified': '1f93d-200d-2640-fe0f',
- 'native': '🤽♀️',
- },
- ];
- expect(search('polo').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
- });
-
- it('can search for thinking_face', () => {
- const expected = [
- {
- id: 'thinking_face',
- unified: '1f914',
- native: '🤔',
- },
- ];
- expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
- });
-
- it('can search for woman-facepalming', () => {
- const expected = [
- {
- id: 'woman-facepalming',
- unified: '1f926-200d-2640-fe0f',
- native: '🤦♀️',
- },
- ];
- expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
- expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
- });
-});
diff --git a/jest.config.js b/jest.config.js
index 50bde57e6..dc61b9a9d 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,6 +9,7 @@ module.exports = {
'/log/',
'/public/',
'/tmp/',
+ '/app/javascript/themes/',
],
setupFiles: [
'raf/polyfill',
--
cgit
From f51f7b0e0617301b95df507dd4a11a6d03006049 Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Sat, 18 Nov 2017 11:05:00 -0800
Subject: Font styles are now packagified
---
app/javascript/styles/fonts/montserrat.scss | 8 +++----
app/javascript/styles/fonts/roboto-mono.scss | 8 +++----
app/javascript/styles/fonts/roboto.scss | 32 ++++++++++++++--------------
3 files changed, 24 insertions(+), 24 deletions(-)
(limited to 'app')
diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss
index 206f1865e..3d2b5a93f 100644
--- a/app/javascript/styles/fonts/montserrat.scss
+++ b/app/javascript/styles/fonts/montserrat.scss
@@ -1,9 +1,9 @@
@font-face {
font-family: 'mastodon-font-display';
src: local('Montserrat'),
- url('../fonts/montserrat/Montserrat-Regular.woff2') format('woff2'),
- url('../fonts/montserrat/Montserrat-Regular.woff') format('woff'),
- url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
+ url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'),
+ url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'),
+ url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@@ -11,7 +11,7 @@
@font-face {
font-family: 'mastodon-font-display';
src: local('Montserrat'),
- url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
+ url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss
index 2a1f74e16..c793aa6ed 100644
--- a/app/javascript/styles/fonts/roboto-mono.scss
+++ b/app/javascript/styles/fonts/roboto-mono.scss
@@ -1,10 +1,10 @@
@font-face {
font-family: 'mastodon-font-monospace';
src: local('Roboto Mono'),
- url('../fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
- url('../fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
- url('../fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
- url('../fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
+ url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'),
+ url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'),
+ url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
+ url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
font-weight: 400;
font-style: normal;
}
diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss
index 345d9ad50..79034c018 100644
--- a/app/javascript/styles/fonts/roboto.scss
+++ b/app/javascript/styles/fonts/roboto.scss
@@ -1,10 +1,10 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
- url('../fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
- url('../fonts/roboto/roboto-italic-webfont.woff') format('woff'),
- url('../fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
- url('../fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
+ url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
+ url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'),
+ url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
+ url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
font-weight: normal;
font-style: italic;
}
@@ -12,10 +12,10 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
- url('../fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
- url('../fonts/roboto/roboto-bold-webfont.woff') format('woff'),
- url('../fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
- url('../fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
+ url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
+ url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'),
+ url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
+ url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
font-weight: bold;
font-style: normal;
}
@@ -23,10 +23,10 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
- url('../fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
- url('../fonts/roboto/roboto-medium-webfont.woff') format('woff'),
- url('../fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
- url('../fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
+ url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
+ url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'),
+ url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
+ url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
font-weight: 500;
font-style: normal;
}
@@ -34,10 +34,10 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
- url('../fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
- url('../fonts/roboto/roboto-regular-webfont.woff') format('woff'),
- url('../fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
- url('../fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
+ url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'),
+ url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'),
+ url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
+ url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
font-weight: normal;
font-style: normal;
}
--
cgit
From 672ace5a202caf483c558a06b7e694ece12bb91b Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Sat, 18 Nov 2017 11:05:23 -0800
Subject: Media styling
---
.../themes/glitch/components/media_gallery.js | 5 ++--
app/javascript/themes/glitch/components/status.js | 6 ++++
app/javascript/themes/glitch/styles/_mixins.scss | 9 ++++++
.../themes/glitch/styles/components.scss | 33 +++++++++++++---------
app/javascript/themes/glitch/styles/index.scss | 6 ++--
5 files changed, 39 insertions(+), 20 deletions(-)
(limited to 'app')
diff --git a/app/javascript/themes/glitch/components/media_gallery.js b/app/javascript/themes/glitch/components/media_gallery.js
index 05390c82f..b6b40c585 100644
--- a/app/javascript/themes/glitch/components/media_gallery.js
+++ b/app/javascript/themes/glitch/components/media_gallery.js
@@ -214,6 +214,7 @@ export default class MediaGallery extends React.PureComponent {
render () {
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
const { visible } = this.state;
+ const size = media.take(4).size;
let children;
@@ -233,8 +234,6 @@ export default class MediaGallery extends React.PureComponent {
);
} else {
- const size = media.take(4).size;
-
if (this.isStandaloneEligible()) {
children = ;
} else {
@@ -243,7 +242,7 @@ export default class MediaGallery extends React.PureComponent {
}
return (
-
+
diff --git a/app/javascript/themes/glitch/components/status.js b/app/javascript/themes/glitch/components/status.js
index cf2fbe21e..e2ef47f5f 100644
--- a/app/javascript/themes/glitch/components/status.js
+++ b/app/javascript/themes/glitch/components/status.js
@@ -228,6 +228,10 @@ export default class Status extends ImmutablePureComponent {
this.props.onMoveDown(this.props.status.get('id'));
}
+ handleRef = c => {
+ this.node = c;
+ }
+
renderLoadingMediaGallery () {
return
;
}
@@ -238,6 +242,7 @@ export default class Status extends ImmutablePureComponent {
render () {
const {
+ handleRef,
parseClick,
setExpansion,
} = this;
@@ -389,6 +394,7 @@ export default class Status extends ImmutablePureComponent {
),
}}
{...selectorAttribs}
+ ref={handleRef}
>
{prepend && account ? (
div {
- background: url('../images/mastodon-not-found.png') no-repeat center -50px;
+ background: url('~images/mastodon-not-found.png') no-repeat center -50px;
padding-top: 210px;
width: 100%;
}
@@ -2828,6 +2820,7 @@ button.icon-button.active i.fa-retweet {
z-index: 100;
display: flex;
flex-direction: column;
+ align-items: stretch;
.status__content > & {
margin-top: 15px; // Add margin when used bare for NSFW video player
@@ -3539,7 +3532,7 @@ button.icon-button.active i.fa-retweet {
img,
canvas {
display: block;
- background: url('../images/void.png') repeat;
+ background: url('~images/void.png') repeat;
object-fit: contain;
}
@@ -3786,7 +3779,7 @@ button.icon-button.active i.fa-retweet {
}
.onboarding-modal__page-one__elephant-friend {
- background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
+ background: url('~images/elephant-friend-1.png') no-repeat center center / contain;
width: 155px;
height: 193px;
margin-right: 15px;
@@ -4196,10 +4189,12 @@ button.icon-button.active i.fa-retweet {
position: relative;
background: $base-shadow-color;
width: 100%;
+ height: 110px;
.detailed-status & {
- margin-left:-10px;
- width: calc(100% + 22px);
+ margin-left: -12px;
+ width: calc(100% + 24px);
+ height: 250px;
}
@include fullwidth-gallery;
@@ -4331,7 +4326,17 @@ button.icon-button.active i.fa-retweet {
overflow: hidden;
position: relative;
background: $base-shadow-color;
+ width: 100%;
max-width: 100%;
+ height: 110px;
+
+ .detailed-status & {
+ margin-left: -12px;
+ width: calc(100% + 24px);
+ height: 250px;
+ }
+
+ @include fullwidth-gallery;
video {
height: 100%;
diff --git a/app/javascript/themes/glitch/styles/index.scss b/app/javascript/themes/glitch/styles/index.scss
index 0eb6ac6d8..c962a1f62 100644
--- a/app/javascript/themes/glitch/styles/index.scss
+++ b/app/javascript/themes/glitch/styles/index.scss
@@ -1,8 +1,8 @@
@import 'mixins';
@import 'variables';
-@import 'fonts/roboto';
-@import 'fonts/roboto-mono';
-@import 'fonts/montserrat';
+@import 'styles/fonts/roboto';
+@import 'styles/fonts/roboto-mono';
+@import 'styles/fonts/montserrat';
@import 'reset';
@import 'basics';
--
cgit
From 08a01dd0374618438aab95c41940841b66c5e2a9 Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Sat, 18 Nov 2017 11:05:39 -0800
Subject: Public pack fix
---
app/javascript/packs/public.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'app')
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 4362905da..6adacad98 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -22,7 +22,7 @@ function main() {
const { length } = require('stringz');
const IntlRelativeFormat = require('intl-relativeformat').default;
const { delegate } = require('rails-ujs');
- const emojify = require('../themes/glitch/features/emoji/emoji').default;
+ const emojify = require('../themes/glitch/util/emoji').default;
const { getLocale } = require('mastodon/locales');
const { localeData } = getLocale();
const VideoContainer = require('../themes/glitch/containers/video_container').default;
--
cgit
From 92cc79be7206534e8c9a9957cc89b5d0eb0bcfac Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Sat, 18 Nov 2017 11:06:04 -0800
Subject: Enabled vanilla thmee
---
app/javascript/themes/vanilla/theme.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'app')
diff --git a/app/javascript/themes/vanilla/theme.yml b/app/javascript/themes/vanilla/theme.yml
index 120c4b669..0b262cc82 100644
--- a/app/javascript/themes/vanilla/theme.yml
+++ b/app/javascript/themes/vanilla/theme.yml
@@ -1,5 +1,5 @@
# (REQUIRED) The location of the pack file inside `pack_directory`.
-#pack: application.js
+pack: application.js
# (OPTIONAL) The directory which contains the pack file.
# Defaults to the theme directory (`app/javascript/themes/[theme]`),
--
cgit