From 65c10c0bc829bb97ad86436e0715d17e82d53c2f Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sat, 24 Mar 2018 09:04:02 +0900 Subject: [PATCH 01/20] Weblate translations (2018-03-23) (#6874) * Translated using Weblate (Galician) Currently translated at 100.0% (587 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/gl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (587 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/nl/ * Translated using Weblate (Catalan) Currently translated at 100.0% (587 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ca/ * Translated using Weblate (Arabic) Currently translated at 76.4% (449 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ar/ * Translated using Weblate (Japanese) Currently translated at 99.8% (586 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/ja/ * Translated using Weblate (Slovak) Currently translated at 92.3% (542 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/sk/ * Translated using Weblate (Slovak) Currently translated at 92.3% (542 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/sk/ * Translated using Weblate (Slovak) Currently translated at 100.0% (58 of 58 strings) Translation: Mastodon/Preferences Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/simple_form/sk/ * Translated using Weblate (Polish) Currently translated at 98.9% (581 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pl/ * Translated using Weblate (French) Currently translated at 99.6% (585 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/fr/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.8% (586 of 587 strings) Translation: Mastodon/Backend Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/backend/pt_BR/ * Translated using Weblate (Catalan) Currently translated at 100.0% (280 of 280 strings) Translation: Mastodon/React Translate-URL: https://weblate.joinmastodon.org/projects/mastodon/frontend/ca/ * bundle exec i18n-tasks normalize && yarn manage:translations --- app/javascript/mastodon/locales/ca.json | 22 +++++++++++----------- config/locales/ar.yml | 5 ++++- config/locales/ca.yml | 9 +++++++++ config/locales/fr.yml | 8 ++++++++ config/locales/gl.yml | 9 +++++++++ config/locales/ja.yml | 9 +++++++++ config/locales/nl.yml | 9 +++++++++ config/locales/pl.yml | 2 ++ config/locales/pt-BR.yml | 9 +++++++++ config/locales/simple_form.sk.yml | 2 +- config/locales/sk.yml | 8 ++++---- 11 files changed, 75 insertions(+), 17 deletions(-) diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 4923c1032..3222daa2f 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -1,9 +1,9 @@ { "account.block": "Bloca @{name}", "account.block_domain": "Amaga-ho tot de {domain}", - "account.blocked": "Blocked", + "account.blocked": "Bloquejat", "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.", - "account.domain_blocked": "Domain hidden", + "account.domain_blocked": "Domini ocult", "account.edit_profile": "Edita el perfil", "account.follow": "Segueix", "account.followers": "Seguidors", @@ -15,7 +15,7 @@ "account.moved_to": "{name} s'ha mogut a:", "account.mute": "Silencia @{name}", "account.mute_notifications": "Notificacions desactivades de @{name}", - "account.muted": "Muted", + "account.muted": "Silenciat", "account.posts": "Toots", "account.posts_with_replies": "Toots amb respostes", "account.report": "Informe @{name}", @@ -60,10 +60,10 @@ "compose_form.placeholder": "En què estàs pensant?", "compose_form.publish": "Toot", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive.marked": "Media is marked as sensitive", - "compose_form.sensitive.unmarked": "Media is not marked as sensitive", - "compose_form.spoiler.marked": "Text is hidden behind warning", - "compose_form.spoiler.unmarked": "Text is not hidden", + "compose_form.sensitive.marked": "Mèdia marcat com a sensible", + "compose_form.sensitive.unmarked": "Mèdia no està marcat com a sensible", + "compose_form.spoiler.marked": "Text ocult sota l'avís", + "compose_form.spoiler.unmarked": "Text no ocult", "compose_form.spoiler_placeholder": "Escriu l'avís aquí", "confirmation_modal.cancel": "Cancel·la", "confirmations.block.confirm": "Bloca", @@ -221,7 +221,7 @@ "report.target": "Informes", "search.placeholder": "Cercar", "search_popout.search_format": "Format de cerca avançada", - "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a favorites, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.", "search_popout.tips.hashtag": "etiqueta", "search_popout.tips.status": "status", "search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i els hashtags", @@ -244,7 +244,7 @@ "status.mute_conversation": "Silenciar conversació", "status.open": "Ampliar aquest estat", "status.pin": "Fixat en el perfil", - "status.pinned": "Pinned toot", + "status.pinned": "Toot fixat", "status.reblog": "Impuls", "status.reblogged_by": "{name} ha retootejat", "status.reply": "Respondre", @@ -254,9 +254,9 @@ "status.sensitive_warning": "Contingut sensible", "status.share": "Compartir", "status.show_less": "Mostra menys", - "status.show_less_all": "Show less for all", + "status.show_less_all": "Mostra menys per a tot", "status.show_more": "Mostra més", - "status.show_more_all": "Show more for all", + "status.show_more_all": "Mostra més per a tot", "status.unmute_conversation": "Activar conversació", "status.unpin": "Deslliga del perfil", "tabs_bar.federated_timeline": "Federada", diff --git a/config/locales/ar.yml b/config/locales/ar.yml index e6447cab3..25ca302d6 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -273,6 +273,7 @@ ar: your_token: رمز نفاذك auth: agreement_html: بقبولك التسجيل فإنك تُصرِّح قبول قواعد مثيل الخادوم و شروط الخدمة التي نوفرها لك. + change_password: الكلمة السرية confirm_email: تأكيد عنوان البريد الإلكتروني delete_account: حذف حساب delete_account_html: إن كنت ترغب في حذف حسابك يُمكنك المواصلة هنا. سوف يُطلَبُ منك التأكيد قبل الحذف. @@ -290,7 +291,7 @@ ar: resend_confirmation: إعادة إرسال تعليمات التأكيد reset_password: إعادة تعيين كلمة المرور security: الهوية - set_new_password: تعيين كلمة مرور جديدة + set_new_password: إدخال كلمة مرور جديدة authorize_follow: error: يا للأسف، وقع هناك خطأ إثر عملية البحث عن الحساب عن بعد follow: إتبع @@ -493,6 +494,7 @@ ar: windows: ويندوز windows_mobile: ويندوز موبايل windows_phone: ويندوز فون + revoke_success: تم إبطال الجلسة بنجاح title: الجلسات settings: authorized_apps: التطبيقات المرخص لها @@ -557,3 +559,4 @@ ar: users: invalid_email: عنوان البريد الإلكتروني غير صالح invalid_otp_token: الرمز الثنائي غير صالح + seamless_external_login: لقد قمت بتسجيل الدخول عبر خدمة خارجية، إنّ إعدادات الكلمة السرية و البريد الإلكتروني غير متوفرة. diff --git a/config/locales/ca.yml b/config/locales/ca.yml index c4008c998..7727bad37 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -634,6 +634,15 @@ ca: two_factor_authentication: Autenticació de dos factors your_apps: Les teves aplicacions statuses: + attached: + description: 'Adjunt: %{attached}' + image: + one: "%{count} imatge" + other: "%{count} imatges" + video: + one: "%{count} vídeo" + other: "%{count} vídeos" + content_warning: 'Avís de contingut: %{warning}' open_in_web: Obre en la web over_character_limit: Límit de caràcters de %{max} superat pin_errors: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 57ed05f40..6137e1bd4 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -634,6 +634,14 @@ fr: two_factor_authentication: Identification à deux facteurs your_apps: Vos applications statuses: + attached: + description: 'Attaché : %{attached}' + image: + one: "%{count} image" + other: "%{count} images" + video: + one: "%{count} vidéo" + other: "%{count} vidéos" open_in_web: Ouvrir sur le web over_character_limit: limite de caractères dépassée de %{max} caractères pin_errors: diff --git a/config/locales/gl.yml b/config/locales/gl.yml index 30b68d7d6..bddc1b789 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -634,6 +634,15 @@ gl: two_factor_authentication: Validar Doble Factor your_apps: Os seus aplicativos statuses: + attached: + description: 'Axenado: %{attached}' + image: + one: "%{count} imaxe" + other: "%{count} imaxes" + video: + one: "%{count} vídeo" + other: "%{count} vídeos" + content_warning: 'Aviso sobre o contido: %{warning}' open_in_web: Abrir na web over_character_limit: Excedeu o límite de caracteres %{max} pin_errors: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 887eb016d..3b1990214 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -634,6 +634,15 @@ ja: two_factor_authentication: 二段階認証 your_apps: アプリ statuses: + attached: + description: '添付: %{attached}' + image: + one: "%{count} 枚の画像" + other: "%{count} 枚の画像" + video: + one: "%{count} 枚の動画" + other: "%{count} 枚の動画" + content_warning: '閲覧注意: %{warning}' open_in_web: Webで開く over_character_limit: 上限は %{max}文字までです pin_errors: diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 66057e606..f3488f708 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -634,6 +634,15 @@ nl: two_factor_authentication: Tweestapsverificatie your_apps: Jouw toepassingen statuses: + attached: + description: 'Bijlagen: %{attached}' + image: + one: "%{count} afbeelding" + other: "%{count} afbeeldingen" + video: + one: "%{count} video" + other: "%{count} video's" + content_warning: 'Tekstwaarschuwing: %{warning}' open_in_web: In de webapp openen over_character_limit: Limiet van %{max} tekens overschreden pin_errors: diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 78ca41102..de43ca9a9 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -641,6 +641,8 @@ pl: two_factor_authentication: Uwierzytelnianie dwuetapowe your_apps: Twoje aplikacje statuses: + attached: + description: 'Przytwierdzony: %{attached}' open_in_web: Otwórz w przeglądarce over_character_limit: limit %{max} znaków przekroczony pin_errors: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 88d4e92ff..589f44fa1 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -634,6 +634,15 @@ pt-BR: two_factor_authentication: Autenticação em dois passos your_apps: Seus aplicativos statuses: + attached: + description: 'Anexado: %{attached}' + image: + one: "%{count} imagem" + other: "%{count} imagens" + video: + one: "%{count} vídeo" + other: "%{count} vídeos" + content_warning: 'Aviso de conteúdo: %{warning}' open_in_web: Abrir na web over_character_limit: limite de caracteres de %{max} excedido pin_errors: diff --git a/config/locales/simple_form.sk.yml b/config/locales/simple_form.sk.yml index dd3651ee3..7d4241bac 100644 --- a/config/locales/simple_form.sk.yml +++ b/config/locales/simple_form.sk.yml @@ -46,7 +46,7 @@ sk: setting_default_sensitive: Označiť každý obrázok/video/súbor ako chúlostivý setting_delete_modal: Zobrazovať potvrdzovacie okno pred zmazaním toot-u setting_display_sensitive_media: Vždy zobrazovať médiá označované ako senzitívne - setting_noindex: Nezaradzovať vaše príspevky do indexácie pre vyhľadávanie + setting_noindex: Nezaraďuj príspevky do indexu pre vyhľadávče setting_reduce_motion: Redukovať pohyb v animáciách setting_system_font_ui: Použiť základné systémové písmo setting_theme: Vzhľad diff --git a/config/locales/sk.yml b/config/locales/sk.yml index e391974c6..a0e1a597c 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -414,10 +414,10 @@ sk: warning_title: Dostupnosť distribuovaného obsahu errors: '403': Nemáte dostatočné povolenie na zobrazenie tejto stránky. - '404': Stránka ktorú ste hľadali neexistuje. - '410': Stránka ktorú tu hľadáte už viac neexistuje. + '404': Stránka ktorú si hľadal/a sa tu nenachádza. + '410': Stránka ktorú tu hľadáš už viac neexistuje. '422': - content: Bezpečtnostné overenie zlyhalo. Blokujete cookies? + content: Bezpečtnostné overenie zlyhalo. Blokuješ cookies? title: Bezpečtnostné overenie zlyhalo '429': Zamlčané '500': @@ -602,7 +602,7 @@ sk: import: Importovať migrate: Presunúť účet notifications: Oznámenia - preferences: Možnosti + preferences: Voľby settings: Nastavenia two_factor_authentication: Dvoj-faktorové overenie your_apps: Tvoje aplikácie From 4e71b104e6d5f02069120c7a56b26888c6f0fef5 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 18:54:19 +0900 Subject: [PATCH 02/20] Internationalize unexpected error message (#6887) --- .../ui/containers/notifications_container.js | 19 +++++++++++++++---- app/javascript/mastodon/locales/ar.json | 2 ++ app/javascript/mastodon/locales/bg.json | 2 ++ app/javascript/mastodon/locales/ca.json | 2 ++ app/javascript/mastodon/locales/de.json | 2 ++ .../mastodon/locales/defaultMessages.json | 13 +++++++++++++ app/javascript/mastodon/locales/en.json | 2 ++ app/javascript/mastodon/locales/eo.json | 2 ++ app/javascript/mastodon/locales/es.json | 2 ++ app/javascript/mastodon/locales/fa.json | 2 ++ app/javascript/mastodon/locales/fi.json | 2 ++ app/javascript/mastodon/locales/fr.json | 2 ++ app/javascript/mastodon/locales/gl.json | 2 ++ app/javascript/mastodon/locales/he.json | 2 ++ app/javascript/mastodon/locales/hr.json | 2 ++ app/javascript/mastodon/locales/hu.json | 2 ++ app/javascript/mastodon/locales/hy.json | 2 ++ app/javascript/mastodon/locales/id.json | 2 ++ app/javascript/mastodon/locales/io.json | 2 ++ app/javascript/mastodon/locales/it.json | 2 ++ app/javascript/mastodon/locales/ja.json | 2 ++ app/javascript/mastodon/locales/ko.json | 2 ++ app/javascript/mastodon/locales/nl.json | 2 ++ app/javascript/mastodon/locales/no.json | 2 ++ app/javascript/mastodon/locales/oc.json | 2 ++ app/javascript/mastodon/locales/pl.json | 2 ++ app/javascript/mastodon/locales/pt-BR.json | 2 ++ app/javascript/mastodon/locales/pt.json | 2 ++ app/javascript/mastodon/locales/ru.json | 2 ++ app/javascript/mastodon/locales/sk.json | 2 ++ app/javascript/mastodon/locales/sr-Latn.json | 2 ++ app/javascript/mastodon/locales/sr.json | 2 ++ app/javascript/mastodon/locales/sv.json | 2 ++ app/javascript/mastodon/locales/th.json | 2 ++ app/javascript/mastodon/locales/tr.json | 2 ++ app/javascript/mastodon/locales/uk.json | 2 ++ app/javascript/mastodon/locales/zh-CN.json | 2 ++ app/javascript/mastodon/locales/zh-HK.json | 2 ++ app/javascript/mastodon/locales/zh-TW.json | 2 ++ app/javascript/mastodon/middleware/errors.js | 8 +++++++- 40 files changed, 109 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js index 5924197f1..b60a0216f 100644 --- a/app/javascript/mastodon/features/ui/containers/notifications_container.js +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -1,11 +1,22 @@ +import { injectIntl } from 'react-intl'; 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 mapStateToProps = (state, { intl }) => { + const notifications = getAlerts(state); + + notifications.forEach(notification => ['title', 'message'].forEach(key => { + const value = notification[key]; + + if (typeof value === 'object') { + notification[key] = intl.formatMessage(value); + } + })); + + return { notifications }; +}; const mapDispatchToProps = (dispatch) => { return { @@ -15,4 +26,4 @@ const mapDispatchToProps = (dispatch) => { }; }; -export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack); +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 73680a1a1..3d9620793 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -28,6 +28,8 @@ "account.unmute": "إلغاء الكتم عن @{name}", "account.unmute_notifications": "إلغاء كتم إخطارات @{name}", "account.view_full_profile": "عرض الملف الشخصي كاملا", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", "bundle_column_error.retry": "إعادة المحاولة", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index 1dee16748..39eb05f2a 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -28,6 +28,8 @@ "account.unmute": "Unmute @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "You can press {combo} to skip this next time", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 3222daa2f..33545d86f 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -28,6 +28,8 @@ "account.unmute": "Treure silenci de @{name}", "account.unmute_notifications": "Activar notificacions de @{name}", "account.view_full_profile": "Mostra el perfil complet", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop", "bundle_column_error.body": "S'ha produït un error en carregar aquest component.", "bundle_column_error.retry": "Torna-ho a provar", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index e0fc0ee85..7bdb6a3c6 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -28,6 +28,8 @@ "account.unmute": "@{name} nicht mehr stummschalten", "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten", "account.view_full_profile": "Vollständiges Profil anzeigen", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", "bundle_column_error.retry": "Erneut versuchen", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index d5b9c09cb..b983823d4 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -1734,5 +1734,18 @@ } ], "path": "app/javascript/mastodon/features/video/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Oops!", + "id": "alert.unexpected.title" + }, + { + "defaultMessage": "An unexpected error occurred.", + "id": "alert.unexpected.message" + } + ], + "path": "app/javascript/mastodon/middleware/errors.json" } ] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d0d863f79..5553772f4 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -28,6 +28,8 @@ "account.unmute": "Unmute @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "You can press {combo} to skip this next time", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index fd687e8b1..35d9edf2b 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -28,6 +28,8 @@ "account.unmute": "Malsilentigi @{name}", "account.unmute_notifications": "Malsilentigi sciigojn de @{name}", "account.view_full_profile": "Vidi plenan profilon", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje", "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", "bundle_column_error.retry": "Bonvolu reprovi", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 2107a1525..e69938b0f 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -28,6 +28,8 @@ "account.unmute": "Dejar de silenciar a @{name}", "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}", "account.view_full_profile": "Ver perfil completo", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez", "bundle_column_error.body": "Algo salió mal al cargar este componente.", "bundle_column_error.retry": "Inténtalo de nuevo", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 455dc5d9f..c9695d0a4 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -28,6 +28,8 @@ "account.unmute": "باصدا کردن @{name}", "account.unmute_notifications": "باصداکردن اعلان‌ها از طرف @{name}", "account.view_full_profile": "نمایش نمایهٔ کامل", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید", "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.", "bundle_column_error.retry": "تلاش دوباره", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 1741445ed..cbdffec10 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -28,6 +28,8 @@ "account.unmute": "Poista mykistys käyttäjältä @{name}", "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta", "account.view_full_profile": "Näytä koko profiili", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Voit painaa näppäimiä {combo} ohittaaksesi tämän ensi kerralla", "bundle_column_error.body": "Jokin meni vikaan tätä komponenttia ladatessa.", "bundle_column_error.retry": "Yritä uudestaan", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 40fd6163e..8c56a7558 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -28,6 +28,8 @@ "account.unmute": "Ne plus masquer", "account.unmute_notifications": "Réactiver les notifications de @{name}", "account.view_full_profile": "Afficher le profil complet", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois", "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.", "bundle_column_error.retry": "Réessayer", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index edfb9cfcb..c5cedd60a 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -28,6 +28,8 @@ "account.unmute": "Non acalar @{name}", "account.unmute_notifications": "Desbloquear as notificacións de @{name}", "account.view_full_profile": "Ver o perfil completo", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Pulse {combo} para saltar esto a próxima vez", "bundle_column_error.body": "Houbo un fallo mentras se cargaba este compoñente.", "bundle_column_error.retry": "Inténteo de novo", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index b637ae414..fe6f9bbb1 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -28,6 +28,8 @@ "account.unmute": "הפסקת השתקת @{name}", "account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}", "account.view_full_profile": "הראה אודות מלאות", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה", "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.", "bundle_column_error.retry": "לנסות שוב", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index 4b64d796d..11cd1bff2 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -28,6 +28,8 @@ "account.unmute": "Poništi utišavanje @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 79888e41e..1ea65768a 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -28,6 +28,8 @@ "account.unmute": "@{name} kinémítása", "account.unmute_notifications": "@{name} értesítéseinek kinémítása", "account.view_full_profile": "Teljes profil megtekintése", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Megnyomhatod {combo}, hogy átugord következő alkalommal", "bundle_column_error.body": "Hiba történt a komponens betöltése közben.", "bundle_column_error.retry": "Próbálja újra", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index 932ff1565..e9638bf96 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -28,6 +28,8 @@ "account.unmute": "Ապալռեցնել @{name}֊ին", "account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից", "account.view_full_profile": "Դիտել ամբողջական տարբերակը։", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա հաջորդ անգամ բաց թողնելու համար", "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանվեց։", "bundle_column_error.retry": "Կրկին փորձել", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index bc4294679..c8d8ebe76 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -28,6 +28,8 @@ "account.unmute": "Berhenti membisukan @{name}", "account.unmute_notifications": "Munculkan notifikasi dari @{name}", "account.view_full_profile": "Lihat profil lengkap", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini", "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.", "bundle_column_error.retry": "Coba lagi", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index 5ea982f46..a2e9af8ef 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -28,6 +28,8 @@ "account.unmute": "Ne plus celar @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 068598de2..40ea9b26d 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -28,6 +28,8 @@ "account.unmute": "Non silenziare @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 0b88ac2df..08f5e7962 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -28,6 +28,8 @@ "account.unmute": "@{name}さんのミュートを解除", "account.unmute_notifications": "@{name}さんからの通知を受け取る", "account.view_full_profile": "全ての情報を見る", + "alert.unexpected.message": "不明なエラーが発生しました", + "alert.unexpected.title": "エラー", "boost_modal.combo": "次からは{combo}を押せばスキップできます", "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。", "bundle_column_error.retry": "再試行", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 532c1f04d..bde4397f3 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -28,6 +28,8 @@ "account.unmute": "뮤트 해제", "account.unmute_notifications": "@{name}의 알림 뮤트 해제", "account.view_full_profile": "전체 프로필 보기", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.", "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.", "bundle_column_error.retry": "다시 시도", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index a83971f00..140be0dca 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -28,6 +28,8 @@ "account.unmute": "@{name} niet meer negeren", "account.unmute_notifications": "@{name} meldingen niet meer negeren", "account.view_full_profile": "Volledig profiel tonen", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan", "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.", "bundle_column_error.retry": "Opnieuw proberen", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index aaad033e2..4d6ac133e 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -28,6 +28,8 @@ "account.unmute": "Avdemp @{name}", "account.unmute_notifications": "Vis varsler fra @{name}", "account.view_full_profile": "Vis hele profilen", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang", "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.", "bundle_column_error.retry": "Prøv igjen", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index f93fe29f6..24dfa9375 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -28,6 +28,8 @@ "account.unmute": "Quitar de rescondre @{name}", "account.unmute_notifications": "Mostrar las notificacions de @{name}", "account.view_full_profile": "Veire lo perfil complèt", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven", "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.", "bundle_column_error.retry": "Tornar ensajar", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 8496495f5..0b6f178f8 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -28,6 +28,8 @@ "account.unmute": "Cofnij wyciszenie @{name}", "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}", "account.view_full_profile": "Wyświetl pełny profil", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", "bundle_column_error.retry": "Spróbuj ponownie", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index c90fb37a0..dcaeaced9 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -28,6 +28,8 @@ "account.unmute": "Não silenciar @{name}", "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}", "account.view_full_profile": "Ver perfil completo", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez", "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", "bundle_column_error.retry": "Tente novamente", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 3b20cf4e6..4725a82da 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -28,6 +28,8 @@ "account.unmute": "Não silenciar @{name}", "account.unmute_notifications": "Deixar de silenciar @{name}", "account.view_full_profile": "Ver perfil completo", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", "bundle_column_error.retry": "Tente de novo", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index ec21b5d55..8e7d36659 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -28,6 +28,8 @@ "account.unmute": "Снять глушение", "account.unmute_notifications": "Показывать уведомления от @{name}", "account.view_full_profile": "Показать полный профиль", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.", "bundle_column_error.retry": "Попробовать снова", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index 683f2aadb..e3b323943 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -28,6 +28,8 @@ "account.unmute": "Prestať ignorovať @{name}", "account.unmute_notifications": "Odtĺmiť notifikácie od @{name}", "account.view_full_profile": "Pozri celý profil", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Nabudúce môžete kliknúť {combo} aby ste preskočili", "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.", "bundle_column_error.retry": "Skúste znova", diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json index c6512cda4..d38e8e3af 100644 --- a/app/javascript/mastodon/locales/sr-Latn.json +++ b/app/javascript/mastodon/locales/sr-Latn.json @@ -28,6 +28,8 @@ "account.unmute": "Ukloni ućutkavanje korisniku @{name}", "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}", "account.view_full_profile": "Vidi ceo profil", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put", "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.", "bundle_column_error.retry": "Pokušajte ponovo", diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json index 93fbe5960..3be0c89ee 100644 --- a/app/javascript/mastodon/locales/sr.json +++ b/app/javascript/mastodon/locales/sr.json @@ -28,6 +28,8 @@ "account.unmute": "Уклони ућуткавање кориснику @{name}", "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}", "account.view_full_profile": "Види цео профил", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут", "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.", "bundle_column_error.retry": "Покушајте поново", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index 4fa129173..a13ba9847 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -28,6 +28,8 @@ "account.unmute": "Ta bort tystad @{name}", "account.unmute_notifications": "Återaktivera notifikationer från @{name}", "account.view_full_profile": "Visa hela profilen", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Du kan trycka {combo} för att slippa denna nästa gång", "bundle_column_error.body": "Något gick fel när du laddade denna komponent.", "bundle_column_error.retry": "Försök igen", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 95a933b40..59ff10b46 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -28,6 +28,8 @@ "account.unmute": "Unmute @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "You can press {combo} to skip this next time", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index baaa5c97a..e83af319e 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -28,6 +28,8 @@ "account.unmute": "Sesi aç @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Bir dahaki sefere {combo} tuşuna basabilirsiniz", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 1755c55b4..accc2d027 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -28,6 +28,8 @@ "account.unmute": "Зняти глушення", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "View full profile", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index d031c85f3..b9a912fb0 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -28,6 +28,8 @@ "account.unmute": "不再隐藏 @{name}", "account.unmute_notifications": "不再隐藏来自 @{name} 的通知", "account.view_full_profile": "查看完整资料", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "下次按住 {combo} 即可跳过此提示", "bundle_column_error.body": "载入这个组件时发生了错误。", "bundle_column_error.retry": "重试", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index d3ad238ad..91b1d00af 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -28,6 +28,8 @@ "account.unmute": "取消 @{name} 的靜音", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "查看完整資料", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},", "bundle_column_error.body": "加載本組件出錯。", "bundle_column_error.retry": "重試", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 3a5eade41..7e845c650 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -28,6 +28,8 @@ "account.unmute": "不再消音 @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account.view_full_profile": "查看完整資訊", + "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.title": "Oops!", "boost_modal.combo": "下次你可以按 {combo} 來跳過", "bundle_column_error.body": "加載本組件出錯。", "bundle_column_error.retry": "重試", diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js index b2c5f0898..72e5631e6 100644 --- a/app/javascript/mastodon/middleware/errors.js +++ b/app/javascript/mastodon/middleware/errors.js @@ -1,7 +1,13 @@ +import { defineMessages } from 'react-intl'; import { showAlert } from '../actions/alerts'; const defaultFailSuffix = 'FAIL'; +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, +}); + export default function errorsMiddleware() { return ({ dispatch }) => next => action => { if (action.type && !action.skipAlert) { @@ -21,7 +27,7 @@ export default function errorsMiddleware() { dispatch(showAlert(title, message)); } else { console.error(action.error); - dispatch(showAlert('Oops!', 'An unexpected error occurred.')); + dispatch(showAlert(messages.unexpectedTitle, messages.unexpectedMessage)); } } } From 54b273bf993888cd079113dd588cb7a90228b93b Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 20:49:54 +0900 Subject: [PATCH 03/20] Close http connection in perform method of Request class (#6889) HTTP connections must be explicitly closed in many cases, and letting perform method close connections makes its callers less redundant and prevent them from forgetting to close connections. --- app/helpers/jsonld_helper.rb | 6 +-- app/lib/provider_discovery.rb | 17 ++++--- app/lib/request.rb | 16 +++++-- app/models/concerns/remotable.rb | 28 +++++------ app/services/fetch_atom_service.rb | 47 ++++++++++--------- app/services/fetch_link_card_service.rb | 21 ++++++--- app/services/resolve_account_service.rb | 9 ++-- app/services/send_interaction_service.rb | 8 ++-- app/services/subscribe_service.rb | 34 +++++++------- app/services/unsubscribe_service.rb | 7 ++- app/workers/activitypub/delivery_worker.rb | 15 +++--- .../pubsubhubbub/confirmation_worker.rb | 18 +++---- app/workers/pubsubhubbub/delivery_worker.rb | 17 +++---- lib/tasks/mastodon.rake | 4 +- spec/lib/request_spec.rb | 14 ++++-- 15 files changed, 134 insertions(+), 127 deletions(-) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 9530ad9f3..957a2cbc9 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -60,9 +60,9 @@ module JsonLdHelper end def fetch_resource_without_id_validation(uri) - response = build_request(uri).perform - return if response.code != 200 - body_to_json(response.to_s) + build_request(uri).perform do |response| + response.code == 200 ? body_to_json(response.to_s) : nil + end end def body_to_json(body) diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb index 5732e4fcb..bbd3a2d43 100644 --- a/app/lib/provider_discovery.rb +++ b/app/lib/provider_discovery.rb @@ -13,15 +13,14 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery def discover_provider(url, **options) format = options[:format] - if options[:html] - html = Nokogiri::HTML(options[:html]) - else - res = Request.new(:get, url).perform - - raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' - - html = Nokogiri::HTML(res.to_s) - end + html = if options[:html] + Nokogiri::HTML(options[:html]) + else + Request.new(:get, url).perform do |res| + raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' + Nokogiri::HTML(res.to_s) + end + end if format.nil? || format == :json provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value diff --git a/app/lib/request.rb b/app/lib/request.rb index 298fb9528..8a127c65f 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -33,9 +33,17 @@ class Request end def perform - http_client.headers(headers).public_send(@verb, @url.to_s, @options) - rescue => e - raise e.class, "#{e.message} on #{@url}", e.backtrace[0] + begin + response = http_client.headers(headers).public_send(@verb, @url.to_s, @options) + rescue => e + raise e.class, "#{e.message} on #{@url}", e.backtrace[0] + end + + begin + yield response + ensure + http_client.close + end end def headers @@ -88,7 +96,7 @@ class Request end def http_client - HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) + @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) end class Socket < TCPSocket diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 69685ec83..0f18c5d96 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -21,23 +21,23 @@ module Remotable return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url begin - response = Request.new(:get, url).perform + Request.new(:get, url).perform do |response| + next if response.code != 200 - return if response.code != 200 + matches = response.headers['content-disposition']&.match(/filename="([^"]*)"/) + filename = matches.nil? ? parsed_url.path.split('/').last : matches[1] + basename = SecureRandom.hex(8) + extname = if filename.nil? + '' + else + File.extname(filename) + end - matches = response.headers['content-disposition']&.match(/filename="([^"]*)"/) - filename = matches.nil? ? parsed_url.path.split('/').last : matches[1] - basename = SecureRandom.hex(8) - extname = if filename.nil? - '' - else - File.extname(filename) - end + send("#{attachment_name}=", StringIO.new(response.to_s)) + send("#{attachment_name}_file_name=", basename + extname) - send("#{attachment_name}=", StringIO.new(response.to_s)) - send("#{attachment_name}_file_name=", basename + extname) - - self[attribute_name] = url if has_attribute?(attribute_name) + self[attribute_name] = url if has_attribute?(attribute_name) + end rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" nil diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index c07859845..48ad5dcd3 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -24,43 +24,44 @@ class FetchAtomService < BaseService def process(url, terminal = false) @url = url - perform_request - process_response(terminal) + perform_request { |response| process_response(response, terminal) } end - def perform_request + def perform_request(&block) accept = 'text/html' accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity - @response = Request.new(:get, @url) - .add_headers('Accept' => accept) - .perform + Request.new(:get, @url).add_headers('Accept' => accept).perform(&block) end - def process_response(terminal = false) - return nil if @response.code != 200 + def process_response(response, terminal = false) + return nil if response.code != 200 - if @response.mime_type == 'application/atom+xml' - [@url, { prefetched_body: @response.to_s }, :ostatus] - elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - json = body_to_json(@response.to_s) + if response.mime_type == 'application/atom+xml' + [@url, { prefetched_body: response.to_s }, :ostatus] + elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type) + json = body_to_json(response.to_s) if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? - [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] + [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] elsif supported_context?(json) && json['type'] == 'Note' - [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] + [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] else @unsupported_activity = true nil end - elsif @response['Link'] && !terminal && link_header.find_link(%w(rel alternate)) - process_headers - elsif @response.mime_type == 'text/html' && !terminal - process_html + elsif !terminal + link_header = response['Link'] && parse_link_header(response) + + if link_header&.find_link(%w(rel alternate)) + process_link_headers(link_header) + elsif response.mime_type == 'text/html' + process_html(response) + end end end - def process_html - page = Nokogiri::HTML(@response.to_s) + def process_html(response) + page = Nokogiri::HTML(response.to_s) json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } @@ -71,7 +72,7 @@ class FetchAtomService < BaseService result end - def process_headers + def process_link_headers(link_header) json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) @@ -81,7 +82,7 @@ class FetchAtomService < BaseService result end - def link_header - @link_header ||= LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link']) + def parse_link_header(response) + LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 8f252e64c..26deb5ecc 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -36,15 +36,24 @@ class FetchLinkCardService < BaseService def process_url @card ||= PreviewCard.new(url: @url) - res = Request.new(:head, @url).perform - return if res.code != 405 && (res.code != 200 || res.mime_type != 'text/html') + failed = Request.new(:head, @url).perform do |res| + res.code != 405 && (res.code != 200 || res.mime_type != 'text/html') + end - @response = Request.new(:get, @url).perform + return if failed - return if @response.code != 200 || @response.mime_type != 'text/html' + Request.new(:get, @url).perform do |res| + if res.code == 200 && res.mime_type == 'text/html' + @html = res.to_s + @html_charset = res.charset + else + @html = nil + @html_charset = nil + end + end - @html = @response.to_s + return if @html.nil? attempt_oembed || attempt_opengraph end @@ -118,7 +127,7 @@ class FetchLinkCardService < BaseService detector = CharlockHolmes::EncodingDetector.new detector.strip_tags = true - guess = detector.detect(@html, @response.charset) + guess = detector.detect(@html, @html_charset) page = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil)) if meta_property(page, 'twitter:player') diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index fd6d30605..034821dc0 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -179,11 +179,10 @@ class ResolveAccountService < BaseService def atom_body return @atom_body if defined?(@atom_body) - response = Request.new(:get, atom_url).perform - - raise Mastodon::UnexpectedResponseError, response unless response.code == 200 - - @atom_body = response.to_s + @atom_body = Request.new(:get, atom_url).perform do |response| + raise Mastodon::UnexpectedResponseError, response unless response.code == 200 + response.to_s + end end def actor_json diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb index fabba8a3e..3419043e5 100644 --- a/app/services/send_interaction_service.rb +++ b/app/services/send_interaction_service.rb @@ -12,11 +12,9 @@ class SendInteractionService < BaseService return if !target_account.ostatus? || block_notification? - delivery = build_request.perform - - raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300 - - delivery.connection&.close + build_request.perform do |delivery| + raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300 + end end private diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index 2f725e2ec..2893b5410 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -6,21 +6,21 @@ class SubscribeService < BaseService @account = account @account.secret = SecureRandom.hex - @response = build_request.perform - if response_failed_permanently? - # We're not allowed to subscribe. Fail and move on. - @account.secret = '' - @account.save! - elsif response_successful? - # The subscription will be confirmed asynchronously. - @account.save! - else - # The response was either a 429 rate limit, or a 5xx error. - # We need to retry at a later time. Fail loudly! - raise Mastodon::UnexpectedResponseError, @response + build_request.perform do |response| + if response_failed_permanently? response + # We're not allowed to subscribe. Fail and move on. + @account.secret = '' + @account.save! + elsif response_successful? response + # The subscription will be confirmed asynchronously. + @account.save! + else + # The response was either a 429 rate limit, or a 5xx error. + # We need to retry at a later time. Fail loudly! + raise Mastodon::UnexpectedResponseError, response + end end - @response.connection&.close end private @@ -47,12 +47,12 @@ class SubscribeService < BaseService end # Any response in the 3xx or 4xx range, except for 429 (rate limit) - def response_failed_permanently? - (@response.status.redirect? || @response.status.client_error?) && !@response.status.too_many_requests? + def response_failed_permanently?(response) + (response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests? end # Any response in the 2xx range - def response_successful? - @response.status.success? + def response_successful?(response) + response.status.success? end end diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index 01f5c6b7a..95c1fb4fc 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -7,10 +7,9 @@ class UnsubscribeService < BaseService @account = account begin - @response = build_request.perform - - Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? - @response.connection&.close + build_request.perform do |response| + Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{response.status}" unless response.status.success? + end rescue HTTP::Error, OpenSSL::SSL::SSLError => e Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}" end diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index 4763856ac..e6cfd0d07 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -12,11 +12,10 @@ class ActivityPub::DeliveryWorker @source_account = Account.find(source_account_id) @inbox_url = inbox_url - perform_request + perform_request do |response| + raise Mastodon::UnexpectedResponseError, response unless response_successful? response + end - raise Mastodon::UnexpectedResponseError, @response unless response_successful? - - @response.connection&.close failure_tracker.track_success! rescue => e failure_tracker.track_failure! @@ -31,12 +30,12 @@ class ActivityPub::DeliveryWorker request.add_headers(HEADERS) end - def perform_request - @response = build_request.perform + def perform_request(&block) + build_request.perform(&block) end - def response_successful? - @response.code > 199 && @response.code < 300 + def response_successful?(response) + response.code > 199 && response.code < 300 end def failure_tracker diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index e1ccfb99c..cc2d1225b 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -21,8 +21,8 @@ class Pubsubhubbub::ConfirmationWorker def process_confirmation prepare_subscription - confirm_callback - logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{callback_response_body}" + callback_get_with_params + logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{@callback_response_body}" update_subscription end @@ -44,7 +44,7 @@ class Pubsubhubbub::ConfirmationWorker end def response_matches_challenge? - callback_response_body == challenge + @callback_response_body == challenge end def subscribing? @@ -55,16 +55,10 @@ class Pubsubhubbub::ConfirmationWorker mode == 'unsubscribe' end - def confirm_callback - @_confirm_callback ||= callback_get_with_params - end - def callback_get_with_params - Request.new(:get, subscription.callback_url, params: callback_params).perform - end - - def callback_response_body - confirm_callback.body.to_s + Request.new(:get, subscription.callback_url, params: callback_params).perform do |response| + @callback_response_body = response.body.to_s + end end def callback_params diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index a9174edd2..619bfa48a 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -23,22 +23,17 @@ class Pubsubhubbub::DeliveryWorker private def process_delivery - payload_delivery + callback_post_payload do |payload_delivery| + raise Mastodon::UnexpectedResponseError, payload_delivery unless response_successful? payload_delivery + end - raise Mastodon::UnexpectedResponseError, payload_delivery unless response_successful? - - payload_delivery.connection&.close subscription.touch(:last_successful_delivery_at) end - def payload_delivery - @_payload_delivery ||= callback_post_payload - end - - def callback_post_payload + def callback_post_payload(&block) request = Request.new(:post, subscription.callback_url, body: payload) request.add_headers(headers) - request.perform + request.perform(&block) end def blocked_domain? @@ -80,7 +75,7 @@ class Pubsubhubbub::DeliveryWorker OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret, payload) end - def response_successful? + def response_successful?(payload_delivery) payload_delivery.code > 199 && payload_delivery.code < 300 end end diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index cf32b1495..0972e4367 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -777,7 +777,7 @@ namespace :mastodon do progress_bar.increment begin - res = Request.new(:head, account.uri).perform + code = Request.new(:head, account.uri).perform(&:code) rescue StandardError # This could happen due to network timeout, DNS timeout, wrong SSL cert, etc, # which should probably not lead to perceiving the account as deleted, so @@ -785,7 +785,7 @@ namespace :mastodon do next end - if [404, 410].include?(res.code) + if [404, 410].include?(code) if options[:force] SuspendAccountService.new.call(account) account.destroy diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index 5da357c55..4d6b20dd5 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -39,12 +39,10 @@ describe Request do describe '#perform' do context 'with valid host' do - before do - stub_request(:get, 'http://example.com') - subject.perform - end + before { stub_request(:get, 'http://example.com') } it 'executes a HTTP request' do + expect { |block| subject.perform &block }.to yield_control expect(a_request(:get, 'http://example.com')).to have_been_made.once end @@ -52,12 +50,20 @@ describe Request do allow(Addrinfo).to receive(:foreach).with('example.com', nil, nil, :SOCK_STREAM) .and_yield(Addrinfo.new(["AF_INET", 0, "example.com", "0.0.0.0"], :PF_INET, :SOCK_STREAM)) .and_yield(Addrinfo.new(["AF_INET6", 0, "example.com", "2001:4860:4860::8844"], :PF_INET6, :SOCK_STREAM)) + + expect { |block| subject.perform &block }.to yield_control expect(a_request(:get, 'http://example.com')).to have_been_made.once end it 'sets headers' do + expect { |block| subject.perform &block }.to yield_control expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made end + + it 'closes underlaying connection' do + expect_any_instance_of(HTTP::Client).to receive(:close) + expect { |block| subject.perform &block }.to yield_control + end end context 'with private host' do From 580835ab698fb116adf26fe4c9c465b2218d124b Mon Sep 17 00:00:00 2001 From: Jeroen Date: Sat, 24 Mar 2018 12:50:14 +0100 Subject: [PATCH 04/20] Invites: Add '1 week' as expire option (#6872) * Invites: Add '1 week' as expire option IMO a max. of 1 day is too short. Not everyone has the time and motivation to use an invite in a 24 hour period. 1 week as a max. is I think a good compromise between convenience and security. * Invites: Add '1 week' as expire option IMO a max. of 1 day is too short. Not everyone has the time and motivation to use an invite in a 24 hour period. 1 week as a max. is I think a good compromise between convenience and security. * Update en.yml --- app/views/invites/_form.html.haml | 2 +- config/locales/en.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index a01cf5946..3f0871f47 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -3,7 +3,7 @@ .fields-group = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') .actions = f.button :button, t('invites.generate'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 735a3490f..995cbdaa0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -474,6 +474,7 @@ en: '21600': 6 hours '3600': 1 hour '43200': 12 hours + '604800': 1 week '86400': 1 day expires_in_prompt: Never generate: Generate From fa310695fa0b5fe76739232dd6acee81da6cd401 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 20:50:41 +0900 Subject: [PATCH 05/20] Note if the user is already following the target when authorizing follow (#6325) --- .../authorize_follows/_post_follow_actions.html.haml | 4 ++++ app/views/authorize_follows/show.html.haml | 8 +++++++- app/views/authorize_follows/success.html.haml | 5 +---- config/locales/en.yml | 1 + config/locales/ja.yml | 1 + 5 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 app/views/authorize_follows/_post_follow_actions.html.haml diff --git a/app/views/authorize_follows/_post_follow_actions.html.haml b/app/views/authorize_follows/_post_follow_actions.html.haml new file mode 100644 index 000000000..2a9c062e9 --- /dev/null +++ b/app/views/authorize_follows/_post_follow_actions.html.haml @@ -0,0 +1,4 @@ +.post-follow-actions + %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block' + %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block' + %div= t('authorize_follow.post_follow.close') diff --git a/app/views/authorize_follows/show.html.haml b/app/views/authorize_follows/show.html.haml index f7a8f72d2..a1fd01dd6 100644 --- a/app/views/authorize_follows/show.html.haml +++ b/app/views/authorize_follows/show.html.haml @@ -5,7 +5,13 @@ .follow-prompt = render 'card', account: @account - - unless current_account.following?(@account) + - if current_account.following?(@account) + .flash-message + %strong + = t('authorize_follow.already_following') + = render 'post_follow_actions' + + - else = form_tag authorize_follow_path, method: :post, class: 'simple_form' do = hidden_field_tag :acct, @account.acct = button_tag t('authorize_follow.follow'), type: :submit diff --git a/app/views/authorize_follows/success.html.haml b/app/views/authorize_follows/success.html.haml index 63ff3bcf1..fa59b24b8 100644 --- a/app/views/authorize_follows/success.html.haml +++ b/app/views/authorize_follows/success.html.haml @@ -10,7 +10,4 @@ = render 'card', account: @account - .post-follow-actions - %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block' - %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block' - %div= t('authorize_follow.post_follow.close') + = render 'post_follow_actions' diff --git a/config/locales/en.yml b/config/locales/en.yml index 995cbdaa0..e3d76971b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -382,6 +382,7 @@ en: security: Security set_new_password: Set new password authorize_follow: + already_following: You are already following this account error: Unfortunately, there was an error looking up the remote account follow: Follow follow_request: 'You have sent a follow request to:' diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 3b1990214..1ff309782 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -382,6 +382,7 @@ ja: security: セキュリティ set_new_password: 新しいパスワード authorize_follow: + already_following: あなたは既にこのアカウントをフォローしています error: 残念ながら、リモートアカウント情報の取得中にエラーが発生しました follow: フォロー follow_request: 'あなたは以下のアカウントにフォローリクエストを送信しました:' From b2a4ffd3a91abc5030baf2ede97c0867924d8fbc Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 20:51:28 +0900 Subject: [PATCH 06/20] Change columns in notifications nonnullable (#6764) --- app/models/notification.rb | 8 ++++---- ...0000_change_columns_in_notifications_nonnullable.rb | 8 ++++++++ db/schema.rb | 10 +++++----- spec/fabricators/notification_fabricator.rb | 4 ++-- spec/models/notification_spec.rb | 10 ++++------ 5 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 db/migrate/20180310000000_change_columns_in_notifications_nonnullable.rb diff --git a/app/models/notification.rb b/app/models/notification.rb index 7f8dae5ec..be9964087 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,12 +4,12 @@ # Table name: notifications # # id :integer not null, primary key -# activity_id :integer -# activity_type :string +# activity_id :integer not null +# activity_type :string not null # created_at :datetime not null # updated_at :datetime not null -# account_id :integer -# from_account_id :integer +# account_id :integer not null +# from_account_id :integer not null # class Notification < ApplicationRecord diff --git a/db/migrate/20180310000000_change_columns_in_notifications_nonnullable.rb b/db/migrate/20180310000000_change_columns_in_notifications_nonnullable.rb new file mode 100644 index 000000000..05ffd0501 --- /dev/null +++ b/db/migrate/20180310000000_change_columns_in_notifications_nonnullable.rb @@ -0,0 +1,8 @@ +class ChangeColumnsInNotificationsNonnullable < ActiveRecord::Migration[5.1] + def change + change_column_null :notifications, :activity_id, false + change_column_null :notifications, :activity_type, false + change_column_null :notifications, :account_id, false + change_column_null :notifications, :from_account_id, false + end +end diff --git a/db/schema.rb b/db/schema.rb index c52a6f0d4..18c61dbe0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180304013859) do +ActiveRecord::Schema.define(version: 20180310000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -274,12 +274,12 @@ ActiveRecord::Schema.define(version: 20180304013859) do end create_table "notifications", force: :cascade do |t| - t.bigint "activity_id" - t.string "activity_type" + t.bigint "activity_id", null: false + t.string "activity_type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "account_id" - t.bigint "from_account_id" + t.bigint "account_id", null: false + t.bigint "from_account_id", null: false t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true t.index ["account_id", "id"], name: "index_notifications_on_account_id_and_id", order: { id: :desc } t.index ["activity_id", "activity_type"], name: "index_notifications_on_activity_id_and_activity_type" diff --git a/spec/fabricators/notification_fabricator.rb b/spec/fabricators/notification_fabricator.rb index b92af0683..638844e0f 100644 --- a/spec/fabricators/notification_fabricator.rb +++ b/spec/fabricators/notification_fabricator.rb @@ -1,4 +1,4 @@ Fabricator(:notification) do - activity_id 1 - activity_type 'Favourite' + activity fabricator: [:mention, :status, :follow, :follow_request, :favourite].sample + account end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 8444c8f63..c781f2a29 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -6,14 +6,13 @@ RSpec.describe Notification, type: :model do end describe '#target_status' do - let(:notification) { Fabricate(:notification, activity_type: type, activity: activity) } + let(:notification) { Fabricate(:notification, activity: activity) } let(:status) { Fabricate(:status) } let(:reblog) { Fabricate(:status, reblog: status) } let(:favourite) { Fabricate(:favourite, status: status) } let(:mention) { Fabricate(:mention, status: status) } - context 'type is :reblog' do - let(:type) { :reblog } + context 'activity is reblog' do let(:activity) { reblog } it 'returns status' do @@ -21,7 +20,7 @@ RSpec.describe Notification, type: :model do end end - context 'type is :favourite' do + context 'activity is favourite' do let(:type) { :favourite } let(:activity) { favourite } @@ -30,8 +29,7 @@ RSpec.describe Notification, type: :model do end end - context 'type is :mention' do - let(:type) { :mention } + context 'activity is mention' do let(:activity) { mention } it 'returns status' do From 1c15329cce07adeeb9e2abf670b3eb37e8d36e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Miko=C5=82ajczak?= Date: Sat, 24 Mar 2018 12:51:51 +0100 Subject: [PATCH 07/20] =?UTF-8?q?Change=20=E2=80=9CToots=20with=20replies?= =?UTF-8?q?=E2=80=9D=20to=20=E2=80=9CToots=20and=20replies=E2=80=9D=20(#68?= =?UTF-8?q?75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- .../mastodon/features/account_timeline/components/header.js | 2 +- app/javascript/mastodon/locales/defaultMessages.json | 4 ++-- app/javascript/mastodon/locales/en.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 9d594fb0c..6b88a7a0c 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -99,7 +99,7 @@ export default class Header extends ImmutablePureComponent { {!hideTabs && (
- +
)} diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index b983823d4..eee60c57f 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -326,7 +326,7 @@ "id": "account.posts" }, { - "defaultMessage": "Toots with replies", + "defaultMessage": "Toots and replies", "id": "account.posts_with_replies" }, { @@ -1748,4 +1748,4 @@ ], "path": "app/javascript/mastodon/middleware/errors.json" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 5553772f4..de44bd0db 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -17,7 +17,7 @@ "account.mute_notifications": "Mute notifications from @{name}", "account.muted": "Muted", "account.posts": "Toots", - "account.posts_with_replies": "Toots with replies", + "account.posts_with_replies": "Toots and replies", "account.report": "Report @{name}", "account.requested": "Awaiting approval. Click to cancel follow request", "account.share": "Share @{name}'s profile", From ff7941e652af1d54d9c991254556e7932a8b183c Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 20:52:26 +0900 Subject: [PATCH 08/20] Show media modal on public pages (#6801) --- .../mastodon/components/media_gallery.js | 6 +- .../mastodon/components/modal_root.js | 84 +++++++++++++++++++ .../containers/media_galleries_container.js | 68 +++++++++++++++ .../containers/media_gallery_container.js | 34 -------- .../features/ui/components/modal_root.js | 77 ++--------------- app/javascript/packs/public.js | 16 ++-- .../styles/mastodon/components.scss | 5 +- .../styles/mastodon/containers.scss | 4 + 8 files changed, 178 insertions(+), 116 deletions(-) create mode 100644 app/javascript/mastodon/components/modal_root.js create mode 100644 app/javascript/mastodon/containers/media_galleries_container.js delete mode 100644 app/javascript/mastodon/containers/media_gallery_container.js diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 1cef029d8..13e1fcc52 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -14,10 +14,6 @@ const messages = defineMessages({ class Item extends React.PureComponent { - static contextTypes = { - router: PropTypes.object, - }; - static propTypes = { attachment: ImmutablePropTypes.map.isRequired, standalone: PropTypes.bool, @@ -53,7 +49,7 @@ class Item extends React.PureComponent { handleClick = (e) => { const { index, onClick } = this.props; - if (this.context.router && e.button === 0) { + if (e.button === 0) { e.preventDefault(); onClick(index); } diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js new file mode 100644 index 000000000..114f74937 --- /dev/null +++ b/app/javascript/mastodon/components/modal_root.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class ModalRoot extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + onClose: PropTypes.func.isRequired, + }; + + state = { + revealed: !!this.props.children, + }; + + activeElement = this.state.revealed ? document.activeElement : null; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.children) { + this.props.onClose(); + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillReceiveProps (nextProps) { + if (!!nextProps.children && !this.props.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.children) { + this.setState({ revealed: false }); + } + } + + componentDidUpdate (prevProps) { + if (!this.props.children && !!prevProps.children) { + this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + this.activeElement.focus(); + this.activeElement = null; + } + if (this.props.children) { + 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; + } + + render () { + const { children, onClose } = this.props; + const { revealed } = this.state; + const visible = !!children; + + if (!visible) { + return ( +
+ ); + } + + return ( +
+
+
+
{children}
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/containers/media_galleries_container.js b/app/javascript/mastodon/containers/media_galleries_container.js new file mode 100644 index 000000000..d77bd688b --- /dev/null +++ b/app/javascript/mastodon/containers/media_galleries_container.js @@ -0,0 +1,68 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import MediaGallery from '../components/media_gallery'; +import ModalRoot from '../components/modal_root'; +import MediaModal from '../features/ui/components/media_modal'; +import { fromJS } from 'immutable'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class MediaGalleriesContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + galleries: PropTypes.object.isRequired, + }; + + state = { + media: null, + index: null, + }; + + handleOpenMedia = (media, index) => { + document.body.classList.add('media-gallery-standalone__body'); + this.setState({ media, index }); + } + + handleCloseMedia = () => { + document.body.classList.remove('media-gallery-standalone__body'); + this.setState({ media: null, index: null }); + } + + render () { + const { locale, galleries } = this.props; + + return ( + + + {[].map.call(galleries, gallery => { + const { media, ...props } = JSON.parse(gallery.getAttribute('data-props')); + + return ReactDOM.createPortal( + , + gallery + ); + })} + + {this.state.media === null || this.state.index === null ? null : ( + + )} + + + + ); + } + +} diff --git a/app/javascript/mastodon/containers/media_gallery_container.js b/app/javascript/mastodon/containers/media_gallery_container.js deleted file mode 100644 index 812c3d4e5..000000000 --- a/app/javascript/mastodon/containers/media_gallery_container.js +++ /dev/null @@ -1,34 +0,0 @@ -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/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 20bf21153..4185cba32 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Base from '../../../components/modal_root'; import BundleContainer from '../containers/bundle_container'; import BundleModalError from './bundle_modal_error'; import ModalLoading from './modal_loading'; @@ -39,56 +40,6 @@ export default class ModalRoot extends React.PureComponent { 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; } @@ -101,28 +52,16 @@ export default class ModalRoot extends React.PureComponent { render () { const { type, props, onClose } = this.props; - const { revealed } = this.state; const visible = !!type; - if (!visible) { - return ( -
- ); - } - return ( -
-
-
-
- {visible && ( - - {(SpecificComponent) => } - - )} -
-
-
+ + {visible && ( + + {(SpecificComponent) => } + + )} + ); } diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index a47fc2830..7096b9b4f 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -25,7 +25,6 @@ function main() { 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 React = require('react'); const ReactDOM = require('react-dom'); @@ -76,15 +75,20 @@ function main() { ReactDOM.render(, content); }); - [].forEach.call(document.querySelectorAll('[data-component="MediaGallery"]'), (content) => { - const props = JSON.parse(content.getAttribute('data-props')); - ReactDOM.render(, content); - }); - [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => { const props = JSON.parse(content.getAttribute('data-props')); ReactDOM.render(, content); }); + + const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]'); + + if (mediaGalleries.length > 0) { + const MediaGalleriesContainer = require('../mastodon/containers/media_galleries_container').default; + const content = document.createElement('div'); + + ReactDOM.render(, content); + document.body.appendChild(content); + } }); delegate(document, '.webapp-btn', 'click', ({ target, button }) => { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 20e07a042..ea6e39392 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3375,13 +3375,14 @@ a.status-card { } .modal-root { + position: relative; transition: opacity 0.3s linear; will-change: opacity; z-index: 9999; } .modal-root__overlay { - position: absolute; + position: fixed; top: 0; left: 0; right: 0; @@ -3390,7 +3391,7 @@ a.status-card { } .modal-root__container { - position: absolute; + position: fixed; top: 0; left: 0; width: 100%; diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 6fa1fa38f..e761f58eb 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -60,6 +60,10 @@ } } +.media-gallery-standalone__body { + overflow: hidden; +} + .account-header { width: 400px; margin: 0 auto; From 28384c1771ccaa600e429f41cb2e19234961a9bd Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sat, 24 Mar 2018 20:52:45 +0900 Subject: [PATCH 09/20] Revert "Revert "Upgrade Paperclip to version 6.0.0" (#6807)" (#6808) This reverts commit 40871caa4b06c7ee1c3b07f439ed984ead295ced. --- Gemfile | 4 ++-- Gemfile.lock | 29 ++++++++++++++++++----------- config/initializers/paperclip.rb | 3 +-- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Gemfile b/Gemfile index 8bc28b893..29fa9cde7 100644 --- a/Gemfile +++ b/Gemfile @@ -13,11 +13,11 @@ gem 'pg', '~> 0.20' gem 'pghero', '~> 1.7' gem 'dotenv-rails', '~> 2.2' -gem 'aws-sdk', '~> 2.10', require: false +gem 'aws-sdk-s3', '~> 1.8', require: false gem 'fog-core', '~> 1.45' gem 'fog-local', '~> 0.4', require: false gem 'fog-openstack', '~> 0.1', require: false -gem 'paperclip', '~> 5.1' +gem 'paperclip', '~> 6.0' gem 'paperclip-av-transcoder', '~> 0.6' gem 'streamio-ffmpeg', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 7360ce7f6..f68419d8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -57,13 +57,18 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.10.100) - aws-sdk-resources (= 2.10.100) - aws-sdk-core (2.10.100) + aws-partitions (1.70.0) + aws-sdk-core (3.17.0) + aws-partitions (~> 1.0) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.10.100) - aws-sdk-core (= 2.10.100) + aws-sdk-kms (1.5.0) + aws-sdk-core (~> 3) + aws-sigv4 (~> 1.0) + aws-sdk-s3 (1.8.2) + aws-sdk-core (~> 3) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.0) aws-sigv4 (1.0.2) bcrypt (3.1.11) better_errors (2.4.0) @@ -236,7 +241,7 @@ GEM httplog (0.99.7) colorize rack - i18n (0.9.3) + i18n (0.9.5) concurrent-ruby (~> 1.0) i18n-tasks (0.9.19) activesupport (>= 4.0.2) @@ -342,12 +347,12 @@ GEM http (~> 3.0) nokogiri (~> 1.8) ox (2.8.2) - paperclip (5.2.1) + paperclip (6.0.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) - cocaine (~> 0.5.5) mime-types mimemagic (~> 0.3.0) + terrapin (~> 0.6.0) paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) @@ -552,6 +557,8 @@ GEM temple (0.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) + terrapin (0.6.0) + climate_control (>= 0.0.3, < 1.0) thor (0.20.0) thread (0.2.2) thread_safe (0.3.6) @@ -575,7 +582,7 @@ GEM tty-screen (0.6.4) twitter-text (1.14.7) unf (~> 0.1.0) - tzinfo (1.2.4) + tzinfo (1.2.5) thread_safe (~> 0.1) tzinfo-data (1.2017.3) tzinfo (>= 1.0.0) @@ -612,7 +619,7 @@ DEPENDENCIES active_record_query_trace (~> 1.5) addressable (~> 2.5) annotate (~> 2.7) - aws-sdk (~> 2.10) + aws-sdk-s3 (~> 1.8) better_errors (~> 2.4) binding_of_caller (~> 0.7) bootsnap @@ -671,7 +678,7 @@ DEPENDENCIES omniauth-saml (~> 1.10) ostatus2 (~> 2.0) ox (~> 2.8) - paperclip (~> 5.1) + paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) parallel_tests (~> 2.17) pg (~> 0.20) diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 8aa1d1b6e..17a520aa2 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -14,8 +14,7 @@ Paperclip::Attachment.default_options.merge!( ) if ENV['S3_ENABLED'] == 'true' - require 'aws-sdk' - Aws.eager_autoload!(services: %w(S3)) + require 'aws-sdk-s3' s3_region = ENV.fetch('S3_REGION') { 'us-east-1' } s3_protocol = ENV.fetch('S3_PROTOCOL') { 'https' } From fe398a098e9990ee3146e70be9e2cda6227274b8 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 21:06:27 +0900 Subject: [PATCH 10/20] Store objects to IndexedDB (#6826) --- app/javascript/mastodon/actions/accounts.js | 42 +++++- app/javascript/mastodon/actions/blocks.js | 3 + app/javascript/mastodon/actions/compose.js | 2 + app/javascript/mastodon/actions/favourites.js | 3 + .../mastodon/actions/importer/index.js | 76 +++++++++++ .../mastodon/actions/importer/normalizer.js | 46 +++++++ .../mastodon/actions/interactions.js | 39 +++--- app/javascript/mastodon/actions/lists.js | 14 +- app/javascript/mastodon/actions/mutes.js | 3 + .../mastodon/actions/notifications.js | 22 ++- .../mastodon/actions/pin_statuses.js | 2 + app/javascript/mastodon/actions/search.js | 11 +- app/javascript/mastodon/actions/statuses.js | 65 ++++++++- app/javascript/mastodon/actions/store.js | 2 + app/javascript/mastodon/actions/timelines.js | 5 + app/javascript/mastodon/db/async.js | 28 ++++ app/javascript/mastodon/db/modifier.js | 93 +++++++++++++ app/javascript/mastodon/reducers/accounts.js | 125 +----------------- .../mastodon/reducers/accounts_counters.js | 112 +--------------- app/javascript/mastodon/reducers/statuses.js | 95 ++----------- 20 files changed, 433 insertions(+), 355 deletions(-) create mode 100644 app/javascript/mastodon/actions/importer/index.js create mode 100644 app/javascript/mastodon/actions/importer/normalizer.js create mode 100644 app/javascript/mastodon/db/async.js create mode 100644 app/javascript/mastodon/db/modifier.js diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index f63325658..1d1947aca 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,4 +1,6 @@ import api, { getLinks } from '../api'; +import asyncDB from '../db/async'; +import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; @@ -64,6 +66,24 @@ 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'; +function getFromDB(dispatch, getState, index, id) { + return new Promise((resolve, reject) => { + const request = index.get(id); + + request.onerror = reject; + + request.onsuccess = () => { + if (!request.result) { + reject(); + return; + } + + dispatch(importAccount(request.result)); + resolve(request.result.moved && getFromDB(dispatch, getState, index, request.result.moved)); + }; + }); +} + export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchRelationships([id])); @@ -74,9 +94,16 @@ export function fetchAccount(id) { dispatch(fetchAccountRequest(id)); - api(getState).get(`/api/v1/accounts/${id}`).then(response => { - dispatch(fetchAccountSuccess(response.data)); - }).catch(error => { + asyncDB.then(db => getFromDB( + dispatch, + getState, + db.transaction('accounts', 'read').objectStore('accounts').index('id'), + id + )).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => { + dispatch(importFetchedAccount(response.data)); + })).then(() => { + dispatch(fetchAccountSuccess()); + }, error => { dispatch(fetchAccountFail(id, error)); }); }; @@ -89,10 +116,9 @@ export function fetchAccountRequest(id) { }; }; -export function fetchAccountSuccess(account) { +export function fetchAccountSuccess() { return { type: ACCOUNT_FETCH_SUCCESS, - account, }; }; @@ -319,6 +345,7 @@ export function fetchFollowers(id) { api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -364,6 +391,7 @@ export function expandFollowers(id) { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -403,6 +431,7 @@ export function fetchFollowing(id) { api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -448,6 +477,7 @@ export function expandFollowing(id) { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => { @@ -529,6 +559,7 @@ export function fetchFollowRequests() { api(getState).get('/api/v1/follow_requests').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); }).catch(error => dispatch(fetchFollowRequestsFail(error))); }; @@ -567,6 +598,7 @@ export function expandFollowRequests() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); }).catch(error => dispatch(expandFollowRequestsFail(error))); }; diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js index 553283a71..7000f5a71 100644 --- a/app/javascript/mastodon/actions/blocks.js +++ b/app/javascript/mastodon/actions/blocks.js @@ -1,5 +1,6 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; @@ -15,6 +16,7 @@ export function fetchBlocks() { api(getState).get('/api/v1/blocks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(fetchBlocksFail(error))); @@ -54,6 +56,7 @@ export function expandBlocks() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(expandBlocksFail(error))); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1371f22b2..8e13209b8 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -4,6 +4,7 @@ import { throttle } from 'lodash'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; +import { importFetchedAccounts } from './importer'; import { updateTimeline, @@ -282,6 +283,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => limit: 4, }, }).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(readyComposeSuggestionsAccounts(token, response.data)); }); }, 200, { leading: true, trailing: true }); diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 93094c526..124cf8c44 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,4 +1,5 @@ import api, { getLinks } from '../api'; +import { importFetchedStatuses } from './importer'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -18,6 +19,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -58,6 +60,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js new file mode 100644 index 000000000..d1ea40c36 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/index.js @@ -0,0 +1,76 @@ +import { putAccounts, putStatuses } from '../../db/modifier'; +import { normalizeAccount, normalizeStatus } from './normalizer'; + +export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; +export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; +export const STATUS_IMPORT = 'STATUS_IMPORT'; +export const STATUSES_IMPORT = 'STATUSES_IMPORT'; + +function pushUnique(array, object) { + if (array.every(element => element.id !== object.id)) { + array.push(object); + } +} + +export function importAccount(account) { + return { type: ACCOUNT_IMPORT, account }; +} + +export function importAccounts(accounts) { + return { type: ACCOUNTS_IMPORT, accounts }; +} + +export function importStatus(status) { + return { type: STATUS_IMPORT, status }; +} + +export function importStatuses(statuses) { + return { type: STATUSES_IMPORT, statuses }; +} + +export function importFetchedAccount(account) { + return importFetchedAccounts([account]); +} + +export function importFetchedAccounts(accounts) { + const normalAccounts = []; + + function processAccount(account) { + pushUnique(normalAccounts, normalizeAccount(account)); + + if (account.moved) { + processAccount(account); + } + } + + accounts.forEach(processAccount); + putAccounts(normalAccounts); + + return importAccounts(normalAccounts); +} + +export function importFetchedStatus(status) { + return importFetchedStatuses([status]); +} + +export function importFetchedStatuses(statuses) { + return (dispatch, getState) => { + const accounts = []; + const normalStatuses = []; + + function processStatus(status) { + pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); + pushUnique(accounts, status.account); + + if (status.reblog && status.reblog.id) { + processStatus(status.reblog); + } + } + + statuses.forEach(processStatus); + putStatuses(normalStatuses); + + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); + }; +} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js new file mode 100644 index 000000000..c88f6946f --- /dev/null +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -0,0 +1,46 @@ +import escapeTextContentForBrowser from 'escape-html'; +import emojify from '../../features/emoji/emoji'; + +const domParser = new DOMParser(); + +export function normalizeAccount(account) { + account = { ...account }; + + 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 account; +} + +export function normalizeStatus(status, normalOldStatus) { + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + normalStatus.reblog = status.reblog.id; + } + + // Only calculate these values when status first encountered + // Otherwise keep the ones already in the reducer + if (normalOldStatus) { + normalStatus.search_index = normalOldStatus.get('search_index'); + normalStatus.contentHtml = normalOldStatus.get('contentHtml'); + normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); + normalStatus.hidden = normalOldStatus.get('hidden'); + } else { + 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); + normalStatus.hidden = normalStatus.sensitive; + } + + return normalStatus; +} diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 10e66910a..2dc4c574c 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -1,4 +1,5 @@ import api from '../api'; +import { importFetchedAccounts, importFetchedStatus } from './importer'; export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -39,7 +40,8 @@ export function reblog(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)); + dispatch(importFetchedStatus(response.data.reblog)); + dispatch(reblogSuccess(status)); }).catch(function (error) { dispatch(reblogFail(status, error)); }); @@ -51,7 +53,8 @@ export function unreblog(status) { dispatch(unreblogRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { - dispatch(unreblogSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unreblogSuccess(status)); }).catch(error => { dispatch(unreblogFail(status, error)); }); @@ -66,11 +69,10 @@ export function reblogRequest(status) { }; }; -export function reblogSuccess(status, response) { +export function reblogSuccess(status) { return { type: REBLOG_SUCCESS, status: status, - response: response, skipLoading: true, }; }; @@ -92,11 +94,10 @@ export function unreblogRequest(status) { }; }; -export function unreblogSuccess(status, response) { +export function unreblogSuccess(status) { return { type: UNREBLOG_SUCCESS, status: status, - response: response, skipLoading: true, }; }; @@ -115,7 +116,8 @@ export function favourite(status) { dispatch(favouriteRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { - dispatch(favouriteSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(favouriteSuccess(status)); }).catch(function (error) { dispatch(favouriteFail(status, error)); }); @@ -127,7 +129,8 @@ export function unfavourite(status) { dispatch(unfavouriteRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { - dispatch(unfavouriteSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unfavouriteSuccess(status)); }).catch(error => { dispatch(unfavouriteFail(status, error)); }); @@ -142,11 +145,10 @@ export function favouriteRequest(status) { }; }; -export function favouriteSuccess(status, response) { +export function favouriteSuccess(status) { return { type: FAVOURITE_SUCCESS, status: status, - response: response, skipLoading: true, }; }; @@ -168,11 +170,10 @@ export function unfavouriteRequest(status) { }; }; -export function unfavouriteSuccess(status, response) { +export function unfavouriteSuccess(status) { return { type: UNFAVOURITE_SUCCESS, status: status, - response: response, skipLoading: true, }; }; @@ -191,6 +192,7 @@ export function fetchReblogs(id) { dispatch(fetchReblogsRequest(id)); api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(fetchReblogsSuccess(id, response.data)); }).catch(error => { dispatch(fetchReblogsFail(id, error)); @@ -225,6 +227,7 @@ export function fetchFavourites(id) { dispatch(fetchFavouritesRequest(id)); api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); dispatch(fetchFavouritesSuccess(id, response.data)); }).catch(error => { dispatch(fetchFavouritesFail(id, error)); @@ -259,7 +262,8 @@ export function pin(status) { dispatch(pinRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { - dispatch(pinSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(pinSuccess(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -274,11 +278,10 @@ export function pinRequest(status) { }; }; -export function pinSuccess(status, response) { +export function pinSuccess(status) { return { type: PIN_SUCCESS, status, - response, skipLoading: true, }; }; @@ -297,7 +300,8 @@ export function unpin (status) { dispatch(unpinRequest(status)); api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { - dispatch(unpinSuccess(status, response.data)); + dispatch(importFetchedStatus(response.data)); + dispatch(unpinSuccess(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); @@ -312,11 +316,10 @@ export function unpinRequest(status) { }; }; -export function unpinSuccess(status, response) { +export function unpinSuccess(status) { return { type: UNPIN_SUCCESS, status, - response, skipLoading: true, }; }; diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index 4c8f9b186..12d60e3a3 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -1,4 +1,5 @@ import api from '../api'; +import { importFetchedAccounts } from './importer'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; @@ -200,9 +201,10 @@ export const deleteListFail = (id, error) => ({ export const fetchListAccounts = listId => (dispatch, getState) => { dispatch(fetchListAccountsRequest(listId)); - api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }) - .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data))) - .catch(err => dispatch(fetchListAccountsFail(listId, err))); + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListAccountsSuccess(listId, data)); + }).catch(err => dispatch(fetchListAccountsFail(listId, err))); }; export const fetchListAccountsRequest = id => ({ @@ -231,8 +233,10 @@ export const fetchListSuggestions = q => (dispatch, getState) => { following: true, }; - api(getState).get('/api/v1/accounts/search', { params }) - .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))); + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchListSuggestionsReady(q, data)); + }); }; export const fetchListSuggestionsReady = (query, accounts) => ({ diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index daa76a8f7..9f645faee 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -1,5 +1,6 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; import { openModal } from './modal'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; @@ -19,6 +20,7 @@ export function fetchMutes() { api(getState).get('/api/v1/mutes').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(fetchMutesFail(error))); @@ -58,6 +60,7 @@ export function expandMutes() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); dispatch(fetchRelationships(response.data.map(item => item.id))); }).catch(error => dispatch(expandMutesFail(error))); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index cf9242d0f..a664cd978 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -2,6 +2,12 @@ import api, { getLinks } from '../api'; import { List as ImmutableList } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; +import { + importFetchedAccount, + importFetchedAccounts, + importFetchedStatus, + importFetchedStatuses, +} from './importer'; import { defineMessages } from 'react-intl'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -41,11 +47,12 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + dispatch(importFetchedAccount(notification.account)); + dispatch(importFetchedStatus(notification.status)); + dispatch({ type: NOTIFICATIONS_UPDATE, notification, - account: notification.account, - status: notification.status, meta: playSound ? { sound: 'boop' } : undefined, }); @@ -89,6 +96,9 @@ export function refreshNotifications() { api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); }).catch(error => { @@ -108,8 +118,6 @@ 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, }; @@ -141,6 +149,10 @@ export function expandNotifications() { api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); }).catch(error => { @@ -159,8 +171,6 @@ 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, }; }; diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js index 3f40f6c2d..77abba7b5 100644 --- a/app/javascript/mastodon/actions/pin_statuses.js +++ b/app/javascript/mastodon/actions/pin_statuses.js @@ -1,4 +1,5 @@ import api from '../api'; +import { importFetchedStatuses } from './importer'; export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; @@ -11,6 +12,7 @@ export function fetchPinnedStatuses() { dispatch(fetchPinnedStatusesRequest()); api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(importFetchedStatuses(response.data)); dispatch(fetchPinnedStatusesSuccess(response.data, null)); }).catch(error => { dispatch(fetchPinnedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 73cb106ec..882c1709e 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -1,5 +1,6 @@ import api from '../api'; import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CLEAR = 'SEARCH_CLEAR'; @@ -38,6 +39,14 @@ export function submitSearch() { resolve: true, }, }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + dispatch(fetchSearchSuccess(response.data)); dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { @@ -56,8 +65,6 @@ export function fetchSearchSuccess(results) { return { type: SEARCH_FETCH_SUCCESS, results, - accounts: results.accounts, - statuses: results.statuses, }; }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 073f09883..dcd813dd9 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,7 +1,10 @@ import api from '../api'; +import asyncDB from '../db/async'; +import { evictStatus } from '../db/modifier'; import { deleteFromTimelines } from './timelines'; import { fetchStatusCard } from './cards'; +import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -34,6 +37,48 @@ export function fetchStatusRequest(id, skipLoading) { }; }; +function getFromDB(dispatch, getState, accountIndex, index, id) { + return new Promise((resolve, reject) => { + const request = index.get(id); + + request.onerror = reject; + + request.onsuccess = () => { + const promises = []; + + if (!request.result) { + reject(); + return; + } + + dispatch(importStatus(request.result)); + + if (getState().getIn(['accounts', request.result.account], null) === null) { + promises.push(new Promise((accountResolve, accountReject) => { + const accountRequest = accountIndex.get(request.result.account); + + accountRequest.onerror = accountReject; + accountRequest.onsuccess = () => { + if (!request.result) { + accountReject(); + return; + } + + dispatch(importAccount(accountRequest.result)); + accountResolve(); + }; + })); + } + + if (request.result.reblog && getState().getIn(['statuses', request.result.reblog], null) === null) { + promises.push(getFromDB(dispatch, getState, accountIndex, index, request.result.reblog)); + } + + resolve(Promise.all(promises)); + }; + }); +} + export function fetchStatus(id) { return (dispatch, getState) => { const skipLoading = getState().getIn(['statuses', id], null) !== null; @@ -47,18 +92,26 @@ export function fetchStatus(id) { dispatch(fetchStatusRequest(id, skipLoading)); - api(getState).get(`/api/v1/statuses/${id}`).then(response => { - dispatch(fetchStatusSuccess(response.data, skipLoading)); - }).catch(error => { + asyncDB.then(db => { + const transaction = db.transaction(['accounts', 'statuses'], 'read'); + const accountIndex = transaction.objectStore('accounts').index('id'); + const index = transaction.objectStore('statuses').index('id'); + + return getFromDB(dispatch, getState, accountIndex, index, id); + }).then(() => { + dispatch(fetchStatusSuccess(skipLoading)); + }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(fetchStatusSuccess(skipLoading)); + })).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading)); }); }; }; -export function fetchStatusSuccess(status, skipLoading) { +export function fetchStatusSuccess(skipLoading) { return { type: STATUS_FETCH_SUCCESS, - status, skipLoading, }; }; @@ -78,6 +131,7 @@ export function deleteStatus(id) { dispatch(deleteStatusRequest(id)); api(getState).delete(`/api/v1/statuses/${id}`).then(() => { + evictStatus(id); dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); }).catch(error => { @@ -113,6 +167,7 @@ export function fetchContext(id) { dispatch(fetchContextRequest(id)); api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); }).catch(error => { diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index 2dd94a998..34dcafc51 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,5 +1,6 @@ import { Iterable, fromJS } from 'immutable'; import { hydrateCompose } from './compose'; +import { importFetchedAccounts } from './importer'; export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; @@ -18,5 +19,6 @@ export function hydrateStore(rawState) { }); dispatch(hydrateCompose()); + dispatch(importFetchedAccounts(Object.values(rawState.accounts))); }; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index f0ab16a2d..e5748b4e7 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,3 +1,4 @@ +import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from '../api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; @@ -44,6 +45,8 @@ export function updateTimeline(timeline, status) { } } + dispatch(importFetchedStatus(status)); + dispatch({ type: TIMELINE_UPDATE, timeline, @@ -109,6 +112,7 @@ export function refreshTimeline(timelineId, path, params = {}) { dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true)); } else { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false)); } }).catch(error => { @@ -152,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandTimelineFail(timelineId, error)); diff --git a/app/javascript/mastodon/db/async.js b/app/javascript/mastodon/db/async.js new file mode 100644 index 000000000..e08fc3f3d --- /dev/null +++ b/app/javascript/mastodon/db/async.js @@ -0,0 +1,28 @@ +import { me } from '../initial_state'; + +export default new Promise((resolve, reject) => { + // Microsoft Edge 17 does not support getAll according to: + // Catalog of standard and vendor APIs across browsers - Microsoft Edge Development + // https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb + if (!me || !('getAll' in IDBObjectStore.prototype)) { + reject(); + return; + } + + const request = indexedDB.open('mastodon:' + me); + + request.onerror = reject; + request.onsuccess = ({ target }) => resolve(target.result); + + request.onupgradeneeded = ({ target }) => { + const accounts = target.result.createObjectStore('accounts', { autoIncrement: true }); + const statuses = target.result.createObjectStore('statuses', { autoIncrement: true }); + + accounts.createIndex('id', 'id', { unique: true }); + accounts.createIndex('moved', 'moved'); + + statuses.createIndex('id', 'id', { unique: true }); + statuses.createIndex('account', 'account'); + statuses.createIndex('reblog', 'reblog'); + }; +}); diff --git a/app/javascript/mastodon/db/modifier.js b/app/javascript/mastodon/db/modifier.js new file mode 100644 index 000000000..eb951905a --- /dev/null +++ b/app/javascript/mastodon/db/modifier.js @@ -0,0 +1,93 @@ +import asyncDB from './async'; + +const limit = 1024; + +function put(name, objects, callback) { + asyncDB.then(db => { + const putTransaction = db.transaction(name, 'readwrite'); + const putStore = putTransaction.objectStore(name); + const putIndex = putStore.index('id'); + + objects.forEach(object => { + function add() { + putStore.add(object); + } + + putIndex.getKey(object.id).onsuccess = retrieval => { + if (retrieval.target.result) { + putStore.delete(retrieval.target.result).onsuccess = add; + } else { + add(); + } + }; + }); + + putTransaction.oncomplete = () => { + const readTransaction = db.transaction(name, 'readonly'); + const readStore = readTransaction.objectStore(name); + + readStore.count().onsuccess = count => { + const excess = count.target.result - limit; + + if (excess > 0) { + readStore.getAll(null, excess).onsuccess = + retrieval => callback(retrieval.target.result.map(({ id }) => id)); + } + }; + }; + }); +} + +export function evictAccounts(ids) { + asyncDB.then(db => { + const transaction = db.transaction(['accounts', 'statuses'], 'readwrite'); + const accounts = transaction.objectStore('accounts'); + const accountsIdIndex = accounts.index('id'); + const accountsMovedIndex = accounts.index('moved'); + const statuses = transaction.objectStore('statuses'); + const statusesIndex = statuses.index('account'); + + function evict(toEvict) { + toEvict.forEach(id => { + accountsMovedIndex.getAllKeys(id).onsuccess = + ({ target }) => evict(target.result); + + statusesIndex.getAll(id).onsuccess = + ({ target }) => evictStatuses(target.result.map(({ id }) => id)); + + accountsIdIndex.getKey(id).onsuccess = + ({ target }) => target.result && accounts.delete(target.result); + }); + } + + evict(ids); + }); +} + +export function evictStatus(id) { + return evictStatuses([id]); +} + +export function evictStatuses(ids) { + asyncDB.then(db => { + const store = db.transaction('statuses', 'readwrite').objectStore('statuses'); + const idIndex = store.index('id'); + const reblogIndex = store.index('reblog'); + + ids.forEach(id => { + reblogIndex.getAllKeys(id).onsuccess = + ({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey)); + + idIndex.getKey(id).onsuccess = + ({ target }) => target.result && store.delete(target.result); + }); + }); +} + +export function putAccounts(records) { + put('accounts', records, evictAccounts); +} + +export function putStatuses(records) { + put('statuses', records, evictStatuses); +} diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 47e6d2330..530ed8e60 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -1,56 +1,7 @@ -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 { - LIST_ACCOUNTS_FETCH_SUCCESS, - LIST_EDITOR_SUGGESTIONS_READY, -} from '../actions/lists'; -import { STORE_HYDRATE } from '../actions/store'; -import emojify from '../features/emoji/emoji'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; -import escapeTextContentForBrowser from 'escape-html'; + +const initialState = ImmutableMap(); const normalizeAccount = (state, account) => { account = { ...account }; @@ -59,15 +10,6 @@ const normalizeAccount = (state, account) => { 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); - - if (account.moved) { - state = normalizeAccount(state, account.moved); - account.moved = account.moved.id; - } - return state.set(account.id, fromJS(account)); }; @@ -79,67 +21,12 @@ const normalizeAccounts = (state, accounts) => { 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 normalizeAccounts(state, Object.values(action.state.get('accounts').toJS())); - case ACCOUNT_FETCH_SUCCESS: - case NOTIFICATIONS_UPDATE: + case ACCOUNT_IMPORT: 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: - case LIST_ACCOUNTS_FETCH_SUCCESS: - case LIST_EDITOR_SUGGESTIONS_READY: - 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 ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); default: return state; } diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index a93fa4245..9ebf72af9 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -1,55 +1,8 @@ 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 { - LIST_ACCOUNTS_FETCH_SUCCESS, - LIST_EDITOR_SUGGESTIONS_READY, -} from '../actions/lists'; -import { STORE_HYDRATE } from '../actions/store'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, fromJS({ @@ -66,71 +19,14 @@ const normalizeAccounts = (state, accounts) => { 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: + case ACCOUNT_IMPORT: 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: - case LIST_ACCOUNTS_FETCH_SUCCESS: - case LIST_EDITOR_SUGGESTIONS_READY: - 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 ACCOUNTS_IMPORT: + return normalizeAccounts(state, action.accounts); case ACCOUNT_FOLLOW_SUCCESS: return action.alreadyFollowing ? state : state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 7b3141623..fc4b4900e 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -1,87 +1,25 @@ 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, STATUS_REVEAL, STATUS_HIDE, } from '../actions/statuses'; import { - TIMELINE_REFRESH_SUCCESS, - TIMELINE_UPDATE, TIMELINE_DELETE, - TIMELINE_EXPAND_SUCCESS, } from '../actions/timelines'; -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 { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; -import escapeTextContentForBrowser from 'escape-html'; -const domParser = new DOMParser(); +const importStatus = (state, status) => state.set(status.id, fromJS(status)); -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; - } - - // Only calculate these values when status first encountered - // Otherwise keep the ones already in the reducer - if (!state.has(status.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); - normalStatus.hidden = normalStatus.sensitive; - } - - 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 importStatuses = (state, statuses) => + state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); const deleteStatus = (state, id, references) => { references.forEach(ref => { @@ -95,17 +33,10 @@ 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 STATUS_IMPORT: + return importStatus(state, action.status); + case STATUSES_IMPORT: + return importStatuses(state, action.statuses); case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); case FAVOURITE_FAIL: @@ -126,16 +57,6 @@ export default function statuses(state = initialState, action) { return state.withMutations(map => { action.ids.forEach(id => map.setIn([id, 'hidden'], true)); }); - 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); default: From 59657e24b9737cb2b38ea6b0f9e99192908b15df Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 21:36:44 +0900 Subject: [PATCH 11/20] Rename variables to have semantic meanings in notifications reducer (#6890) --- app/javascript/mastodon/reducers/notifications.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 264db4f55..06c36c89a 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -49,11 +49,11 @@ const normalizeNotification = (state, notification) => { }; const normalizeNotifications = (state, notifications, next) => { - let items = ImmutableList(); + let newItems = ImmutableList(); const loaded = state.get('loaded'); notifications.forEach((n, i) => { - items = items.set(i, notificationToMap(n)); + newItems = newItems.set(i, notificationToMap(n)); }); if (state.get('next') === null) { @@ -61,7 +61,7 @@ const normalizeNotifications = (state, notifications, next) => { } return state - .update('items', list => loaded ? items.concat(list) : list.concat(items)) + .update('items', oldItems => loaded ? newItems.concat(oldItems) : oldItems.concat(newItems)) .set('loaded', true) .set('isLoading', false); }; From 9a1a55ce526c956ac6b35897d483c316b7ad4394 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 23:25:15 +0900 Subject: [PATCH 12/20] Allow clients to fetch statuses made while they were offline (#6876) --- app/javascript/mastodon/actions/compose.js | 20 +--- app/javascript/mastodon/actions/streaming.js | 9 +- app/javascript/mastodon/actions/timelines.js | 111 ++---------------- .../mastodon/components/load_more.js | 5 +- .../mastodon/components/scrollable_list.js | 6 +- .../mastodon/components/status_list.js | 37 +++++- .../features/account_gallery/index.js | 51 ++++++-- .../features/account_timeline/index.js | 18 ++- .../features/community_timeline/index.js | 13 +- .../features/hashtag_timeline/index.js | 15 +-- .../mastodon/features/home_timeline/index.js | 12 +- .../mastodon/features/list_timeline/index.js | 10 +- .../features/public_timeline/index.js | 13 +- .../standalone/community_timeline/index.js | 13 +- .../standalone/hashtag_timeline/index.js | 13 +- .../standalone/public_timeline/index.js | 13 +- .../features/ui/components/report_modal.js | 6 +- .../ui/containers/status_list_container.js | 6 +- app/javascript/mastodon/features/ui/index.js | 4 +- app/javascript/mastodon/reducers/statuses.js | 4 +- app/javascript/mastodon/reducers/timelines.js | 65 +++++----- app/javascript/mastodon/stream.js | 6 +- 22 files changed, 191 insertions(+), 259 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8e13209b8..5e7cdd270 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -5,13 +5,7 @@ import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; - -import { - updateTimeline, - refreshHomeTimeline, - refreshCommunityTimeline, - refreshPublicTimeline, -} from './timelines'; +import { updateTimeline } from './timelines'; let cancelFetchComposeSuggestionsAccounts; @@ -125,19 +119,17 @@ export function submitCompose() { // To make the app more responsive, immediately get the status into the columns - const insertOrRefresh = (timelineId, refreshAction) => { - if (getState().getIn(['timelines', timelineId, 'online'])) { + const insertIfOnline = (timelineId) => { + if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { dispatch(updateTimeline(timelineId, { ...response.data })); - } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { - dispatch(refreshAction()); } }; - insertOrRefresh('home', refreshHomeTimeline); + insertIfOnline('home'); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { - insertOrRefresh('community', refreshCommunityTimeline); - insertOrRefresh('public', refreshPublicTimeline); + insertIfOnline('community'); + insertIfOnline('public'); } }).catch(function (error) { dispatch(submitComposeFail(error)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index c22152edd..3ac6b8a09 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -2,8 +2,7 @@ import { connectStream } from '../stream'; import { updateTimeline, deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, + expandHomeTimeline, disconnectTimeline, } from './timelines'; import { updateNotifications, refreshNotifications } from './notifications'; @@ -16,10 +15,6 @@ 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)); }, @@ -42,7 +37,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) } function refreshHomeTimelineAndNotification (dispatch) { - dispatch(refreshHomeTimeline()); + dispatch(expandHomeTimeline()); dispatch(refreshNotifications()); } diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index e5748b4e7..5be07126d 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,36 +1,20 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from '../api'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap } 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, partial) { - return { - type: TIMELINE_REFRESH_SUCCESS, - timeline, - statuses, - skipLoading, - next, - partial, - }; -}; - 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) : []; @@ -80,97 +64,34 @@ export function deleteFromTimelines(id) { }; }; -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') && !timeline.get('isPartial'))) { - 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 => { - if (response.status === 206) { - dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true)); - } else { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false)); - } - }).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, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); -export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -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 const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); - -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) { + if (timeline.get('isLoading')) { 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(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206)); }).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, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); -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 const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); +export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); +export const expandPublicTimeline = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }); +export const expandCommunityTimeline = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }); +export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); +export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +export const expandHashtagTimeline = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }); +export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); export function expandTimelineRequest(timeline) { return { @@ -179,12 +100,13 @@ export function expandTimelineRequest(timeline) { }; }; -export function expandTimelineSuccess(timeline, statuses, next) { +export function expandTimelineSuccess(timeline, statuses, next, partial) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, statuses, next, + partial, }; }; @@ -204,13 +126,6 @@ export function scrollTopTimeline(timeline, top) { }; }; -export function connectTimeline(timeline) { - return { - type: TIMELINE_CONNECT, - timeline, - }; -}; - export function disconnectTimeline(timeline) { return { type: TIMELINE_DISCONNECT, diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js index c4c8c94a2..389c3e1e1 100644 --- a/app/javascript/mastodon/components/load_more.js +++ b/app/javascript/mastodon/components/load_more.js @@ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent { static propTypes = { onClick: PropTypes.func, + disabled: PropTypes.bool, visible: PropTypes.bool, } @@ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent { } render() { - const { visible } = this.props; + const { disabled, visible } = this.props; return ( - ); diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index ac3e404df..ee07106f7 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, - onLoadMore: PropTypes.func.isRequired, + onLoadMore: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -148,11 +148,11 @@ export default class ScrollableList extends PureComponent { } render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); - const loadMore = (hasMore && childrenCount > 0) ? : null; + const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? : null; let scrollableArea = null; if (isLoading || childrenCount > 0 || !emptyMessage) { diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 3bebf702c..8c2673f30 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,11 +1,31 @@ +import { debounce } from 'lodash'; 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 LoadMore from './load_more'; import ScrollableList from './scrollable_list'; import { FormattedMessage } from 'react-intl'; +class LoadGap extends ImmutablePureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + return ; + } + +} + export default class StatusList extends ImmutablePureComponent { static propTypes = { @@ -38,6 +58,10 @@ export default class StatusList extends ImmutablePureComponent { this._selectChild(elementIndex); } + handleLoadOlder = debounce(() => { + this.props.onLoadMore(this.props.statusIds.last()); + }, 300, { leading: true }) + _selectChild (index) { const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); @@ -51,7 +75,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, ...other } = this.props; + const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -70,7 +94,14 @@ export default class StatusList extends ImmutablePureComponent { } let scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map(statusId => ( + statusIds.map((statusId, index) => statusId === null ? ( + 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + {scrollableContent} ); diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 4b408256a..9a40d139c 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -3,7 +3,7 @@ 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 { expandAccountMediaTimeline } from '../../actions/timelines'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; @@ -17,9 +17,31 @@ 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']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), }); +class LoadMoreMedia extends ImmutablePureComponent { + + static propTypes = { + maxId: PropTypes.string, + onLoadMore: PropTypes.func.isRequired, + }; + + handleLoadMore = () => { + this.props.onLoadMore(this.props.maxId); + } + + render () { + return ( + + ); + } + +} + @connect(mapStateToProps) export default class AccountGallery extends ImmutablePureComponent { @@ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent { componentDidMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + this.props.dispatch(expandAccountMediaTimeline(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)); + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); } } handleScrollToBottom = () => { if (this.props.hasMore) { - this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + this.handleLoadMore(this.props.medias.last().get('id')); } } @@ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent { } } - handleLoadMore = (e) => { + handleLoadMore = maxId => { + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); + }; + + handleLoadOlder = (e) => { e.preventDefault(); this.handleScrollToBottom(); } @@ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent { render () { const { medias, isLoading, hasMore } = this.props; - let loadMore = null; + let loadOlder = null; if (!medias && isLoading) { return ( @@ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent { } if (!isLoading && medias.size > 0 && hasMore) { - loadMore = ; + loadOlder = ; } return ( @@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {

- {medias.map(media => ( + {medias.map((media, index) => media === null ? ( + 0 ? medias.getIn(index - 1, 'id') : null} + /> + ) : ( ))} - {loadMore} + {loadOlder}
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 5e21cf7c6..d329bac5c 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { fetchAccount } from '../../actions/accounts'; -import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; @@ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), - hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), + hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), }; }; @@ -41,25 +41,23 @@ export default class AccountTimeline extends ImmutablePureComponent { this.props.dispatch(fetchAccount(accountId)); if (!withReplies) { - this.props.dispatch(refreshAccountFeaturedTimeline(accountId)); + this.props.dispatch(expandAccountFeaturedTimeline(accountId)); } - this.props.dispatch(refreshAccountTimeline(accountId, withReplies)); + this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); } componentWillReceiveProps (nextProps) { if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { this.props.dispatch(fetchAccount(nextProps.params.accountId)); if (!nextProps.withReplies) { - this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); + this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); } - this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); + this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); } } - handleLoadMore = () => { - if (!this.props.isLoading && this.props.hasMore) { - this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies)); - } + handleLoadMore = maxId => { + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); } render () { diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 596a89412..870474ed5 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -4,10 +4,7 @@ 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 { 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'; @@ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshCommunityTimeline()); + dispatch(expandCommunityTimeline()); this.disconnect = dispatch(connectCommunityStream()); } @@ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandCommunityTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandCommunityTimeline({ maxId })); } render () { @@ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} timelineId='community' - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 5fe21ce90..374615ac7 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -4,10 +4,7 @@ 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 { expandHashtagTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; import { connectHashtagStream } from '../../actions/streaming'; @@ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent { const { dispatch } = this.props; const { id } = this.props.params; - dispatch(refreshHashtagTimeline(id)); + dispatch(expandHashtagTimeline(id)); this._subscribe(dispatch, id); } componentWillReceiveProps (nextProps) { if (nextProps.params.id !== this.props.params.id) { - this.props.dispatch(refreshHashtagTimeline(nextProps.params.id)); + this.props.dispatch(expandHashtagTimeline(nextProps.params.id)); this._unsubscribe(); this._subscribe(this.props.dispatch, nextProps.params.id); } @@ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandHashtagTimeline(this.props.params.id)); + handleLoadMore = maxId => { + this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId })); } render () { @@ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`hashtag_timeline-${columnId}`} timelineId={`hashtag:${id}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 31f5a3c8b..db6bbdec1 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { expandHomeTimeline, refreshHomeTimeline } from '../../actions/timelines'; +import { expandHomeTimeline } from '../../actions/timelines'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; @@ -16,7 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, - isPartial: state.getIn(['timelines', 'home', 'isPartial'], false), + isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, }); @connect(mapStateToProps) @@ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandHomeTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandHomeTimeline({ maxId })); } componentDidMount () { @@ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent { return; } else if (!wasPartial && isPartial) { this.polling = setInterval(() => { - dispatch(refreshHomeTimeline()); + dispatch(expandHomeTimeline()); }, 3000); } else if (wasPartial && !isPartial) { this._stopPolling(); @@ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent { }} />} /> diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index 3b97ac62a..9a1e3c6d6 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { connectListStream } from '../../actions/streaming'; -import { refreshListTimeline, expandListTimeline } from '../../actions/timelines'; +import { expandListTimeline } from '../../actions/timelines'; import { fetchList, deleteList } from '../../actions/lists'; import { openModal } from '../../actions/modal'; import MissingIndicator from '../../components/missing_indicator'; @@ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent { const { id } = this.props.params; dispatch(fetchList(id)); - dispatch(refreshListTimeline(id)); + dispatch(expandListTimeline(id)); this.disconnect = dispatch(connectListStream(id)); } @@ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { + handleLoadMore = maxId => { const { id } = this.props.params; - this.props.dispatch(expandListTimeline(id)); + this.props.dispatch(expandListTimeline(id, { maxId })); } handleEditClick = () => { @@ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`list_timeline-${columnId}`} timelineId={`list:${id}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 193489c63..5a88f7601 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -4,10 +4,7 @@ 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 { 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'; @@ -55,7 +52,7 @@ export default class PublicTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshPublicTimeline()); + dispatch(expandPublicTimeline()); this.disconnect = dispatch(connectPublicStream()); } @@ -70,8 +67,8 @@ export default class PublicTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandPublicTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandPublicTimeline({ maxId })); } render () { @@ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent { } diff --git a/app/javascript/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js index 51e50e1f5..629d058a2 100644 --- a/app/javascript/mastodon/features/standalone/community_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/community_timeline/index.js @@ -2,10 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from '../../ui/containers/status_list_container'; -import { - refreshCommunityTimeline, - expandCommunityTimeline, -} from '../../../actions/timelines'; +import { expandCommunityTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../components/column_header'; import { defineMessages, injectIntl } from 'react-intl'; @@ -35,7 +32,7 @@ export default class CommunityTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshCommunityTimeline()); + dispatch(expandCommunityTimeline()); this.disconnect = dispatch(connectCommunityStream()); } @@ -46,8 +43,8 @@ export default class CommunityTimeline extends React.PureComponent { } } - handleLoadMore = () => { - this.props.dispatch(expandCommunityTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandCommunityTimeline({ maxId })); } render () { @@ -63,7 +60,7 @@ export default class CommunityTimeline extends React.PureComponent { diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js index f14be2aaf..931ca2a32 100644 --- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js @@ -2,10 +2,7 @@ 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 { expandHashtagTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../components/column_header'; import { connectHashtagStream } from '../../../actions/streaming'; @@ -29,7 +26,7 @@ export default class HashtagTimeline extends React.PureComponent { componentDidMount () { const { dispatch, hashtag } = this.props; - dispatch(refreshHashtagTimeline(hashtag)); + dispatch(expandHashtagTimeline(hashtag)); this.disconnect = dispatch(connectHashtagStream(hashtag)); } @@ -40,8 +37,8 @@ export default class HashtagTimeline extends React.PureComponent { } } - handleLoadMore = () => { - this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); + handleLoadMore = maxId => { + this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId })); } render () { @@ -59,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent { trackScroll={false} scrollKey='standalone_hashtag_timeline' timelineId={`hashtag:${hashtag}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js index 5805d1a10..1236cb927 100644 --- a/app/javascript/mastodon/features/standalone/public_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js @@ -2,10 +2,7 @@ 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 { expandPublicTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../components/column_header'; import { defineMessages, injectIntl } from 'react-intl'; @@ -35,7 +32,7 @@ export default class PublicTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshPublicTimeline()); + dispatch(expandPublicTimeline()); this.disconnect = dispatch(connectPublicStream()); } @@ -46,8 +43,8 @@ export default class PublicTimeline extends React.PureComponent { } } - handleLoadMore = () => { - this.props.dispatch(expandPublicTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandPublicTimeline({ maxId })); } render () { @@ -63,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index 3ae97646f..8a55c553c 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; -import { refreshAccountTimeline } from '../../../actions/timelines'; +import { expandAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { makeGetAccount } from '../../../selectors'; @@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent { } componentDidMount () { - this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'))); + this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); } componentWillReceiveProps (nextProps) { if (this.props.account !== nextProps.account && nextProps.account) { - this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'))); + this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'))); } } diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index fc2867cf0..4efacda65 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -48,15 +48,13 @@ const makeMapStateToProps = () => { statusIds: getStatusIds(state, { type: timelineId }), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), - hasMore: !!state.getIn(['timelines', timelineId, 'next']), + hasMore: state.getIn(['timelines', timelineId, 'hasMore']), }); return mapStateToProps; }; -const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({ - - onLoadMore: debounce(loadMore, 300, { leading: true }), +const mapDispatchToProps = (dispatch, { timelineId }) => ({ onScrollToTop: debounce(() => { dispatch(scrollTopTimeline(timelineId, true)); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 6cf00222a..59412908a 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -10,7 +10,7 @@ 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 { expandHomeTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; @@ -284,7 +284,7 @@ export default class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - this.props.dispatch(refreshHomeTimeline()); + this.props.dispatch(expandHomeTimeline()); this.props.dispatch(refreshNotifications()); } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index fc4b4900e..3abe69bce 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -10,9 +10,7 @@ import { STATUS_REVEAL, STATUS_HIDE, } from '../actions/statuses'; -import { - TIMELINE_DELETE, -} from '../actions/timelines'; +import { TIMELINE_DELETE } from '../actions/timelines'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 9a10bcc59..f795e7e08 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -1,14 +1,10 @@ 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 { @@ -22,37 +18,33 @@ const initialState = ImmutableMap(); const initialTimeline = ImmutableMap({ unread: 0, - online: false, top: true, - loaded: false, isLoading: false, - next: false, + hasMore: true, items: ImmutableList(), }); -const normalizeTimeline = (state, timeline, statuses, next, isPartial) => { - 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) : oldIds.concat(ids)); - mMap.set('isPartial', isPartial); - })); -}; - -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)); - +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); - mMap.set('next', next); - mMap.set('items', oldIds.concat(ids)); + if (!next) mMap.set('hasMore', false); + + if (!statuses.isEmpty()) { + mMap.update('items', ImmutableList(), oldIds => { + const newIds = statuses.map(status => status.get('id')); + const lastIndex = oldIds.findLastIndex(id => id !== null && id >= newIds.last()) + 1; + const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && id > newIds.first()); + + if (firstIndex < 0) { + return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex)); + } + + return oldIds.take(firstIndex + 1).concat( + isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds, + oldIds.skip(lastIndex) + ); + }); + } })); }; @@ -118,16 +110,12 @@ const updateTop = (state, timeline, 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, action.partial); case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, fromJS(action.status)); case TIMELINE_DELETE: @@ -139,10 +127,15 @@ export default function timelines(state = initialState, action) { 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)); + return state.update( + action.timeline, + initialTimeline, + map => map.update( + 'items', + items => items.first() ? items : items.unshift(null) + ) + ); default: return state; } diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index 9a6f4f26d..6c67ba275 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -1,10 +1,10 @@ import WebSocketClient from 'websocket.js'; -export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ 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); + const { onDisconnect, onReceive } = callbacks(dispatch, getState); let polling = null; const setupPolling = () => { @@ -25,7 +25,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ if (pollingRefresh) { clearPolling(); } - onConnect(); }, disconnected () { @@ -44,7 +43,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ clearPolling(); pollingRefresh(dispatch); } - onConnect(); }, }); From cbf97c03bba35a642e6f1d1a698aad7a69ad13a3 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sun, 25 Mar 2018 06:07:23 +0900 Subject: [PATCH 13/20] Allow clients to fetch notifications made while they were offline (#6886) --- .../mastodon/actions/notifications.js | 71 +------------------ app/javascript/mastodon/actions/streaming.js | 4 +- .../mastodon/features/notifications/index.js | 49 ++++++++++--- app/javascript/mastodon/features/ui/index.js | 4 +- .../mastodon/reducers/notifications.js | 68 +++++++++--------- 5 files changed, 82 insertions(+), 114 deletions(-) diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index a664cd978..7267b85bd 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -1,5 +1,4 @@ import api, { getLinks } from '../api'; -import { List as ImmutableList } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; import { @@ -12,10 +11,6 @@ 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'; @@ -74,74 +69,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); -export function refreshNotifications() { +export function expandNotifications({ maxId } = {}) { 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(importFetchedAccounts(response.data.map(item => item.account))); - dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - - 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, - 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) { + if (getState().getIn(['notifications', 'isLoading'])) { return; } const params = { - max_id: items.last().get('id'), - limit: 20, + max_id: maxId, exclude_types: excludeTypesFromSettings(getState()), }; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 3ac6b8a09..f76510cdb 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -5,7 +5,7 @@ import { expandHomeTimeline, disconnectTimeline, } from './timelines'; -import { updateNotifications, refreshNotifications } from './notifications'; +import { updateNotifications, expandNotifications } from './notifications'; import { getLocale } from '../locales'; const { messages } = getLocale(); @@ -38,7 +38,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) function refreshHomeTimelineAndNotification (dispatch) { dispatch(expandHomeTimeline()); - dispatch(refreshNotifications()); + dispatch(expandNotifications()); } export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index cb9d025ea..9a6fb45c8 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -13,6 +13,7 @@ import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from '../../components/scrollable_list'; +import LoadMore from '../../components/load_more'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -21,13 +22,31 @@ const messages = defineMessages({ 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')))); +], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); + +class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + return ; + } + +} const mapStateToProps = state => ({ notifications: getNotifications(state), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, - hasMore: !!state.getIn(['notifications', 'next']), + hasMore: state.getIn(['notifications', 'hasMore']), }); @connect(mapStateToProps) @@ -51,14 +70,19 @@ export default class Notifications extends React.PureComponent { }; componentWillUnmount () { - this.handleLoadMore.cancel(); + this.handleLoadOlder.cancel(); this.handleScrollToTop.cancel(); this.handleScroll.cancel(); this.props.dispatch(scrollTopNotifications(false)); } - handleLoadMore = debounce(() => { - this.props.dispatch(expandNotifications()); + handleLoadGap = (maxId) => { + this.props.dispatch(expandNotifications({ maxId })); + }; + + handleLoadOlder = debounce(() => { + const last = this.props.notifications.last(); + this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }); handleScrollToTop = debounce(() => { @@ -93,12 +117,12 @@ export default class Notifications extends React.PureComponent { } handleMoveUp = id => { - const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; this._selectChild(elementIndex); } handleMoveDown = id => { - const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; this._selectChild(elementIndex); } @@ -120,7 +144,14 @@ export default class Notifications extends React.PureComponent { if (isLoading && this.scrollableContent) { scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item) => ( + scrollableContent = notifications.map((item, index) => item === null ? ( + 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( ImmutableMap({ @@ -48,35 +44,41 @@ const normalizeNotification = (state, notification) => { }); }; -const normalizeNotifications = (state, notifications, next) => { - let newItems = ImmutableList(); - const loaded = state.get('loaded'); +const newer = (m, n) => { + const mId = m.get('id'); + const nId = n.get('id'); - notifications.forEach((n, i) => { - newItems = newItems.set(i, notificationToMap(n)); - }); - - if (state.get('next') === null) { - state = state.set('next', next); - } - - return state - .update('items', oldItems => loaded ? newItems.concat(oldItems) : oldItems.concat(newItems)) - .set('loaded', true) - .set('isLoading', false); + return mId.length === nId.length ? mId > nId : mId.length > nId.length; }; -const appendNormalizedNotifications = (state, notifications, next) => { +const expandNormalizedNotifications = (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); + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + const lastIndex = 1 + list.findLastIndex( + item => item !== null && (newer(item, items.last()) || item.get('id') === items.last().get('id')) + ); + + const firstIndex = 1 + list.take(lastIndex).findLastIndex( + item => item !== null && newer(item, items.first()) + ); + + return list.take(firstIndex).concat(items, list.skip(lastIndex)); + }); + } + + if (!next) { + mutable.set('hasMore', true); + } + + mutable.set('isLoading', false); + }); }; const filterNotifications = (state, relationship) => { @@ -97,27 +99,27 @@ const deleteByStatus = (state, 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); + return expandNormalizedNotifications(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); + return state.set('items', ImmutableList()).set('hasMore', false); case TIMELINE_DELETE: return deleteByStatus(state, action.id); + case TIMELINE_DISCONNECT: + return action.timeline === 'home' ? + state.update('items', items => items.first() ? items.unshift(null) : items) : + state; default: return state; } From 85a395fab6d7077a252bfe6f96673931ea3aa5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Miko=C5=82ajczak?= Date: Sun, 25 Mar 2018 16:33:07 +0200 Subject: [PATCH 14/20] i18n: Update Polish translation (#6903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- .../mastodon/locales/defaultMessages.json | 2 +- app/javascript/mastodon/locales/pl.json | 4 ++-- config/locales/pl.yml | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index eee60c57f..76b302f3a 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -1748,4 +1748,4 @@ ], "path": "app/javascript/mastodon/middleware/errors.json" } -] +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 0b6f178f8..7262ce76b 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -28,8 +28,8 @@ "account.unmute": "Cofnij wyciszenie @{name}", "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}", "account.view_full_profile": "Wyświetl pełny profil", - "alert.unexpected.message": "An unexpected error occurred.", - "alert.unexpected.title": "Oops!", + "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.", + "alert.unexpected.title": "O nie!", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", "bundle_column_error.retry": "Spróbuj ponownie", diff --git a/config/locales/pl.yml b/config/locales/pl.yml index de43ca9a9..e92742ef4 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -383,6 +383,7 @@ pl: security: Bezpieczeństwo set_new_password: Ustaw nowe hasło authorize_follow: + already_following: Już śledzisz to konto error: Niestety, podczas sprawdzania zdalnego konta wystąpił błąd follow: Śledź follow_request: 'Wysłano prośbę o pozwolenie na śledzenie:' @@ -475,6 +476,7 @@ pl: '21600': 6 godzinach '3600': godzinie '43200': 12 godzinach + '604800': 1 tygodniu '86400': dobie expires_in_prompt: Nigdy generate: Wygeneruj @@ -643,6 +645,17 @@ pl: statuses: attached: description: 'Przytwierdzony: %{attached}' + image: + few: "%{count} obrazy" + many: "%{count} obrazów" + one: "%{count} obraz" + other: "%{count} obrazów" + video: + few: "%{count} filmy" + many: "%{count} filmów" + one: "%{count} film" + other: "%{count} filmów" + content_warning: 'Ostrzeżenie o zawartości: %{warning}' open_in_web: Otwórz w przeglądarce over_character_limit: limit %{max} znaków przekroczony pin_errors: From 3b2c7a33a9fe151b65724f9076a4ef93ad1d6948 Mon Sep 17 00:00:00 2001 From: Yann Klis Date: Mon, 26 Mar 2018 12:47:34 +0200 Subject: [PATCH 15/20] Missing OTP_SECRET in scalingo.json (#6917) --- scalingo.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scalingo.json b/scalingo.json index 426698b9c..0cc648f02 100644 --- a/scalingo.json +++ b/scalingo.json @@ -21,6 +21,10 @@ "description": "The secret key base", "generator": "secret" }, + "OTP_SECRET": { + "description": "One-time password secret", + "generator": "secret" + }, "SINGLE_USER_MODE": { "description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)", "value": "false", From 605a92b4607589c64acf9c5cb58d2fcc68e2606a Mon Sep 17 00:00:00 2001 From: unarist Date: Mon, 26 Mar 2018 19:48:01 +0900 Subject: [PATCH 16/20] Fix moved account handling in IndexedDB feature (#6915) * Fix stack overflow on importFetchedAccounts When the account has moved property, it should process destination account instead of source account itself. * Set account id instead of account object for moved property This restores "foo has moved to" indication on account view, and fixes `reblog` index on `accounts` object store. --- app/javascript/mastodon/actions/importer/index.js | 2 +- app/javascript/mastodon/actions/importer/normalizer.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index d1ea40c36..a97f4d173 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -39,7 +39,7 @@ export function importFetchedAccounts(accounts) { pushUnique(normalAccounts, normalizeAccount(account)); if (account.moved) { - processAccount(account); + processAccount(account.moved); } } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index c88f6946f..1b09f319f 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -10,6 +10,10 @@ export function normalizeAccount(account) { account.display_name_html = emojify(escapeTextContentForBrowser(displayName)); account.note_emojified = emojify(account.note); + if (account.moved) { + account.moved = account.moved.id; + } + return account; } From f691afaae913fdb3041864b2824ca092e092ba84 Mon Sep 17 00:00:00 2001 From: Yuto Tokunaga Date: Mon, 26 Mar 2018 20:59:21 +0900 Subject: [PATCH 17/20] Refactor scss (#6913) * Refactoring scss introduce scss variables for the media modal fix css block structure corresponding to react components fix flex layouts remove background image of the loaded image on the media modal * Fix typo --- .../styles/mastodon/components.scss | 49 ++++++++----------- app/javascript/styles/mastodon/variables.scss | 5 ++ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ea6e39392..1fb1fa851 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1435,14 +1435,19 @@ position: relative; width: 100%; height: 100%; + display: flex; + align-items: center; + justify-content: center; - &.image-loader--loading { - display: flex; - align-content: center; + .image-loader__preview-canvas { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + background: url('../images/void.png') repeat; + object-fit: contain; + } - .image-loader__preview-canvas { - filter: blur(2px); - } + &.image-loader--loading .image-loader__preview-canvas { + filter: blur(2px); } &.image-loader--amorphous .image-loader__preview-canvas { @@ -1455,7 +1460,16 @@ width: 100%; height: 100%; display: flex; - align-content: center; + align-items: center; + justify-content: center; + + img { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + width: auto; + height: auto; + object-fit: contain; + } } .navigation-bar { @@ -3422,27 +3436,6 @@ a.status-card { width: 100%; height: 100%; position: relative; - - img, - canvas, - video { - max-width: 100%; - /* - put margins on top and bottom of image to avoid the screen coverd by - image. - */ - max-height: 80%; - width: auto; - height: auto; - margin: auto; - } - - img, - canvas { - display: block; - background: url('../images/void.png') repeat; - object-fit: contain; - } } .media-modal__closer { diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index dcc2857ff..e456c27ee 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -30,3 +30,8 @@ $ui-highlight-color: $classic-highlight-color !default; // Vibrant // Language codes that uses CJK fonts $cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; + +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; From 18965cb0e611b226c6252f1669f228f5b95f1ac6 Mon Sep 17 00:00:00 2001 From: Stephen Burgess Date: Mon, 26 Mar 2018 07:59:44 -0400 Subject: [PATCH 18/20] feat(ShowMore): Add classname to show more/show less button (#6904) --- app/javascript/mastodon/components/status_content.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index b6082f008..9b86592f6 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -158,7 +158,7 @@ export default class StatusContent extends React.PureComponent { {mentionsPlaceholder} From 40e5d2303ba1edc51beae66cc15263675980106a Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Mon, 26 Mar 2018 21:02:10 +0900 Subject: [PATCH 19/20] Validate HTTP response length while receiving (#6891) to_s method of HTTP::Response keeps blocking while it receives the whole content, no matter how it is big. This means it may waste time to receive unacceptably large files. It may also consume memory and disk in the process. This solves the inefficency by checking response length while receiving. --- app/helpers/jsonld_helper.rb | 2 +- app/lib/exceptions.rb | 1 + app/lib/provider_discovery.rb | 2 +- app/lib/request.rb | 31 +++++++++++- app/models/account.rb | 1 - app/models/application_record.rb | 1 + app/models/concerns/account_avatar.rb | 4 +- app/models/concerns/account_header.rb | 4 +- app/models/concerns/remotable.rb | 6 +-- app/models/custom_emoji.rb | 6 ++- app/models/media_attachment.rb | 7 +-- app/models/preview_card.rb | 5 +- app/services/fetch_atom_service.rb | 11 +++-- app/services/fetch_link_card_service.rb | 2 +- app/services/resolve_account_service.rb | 2 +- .../pubsubhubbub/confirmation_worker.rb | 2 +- spec/lib/request_spec.rb | 49 +++++++++++++++++++ spec/models/concerns/remotable_spec.rb | 5 +- 18 files changed, 115 insertions(+), 26 deletions(-) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 957a2cbc9..dfb8fcb8b 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -61,7 +61,7 @@ module JsonLdHelper def fetch_resource_without_id_validation(uri) build_request(uri).perform do |response| - response.code == 200 ? body_to_json(response.to_s) : nil + response.code == 200 ? body_to_json(response.body_with_limit) : nil end end diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb index 95e3365c2..e88e98eae 100644 --- a/app/lib/exceptions.rb +++ b/app/lib/exceptions.rb @@ -5,6 +5,7 @@ module Mastodon class NotPermittedError < Error; end class ValidationError < Error; end class HostValidationError < ValidationError; end + class LengthValidationError < ValidationError; end class RaceConditionError < Error; end class UnexpectedResponseError < Error diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb index bbd3a2d43..3bec7211b 100644 --- a/app/lib/provider_discovery.rb +++ b/app/lib/provider_discovery.rb @@ -18,7 +18,7 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery else Request.new(:get, url).perform do |res| raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' - Nokogiri::HTML(res.to_s) + Nokogiri::HTML(res.body_with_limit) end end diff --git a/app/lib/request.rb b/app/lib/request.rb index 8a127c65f..dca93a6e9 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -40,7 +40,7 @@ class Request end begin - yield response + yield response.extend(ClientLimit) ensure http_client.close end @@ -99,6 +99,33 @@ class Request @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) end + module ClientLimit + def body_with_limit(limit = 1.megabyte) + raise Mastodon::LengthValidationError if content_length.present? && content_length > limit + + if charset.nil? + encoding = Encoding::BINARY + else + begin + encoding = Encoding.find(charset) + rescue ArgumentError + encoding = Encoding::BINARY + end + end + + contents = String.new(encoding: encoding) + + while (chunk = readpartial) + contents << chunk + chunk.clear + + raise Mastodon::LengthValidationError if contents.bytesize > limit + end + + contents + end + end + class Socket < TCPSocket class << self def open(host, *args) @@ -118,5 +145,5 @@ class Request end end - private_constant :Socket + private_constant :ClientLimit, :Socket end diff --git a/app/models/account.rb b/app/models/account.rb index 9a83d979f..25e7d7436 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -55,7 +55,6 @@ class Account < ApplicationRecord include AccountHeader include AccountInteractions include Attachmentable - include Remotable include Paginable enum protocol: [:ostatus, :activitypub] diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 71fbba5b3..83134d41a 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,4 +2,5 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + include Remotable end diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb index 9e34a9461..2d5ebfca3 100644 --- a/app/models/concerns/account_avatar.rb +++ b/app/models/concerns/account_avatar.rb @@ -4,6 +4,7 @@ module AccountAvatar extend ActiveSupport::Concern IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + LIMIT = 2.megabytes class_methods do def avatar_styles(file) @@ -19,7 +20,8 @@ module AccountAvatar # Avatar upload has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail] validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES - validates_attachment_size :avatar, less_than: 2.megabytes + validates_attachment_size :avatar, less_than: LIMIT + remotable_attachment :avatar, LIMIT end def avatar_original_url diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb index 04c576b28..ef40b8126 100644 --- a/app/models/concerns/account_header.rb +++ b/app/models/concerns/account_header.rb @@ -4,6 +4,7 @@ module AccountHeader extend ActiveSupport::Concern IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + LIMIT = 2.megabytes class_methods do def header_styles(file) @@ -19,7 +20,8 @@ module AccountHeader # Header upload has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail] validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES - validates_attachment_size :header, less_than: 2.megabytes + validates_attachment_size :header, less_than: LIMIT + remotable_attachment :header, LIMIT end def header_original_url diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 0f18c5d96..3b8c507c3 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -3,8 +3,8 @@ module Remotable extend ActiveSupport::Concern - included do - attachment_definitions.each_key do |attachment_name| + class_methods do + def remotable_attachment(attachment_name, limit) attribute_name = "#{attachment_name}_remote_url".to_sym method_name = "#{attribute_name}=".to_sym alt_method_name = "reset_#{attachment_name}!".to_sym @@ -33,7 +33,7 @@ module Remotable File.extname(filename) end - send("#{attachment_name}=", StringIO.new(response.to_s)) + send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit))) send("#{attachment_name}_file_name=", basename + extname) self[attribute_name] = url if has_attribute?(attribute_name) diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index a77b53c98..476178e86 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -19,6 +19,8 @@ # class CustomEmoji < ApplicationRecord + LIMIT = 50.kilobytes + SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' SCAN_RE = /(?<=[^[:alnum:]:]|\n|^) @@ -29,14 +31,14 @@ class CustomEmoji < ApplicationRecord has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } - validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes } + validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT } validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } scope :local, -> { where(domain: nil) } scope :remote, -> { where.not(domain: nil) } scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } - include Remotable + remotable_attachment :image, LIMIT def local? domain.nil? diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index a4d9cd9d1..ac2aa7ed2 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -56,6 +56,8 @@ class MediaAttachment < ApplicationRecord }, }.freeze + LIMIT = 8.megabytes + belongs_to :account, inverse_of: :media_attachments, optional: true belongs_to :status, inverse_of: :media_attachments, optional: true @@ -64,10 +66,9 @@ class MediaAttachment < ApplicationRecord processors: ->(f) { file_processors f }, convert_options: { all: '-quality 90 -strip' } - include Remotable - validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES - validates_attachment_size :file, less_than: 8.megabytes + validates_attachment_size :file, less_than: LIMIT + remotable_attachment :file, LIMIT validates :account, presence: true validates :description, length: { maximum: 420 }, if: :local? diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 86eecdfe5..0c82f06ce 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -26,6 +26,7 @@ class PreviewCard < ApplicationRecord IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + LIMIT = 1.megabytes self.inheritance_column = false @@ -36,11 +37,11 @@ class PreviewCard < ApplicationRecord has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' } include Attachmentable - include Remotable validates :url, presence: true, uniqueness: true validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES - validates_attachment_size :image, less_than: 1.megabytes + validates_attachment_size :image, less_than: LIMIT + remotable_attachment :image, LIMIT before_save :extract_dimensions, if: :link? diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 48ad5dcd3..62dea8298 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -38,13 +38,14 @@ class FetchAtomService < BaseService return nil if response.code != 200 if response.mime_type == 'application/atom+xml' - [@url, { prefetched_body: response.to_s }, :ostatus] + [@url, { prefetched_body: response.body_with_limit }, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type) - json = body_to_json(response.to_s) + body = response.body_with_limit + json = body_to_json(body) if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? - [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] + [json['id'], { prefetched_body: body, id: true }, :activitypub] elsif supported_context?(json) && json['type'] == 'Note' - [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] + [json['id'], { prefetched_body: body, id: true }, :activitypub] else @unsupported_activity = true nil @@ -61,7 +62,7 @@ class FetchAtomService < BaseService end def process_html(response) - page = Nokogiri::HTML(response.to_s) + page = Nokogiri::HTML(response.body_with_limit) json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 26deb5ecc..d5920a417 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -45,7 +45,7 @@ class FetchLinkCardService < BaseService Request.new(:get, @url).perform do |res| if res.code == 200 && res.mime_type == 'text/html' - @html = res.to_s + @html = res.body_with_limit @html_charset = res.charset else @html = nil diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index 034821dc0..744ea24f4 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -181,7 +181,7 @@ class ResolveAccountService < BaseService @atom_body = Request.new(:get, atom_url).perform do |response| raise Mastodon::UnexpectedResponseError, response unless response.code == 200 - response.to_s + response.body_with_limit end end diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index cc2d1225b..c0e7b677e 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -57,7 +57,7 @@ class Pubsubhubbub::ConfirmationWorker def callback_get_with_params Request.new(:get, subscription.callback_url, params: callback_params).perform do |response| - @callback_response_body = response.body.to_s + @callback_response_body = response.body_with_limit end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index 4d6b20dd5..939ac006a 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require 'securerandom' describe Request do subject { Request.new(:get, 'http://example.com') } @@ -64,6 +65,12 @@ describe Request do expect_any_instance_of(HTTP::Client).to receive(:close) expect { |block| subject.perform &block }.to yield_control end + + it 'returns response which implements body_with_limit' do + subject.perform do |response| + expect(response).to respond_to :body_with_limit + end + end end context 'with private host' do @@ -81,4 +88,46 @@ describe Request do end end end + + describe "response's body_with_limit method" do + it 'rejects body more than 1 megabyte by default' do + stub_request(:any, 'http://example.com').to_return(body: SecureRandom.random_bytes(2.megabytes)) + expect { subject.perform { |response| response.body_with_limit } }.to raise_error Mastodon::LengthValidationError + end + + it 'accepts body less than 1 megabyte by default' do + stub_request(:any, 'http://example.com').to_return(body: SecureRandom.random_bytes(2.kilobytes)) + expect { subject.perform { |response| response.body_with_limit } }.not_to raise_error + end + + it 'rejects body by given size' do + stub_request(:any, 'http://example.com').to_return(body: SecureRandom.random_bytes(2.kilobytes)) + expect { subject.perform { |response| response.body_with_limit(1.kilobyte) } }.to raise_error Mastodon::LengthValidationError + end + + it 'rejects too large chunked body' do + stub_request(:any, 'http://example.com').to_return(body: SecureRandom.random_bytes(2.megabytes), headers: { 'Transfer-Encoding' => 'chunked' }) + expect { subject.perform { |response| response.body_with_limit } }.to raise_error Mastodon::LengthValidationError + end + + it 'rejects too large monolithic body' do + stub_request(:any, 'http://example.com').to_return(body: SecureRandom.random_bytes(2.megabytes), headers: { 'Content-Length' => 2.megabytes }) + expect { subject.perform { |response| response.body_with_limit } }.to raise_error Mastodon::LengthValidationError + end + + it 'uses binary encoding if Content-Type does not tell encoding' do + stub_request(:any, 'http://example.com').to_return(body: '', headers: { 'Content-Type' => 'text/html' }) + expect(subject.perform { |response| response.body_with_limit.encoding }).to eq Encoding::BINARY + end + + it 'uses binary encoding if Content-Type tells unknown encoding' do + stub_request(:any, 'http://example.com').to_return(body: '', headers: { 'Content-Type' => 'text/html; charset=unknown' }) + expect(subject.perform { |response| response.body_with_limit.encoding }).to eq Encoding::BINARY + end + + it 'uses encoding specified by Content-Type' do + stub_request(:any, 'http://example.com').to_return(body: '', headers: { 'Content-Type' => 'text/html; charset=UTF-8' }) + expect(subject.perform { |response| response.body_with_limit.encoding }).to eq Encoding::UTF_8 + end + end end diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb index 0b2dad23f..b39233739 100644 --- a/spec/models/concerns/remotable_spec.rb +++ b/spec/models/concerns/remotable_spec.rb @@ -29,7 +29,10 @@ RSpec.describe Remotable do context 'Remotable module is included' do before do - class Foo; include Remotable; end + class Foo + include Remotable + remotable_attachment :hoge, 1.kilobyte + end end let(:attribute_name) { "#{hoge}_remote_url".to_sym } From 2a90da18375a38957ae4c94fa3e86a8180237d8a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 27 Mar 2018 04:33:57 +0200 Subject: [PATCH 20/20] Fix UniqueUsernameValidator comparison (#6926) Comparison was downcasing only one side, therefore if previously existing account had a non-lowercase spelling, it would be ignored when checking for duplicates. New rake task `mastodon:maintenance:find_duplicate_usernames` will help find constraint violations that might have occured from the presence of this bug. Bump version to 2.3.3 --- app/models/concerns/account_finder_concern.rb | 2 +- app/validators/unique_username_validator.rb | 2 +- lib/mastodon/version.rb | 2 +- lib/tasks/mastodon.rake | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index 2e8a7fb37..6b7237e89 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -30,7 +30,7 @@ module AccountFinderConcern end def account - scoped_accounts.take + scoped_accounts.order(id: :asc).take end private diff --git a/app/validators/unique_username_validator.rb b/app/validators/unique_username_validator.rb index c76407b16..fb67105dd 100644 --- a/app/validators/unique_username_validator.rb +++ b/app/validators/unique_username_validator.rb @@ -6,7 +6,7 @@ class UniqueUsernameValidator < ActiveModel::Validator normalized_username = account.username.downcase.delete('.') - scope = Account.where(domain: nil, username: normalized_username) + scope = Account.where(domain: nil).where('lower(username) = ?', normalized_username) scope = scope.where.not(id: account.id) if account.persisted? account.errors.add(:username, :taken) if scope.exists? diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 121c5c693..a6927eec3 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 2 + 3 end def pre diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 0972e4367..cfd6a1d25 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -740,6 +740,24 @@ namespace :mastodon do LinkCrawlWorker.push_bulk status_ids end + desc 'Find case-insensitive username duplicates of local users' + task find_duplicate_usernames: :environment do + include RoutingHelper + + disable_log_stdout! + + duplicate_masters = Account.find_by_sql('SELECT * FROM accounts WHERE id IN (SELECT min(id) FROM accounts WHERE domain IS NULL GROUP BY lower(username) HAVING count(*) > 1)') + pastel = Pastel.new + + duplicate_masters.each do |account| + puts pastel.yellow("First of their name: ") + pastel.bold(account.username) + " (#{admin_account_url(account.id)})" + + Account.where('lower(username) = ?', account.username.downcase).where.not(id: account.id).each do |duplicate| + puts " " + pastel.red("Duplicate: ") + admin_account_url(duplicate.id) + end + end + end + desc 'Remove all home feed regeneration markers' task remove_regeneration_markers: :environment do keys = Redis.current.keys('account:*:regeneration')