about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx11
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx22
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx4
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx13
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx1
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx11
-rw-r--r--app/assets/javascripts/components/selectors/index.jsx2
-rw-r--r--config/initializers/timeout.rb4
-rw-r--r--config/locales/devise.fr.yml1
-rw-r--r--config/locales/doorkeeper.fr.yml3
-rw-r--r--config/locales/fr.yml91
-rw-r--r--config/locales/simple_form.fr.yml16
-rw-r--r--streaming/index.js7
16 files changed, 205 insertions, 15 deletions
diff --git a/Gemfile b/Gemfile
index 440f2e87b..764010d5d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -50,6 +50,7 @@ gem 'rails-settings-cached'
 gem 'simple-navigation'
 gem 'statsd-instrument'
 gem 'ruby-oembed', require: 'oembed'
+gem 'rack-timeout'
 
 gem 'react-rails'
 gem 'browserify-rails'
@@ -89,5 +90,4 @@ group :production do
   gem 'rails_12factor'
   gem 'redis-rails'
   gem 'lograge'
-  gem 'rack-timeout'
 end
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 05fa8e68d..37ebb9969 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -579,15 +579,18 @@ export function expandFollowingFail(id, error) {
   };
 };
 
-export function fetchRelationships(account_ids) {
+export function fetchRelationships(accountIds) {
   return (dispatch, getState) => {
-    if (account_ids.length === 0) {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
       return;
     }
 
-    dispatch(fetchRelationshipsRequest(account_ids));
+    dispatch(fetchRelationshipsRequest(newAccountIds));
 
-    api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
       dispatch(fetchRelationshipsSuccess(response.data));
     }).catch(error => {
       dispatch(fetchRelationshipsFail(error));
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 3e2d4ff43..6cd1f04b3 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -14,6 +14,9 @@ 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 function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
@@ -76,6 +79,11 @@ export function refreshTimeline(timeline, id = null) {
     let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
+      if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
+        // Skip refreshing when timeline is live anyway
+        return;
+      }
+
       params          = { ...params, since_id: newestId };
       skipLoading     = true;
     }
@@ -162,3 +170,17 @@ export function scrollTopTimeline(timeline, top) {
     top
   };
 };
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline
+  };
+};
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 1d41c14d4..ab21ca9cd 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -24,7 +24,7 @@ const muteStyle = {
   top: '10px',
   right: '10px',
   color: 'white',
-  boxShadow: '1px 1px 1px #000',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   opacity: '0.8',
   zIndex: '5'
 };
@@ -57,7 +57,7 @@ const spoilerButtonStyle = {
   top: '6px',
   left: '8px',
   color: 'white',
-  boxShadow: '1px 1px 1px #000',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   zIndex: '100'
 };
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 40fbac525..6dc08bb4c 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -4,7 +4,9 @@ import {
   refreshTimelineSuccess,
   updateTimeline,
   deleteFromTimelines,
-  refreshTimeline
+  refreshTimeline,
+  connectTimeline,
+  disconnectTimeline
 } from '../actions/timelines';
 import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
@@ -70,6 +72,14 @@ const Mastodon = React.createClass({
 
     this.subscription = createStream(accessToken, 'user', {
 
+      connected () {
+        store.dispatch(connectTimeline('home'));
+      },
+
+      disconnected () {
+        store.dispatch(disconnectTimeline('home'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
@@ -85,6 +95,7 @@ const Mastodon = React.createClass({
       },
 
       reconnected () {
+        store.dispatch(connectTimeline('home'));
         store.dispatch(refreshTimeline('home'));
         store.dispatch(refreshNotifications());
       }
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index 2cfd7b2fe..0957338cf 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public:local', {
 
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index b2342abbd..6d766a83b 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const PublicTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public', {
 
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 2f5dd182f..a45b04b0c 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -33,6 +33,7 @@ const fr = {
   "navigation_bar.logout": "Déconnexion",
   "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Fil public",
+  "navigation_bar.local_timeline": "Fil local",
   "notification.favourite": "{name} a ajouté à ses favoris :",
   "notification.follow": "{name} vous suit.",
   "notification.mention": "{name} vous a mentionné⋅e :",
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index c67d05423..675a52759 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -7,7 +7,9 @@ import {
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
-  TIMELINE_SCROLL_TOP
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT
 } from '../actions/timelines';
 import {
   REBLOG_SUCCESS,
@@ -35,6 +37,7 @@ const initialState = Immutable.Map({
     path: () => '/api/v1/timelines/home',
     next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -45,6 +48,7 @@ const initialState = Immutable.Map({
     path: () => '/api/v1/timelines/public',
     next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -56,6 +60,7 @@ const initialState = Immutable.Map({
     next: null,
     params: { local: true },
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -300,6 +305,10 @@ export default function timelines(state = initialState, action) {
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
+  case TIMELINE_CONNECT:
+    return state.setIn([action.timeline, 'online'], true);
+  case TIMELINE_DISCONNECT:
+    return state.setIn([action.timeline, 'online'], false);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx
index 0e88654a1..01a6cb264 100644
--- a/app/assets/javascripts/components/selectors/index.jsx
+++ b/app/assets/javascripts/components/selectors/index.jsx
@@ -5,7 +5,7 @@ const getStatuses = state => state.get('statuses');
 const getAccounts = state => state.get('accounts');
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
-const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
 
 export const makeGetAccount = () => {
   return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
diff --git a/config/initializers/timeout.rb b/config/initializers/timeout.rb
index 06a29492e..de87fd906 100644
--- a/config/initializers/timeout.rb
+++ b/config/initializers/timeout.rb
@@ -1,4 +1,6 @@
+Rack::Timeout::Logger.disable
+Rack::Timeout.service_timeout = false
+
 if Rails.env.production?
   Rack::Timeout.service_timeout = 90
-  Rack::Timeout::Logger.disable
 end
diff --git a/config/locales/devise.fr.yml b/config/locales/devise.fr.yml
index b64601e7b..ce44d041a 100644
--- a/config/locales/devise.fr.yml
+++ b/config/locales/devise.fr.yml
@@ -58,3 +58,4 @@ fr:
       not_locked: n'était pas verrouillé(e)
       not_saved:
         one: '1 erreur a empêché ce(tte) %{resource} d''être sauvegardé(e) :'
+        other: '%{count} erreurs ont empêché ce(tte) %{resource} d''être sauvegardé(e): '
diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml
index 6f3c0864a..45fe08e20 100644
--- a/config/locales/doorkeeper.fr.yml
+++ b/config/locales/doorkeeper.fr.yml
@@ -54,7 +54,7 @@ fr:
         title: Une erreur est survenue
       new:
         able_to: Cette application pourra
-        prompt: Autorisez %{client_name} à utiliser votre compte?
+        prompt: Autoriser %{client_name} à utiliser votre compte?
         title: Autorisation requise
       show:
         title: Code d'autorisation
@@ -67,6 +67,7 @@ fr:
         application: Application
         created_at: Créé le
         date_format: "%Y-%m-%d %H:%M:%S"
+        scopes: permissions
         title: Vos applications autorisées
     errors:
       messages:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 173e8d16c..c0bda95b7 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -2,9 +2,30 @@
 fr:
   about:
     about_mastodon: Mastodon est un serveur <em>libre</em> de réseautage social. Alternative <em>décentralisée</em> aux plateformes commerciales, la monopolisation de vos communications par une entreprise unique est évitée. Tout un chacun peut faire tourner Mastodon et participer au <em>réseau social</em> de manière transparente.
+    about_this: À propos de cette instance
+    apps: Applications
+    business_email: E-mail professionnel
+    description_headline: Qu'est-ce que %{domain} ?
+    domain_count_after: autres instances
+    domain_count_before: Connectés à
+    features:
+      api: API ouverte aux apps et services
+      blocking: Outils complets de bloquage et masquage
+      characters: 500 caractères par post
+      chronology: Fil chronologique
+      ethics: 'Pas de pubs, pas de pistage'
+      gifv: Partage de vidéos et de GIFs
+      privacy: Réglages de confidentialité au niveau des posts
+      public: Fils publics
+    features_headline: Ce qui rend Mastodon différent
     get_started: Rejoindre le réseau
+    links: Liens
     source_code: Code source
+    status_count_after: posts
+    status_count_before: Ayant publiés
     terms: Conditions d’utilisation
+    user_count_after: utilisateurs
+    user_count_before: Abrite
   accounts:
     follow: Suivre
     followers: Abonnés
@@ -13,18 +34,33 @@ fr:
     people_followed_by: Personnes suivies par %{name}
     people_who_follow: Personnes qui suivent %{name}
     posts: Statuts
+    remote_follow: Suivre à distance
     unfollow: Ne plus suivre
   application_mailer:
+    settings: 'Changer les préférences e-mail: ${link}'
     signature: Notifications de Mastodon depuis %{instance}
+    view: 'Voir:'
+  applications:
+    invalid_url: L'URL fournie est invalide
   auth:
     change_password: Changer de mot de passe
     didnt_get_confirmation: Vous n’avez pas reçu les consignes de confirmation ?
     forgot_password: Mode passe oublié ?
     login: Se connecter
+    logout: Se déconnecter
     register: S’inscrire
     resend_confirmation: Envoyer à nouveau les consignes de confirmation
     reset_password: Réinitialiser le mot de passe
-    set_new_password: Établir le nouveau mot de passe
+    set_new_password: Définir le nouveau mot de passe
+  authorize_follow:
+    follow: Suivre
+    prompt_html: 'Vous (<strong>%{self}</strong>) avez demandé à suivre:'
+    title: Suivre %{acct}
+  exports:
+    blocks: Vous bloquez
+    csv: CSV
+    follows: Vous suivez
+    storage: Médias stockés
   generic:
     changes_saved_msg: Les modifications ont été enregistrées avec succès !
     powered_by: propulsé par %{link}
@@ -32,7 +68,24 @@ fr:
     validation_errors:
       one: Quelque chose ne va pas ! Vérifiez l’erreur ci-dessous.
       other: Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous.
+  imports:
+    preface: Vous pouvez importer certaines données comme les personnes que vous suivez ou bloquez sur votre compte sur cette instance à partir de fichiers crées sur une autre instance.
+    success: Vos données ont été importées avec succès et seront traités en temps et en heure
+    types:
+      blocking: Liste d'utilisateurs bloqués
+      following: Liste d'utilisateurs suivis
+    upload: Importer
+  landing_strip_html: <strong>%{name}</strong> utilise <strong>%{domain}</strong>. Vous pouvez le/la suivre et intéragir si vous possédez un compte quelque part dans le "fediverse". Si ce n'est pas le cas, vous pouvez <a href="%{sign_up_path}">en créer un ici</a>.
   notification_mailer:
+    digest:
+      body: 'Voici ce que vous avez raté sur ${instance} depuis votre dernière visite (%{}):'
+      mention: '%{name} vous a mentionné⋅e'
+      new_followers_summary:
+        one: Vous avez un nouvel abonné! Youpi!
+        other: Vous avez %{count} nouveaux abonnés! Incroyable!
+      subject:
+        one: "Une nouvelle notification depuis votre dernière visite \U0001F418"
+        other: "%{count} nouvelles notifications depuis votre dernière visite \U0001F418"
     favourite:
       body: "%{name} a ajouté votre statut à ses favoris :"
       subject: "%{name} a ajouté votre statut à ses favoris"
@@ -48,8 +101,44 @@ fr:
   pagination:
     next: Suivant
     prev: Précédent
+  remote_follow:
+    acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅trice
+    missing_resource: L'URL de redirection n'a pas pu être trouvée
+    proceed: Continuez pour suivre
+    prompt: 'Vous allez suivre :'
   settings:
+    authorized_apps: Applications autorisées
+    back: Retour vers Mastodon
     edit_profile: Modifier le profil
+    export: Export de données
+    import: Import
     preferences: Préférences
+    settings: Réglages
+    two_factor_auth: Identification à deux facteurs (Two-factor auth)
+  statuses:
+    open_in_web: Ouvrir sur le web
+    over_character_limit: limite de caractères dépassée de %{max} caractères
+    show_more: Montrer plus
+    visibilities:
+      private: Abonnés uniquement
+      public: Public
+      unlisted: Public sans être affiché sur le fil public
+  stream_entries:
+    click_to_show: Clic pour afficher
+    reblogged: partagé
+    sensitive_content: Contenu sensible
+  time:
+    formats:
+      default: '%d %b %Y, %H:%M'
+  two_factor_auth:
+    description_html: Si vous activez <strong>l'identification à deux facteurs</strong> vous devrez être en posession de votre téléphone afin de générer un code de connexion.
+    disable: Désactiver
+    enable: Activer
+    instructions_html: "<strong>Scannez ce QR code grâce à Google Authenticator or une application similaire sur votre téléphone</strong>. Désormais, cette application générera des jetons que vous devrez saisir à chaque connexion"
+    plaintext_secret_html: 'Code secret en clair: <samp>%{secret}</samp>'
+    warning: Si vous ne pouvez pas configurer une application d'authentification maintenant, vous devriez cliquer sur "Désactiver" pour ne pas bloquer l'accès à votre compte
+  users:
+    invalid_email: L'adresse e-mail est invalide
+    invalid_otp_token: Le code d'identification à deux facteurs est invalide
   will_paginate:
     page_gap: "&hellip;"
diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml
index 0fcf89140..bcd680722 100644
--- a/config/locales/simple_form.fr.yml
+++ b/config/locales/simple_form.fr.yml
@@ -1,26 +1,42 @@
 ---
 fr:
   simple_form:
+    hints:
+      defaults:
+        avatar: Au format PNG, GIF ou JPG. 2Mo maximum. Sera réduit à 120x120px
+        display_name: 30 caractères maximum
+        header: Au format PNG, GIF ou JPG. 2Mo maximum. Sera réduit à 700x335px
+        locked: Vous devrez approuver chaque abonné⋅e et vos statuts ne s'afficheront qu'à vos abonné⋅es
+        note: 160 caractères maximum
+      imports:
+        data: Un fichier CSV généré par une autre instance de Mastodon
     labels:
       defaults:
         avatar: Image de profil
         confirm_new_password: Confirmation du nouveau mot de passe
         confirm_password: Confirmation du mot de passe
         current_password: Mot de passe actuel
+        data: Données
         display_name: Nom public
         email: Adresse courriel
         header: Image d’en-tête
         locale: Langue
+        locked: Rendre le compte privé
         new_password: Nouveau mot de passe
         note: Présentation
+        otp_attempt: Code d'identification à deux facteurs
         password: Mot de passe
+        setting_default_privacy: Confidentialité des statuts
+        type: Type d'import
         username: Identifiant
       interactions:
         must_be_follower: Masquer les notifications des personnes qui ne vous suivent pas
         must_be_following: Masquer les notifications des personnes que vous ne suivez pas
       notification_emails:
+        digest: Envoyer des emails récapitulatifs
         favourite: Envoyer un courriel lorsque quelqu’un ajoute mes statut à ses favoris
         follow: Envoyer un courriel lorsque quelqu’un me suit
+        follow_request: Envoyer un courriel lorsque quelqu'un demande à me suivre
         mention: Envoyer un courriel lorsque quelqu’un me mentionne
         reblog: Envoyer un courriel lorsque quelqu’un partage mes statuts
     'no': Non
diff --git a/streaming/index.js b/streaming/index.js
index 0f838e411..7edf6203f 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -215,8 +215,11 @@ const streamHttpEnd = req => (id, listener) => {
 
 // Setup stream output to WebSockets
 const streamToWs = (req, ws) => {
+  const heartbeat = setInterval(() => ws.ping(), 15000)
+
   ws.on('close', () => {
     log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
+    clearInterval(heartbeat)
   })
 
   return (event, payload) => {
@@ -234,6 +237,10 @@ const streamWsEnd = ws => (id, listener) => {
   ws.on('close', () => {
     unsubscribe(id, listener)
   })
+
+  ws.on('error', e => {
+    unsubscribe(id, listener)
+  })
 }
 
 app.use(setRequestId)