From 8e37b404a1451d9b73c368de1d209ba6b0d40cc6 Mon Sep 17 00:00:00 2001
From: Benoit Lavenier <benoit.lavenier@e-is.pro>
Date: Thu, 10 Aug 2023 19:02:04 +0200
Subject: [PATCH] enh(home): Enable feeds to redirect to another json, using
 next_url

---
 app/config.json                        |  16 +-
 doc/feed/draft/feed-fr.json            |  10 +-
 doc/feed/feed-en-GB.json               |   9 +
 doc/feed/feed-en.json                  |   9 +
 doc/feed/feed-es-ES.json               |   9 +
 doc/feed/feed-fr-FR.json               |   7 +-
 scss/ionic.app.scss                    |  56 +++-
 www/index.html                         |   1 +
 www/js/config.js                       |  19 +-
 www/js/controllers.js                  |  30 +-
 www/js/controllers/feed-controllers.js | 365 +++++++++++++++++++++++++
 www/js/controllers/home-controllers.js | 195 +------------
 www/js/services/settings-services.js   |   2 +-
 www/templates/feed/feed.html           |  62 +++++
 www/templates/home/home.html           |  63 +----
 15 files changed, 575 insertions(+), 278 deletions(-)
 create mode 100644 doc/feed/feed-en-GB.json
 create mode 100644 doc/feed/feed-en.json
 create mode 100644 doc/feed/feed-es-ES.json
 create mode 100644 www/js/controllers/feed-controllers.js
 create mode 100644 www/templates/feed/feed.html

diff --git a/app/config.json b/app/config.json
index 0c14d7293..2cb8e7094 100644
--- a/app/config.json
+++ b/app/config.json
@@ -35,12 +35,20 @@
     },
     "feed": {
       "jsonFeed": {
-        "fr-FR": "https://forum.monnaie-libre.fr/c/tools/cesium/110.json",
-        "en": "https://forum.monnaie-libre.fr/t/actualites-fr/28088.json",
-        "es": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-es.json"
+        "ca": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-ca.json",
+        "de-DE": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-de-DE.json",
+        "en": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en.json",
+        "en-GB": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en-GB.json",
+        "eo-EO": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-eo-EO.json",
+        "es-ES": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-es-ES.json",
+        "fr-FR": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-fr-FR.json",
+        "it-IT": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-it-IT.json",
+        "nl-NL": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-nl-NL.json",
+        "pt-PT": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-pt-PT.json"
       },
       "maxContentLength": 1300,
-      "maxAgeInMonths": 3
+      "maxAgeInMonths": 3,
+      "maxCount": 3
     },
     "fallbackNodes": [
       {
diff --git a/doc/feed/draft/feed-fr.json b/doc/feed/draft/feed-fr.json
index 49f87cce0..0929a426b 100644
--- a/doc/feed/draft/feed-fr.json
+++ b/doc/feed/draft/feed-fr.json
@@ -1,23 +1,23 @@
 {
-  "version": "https://jsonfeed.org/version/1",
+  "version": "https://jsonfeed.org/version/1.1",
   "user_comment": "Feed that use the jsonFeed format (see jsonFeed.org for details).",
   "title": "Actualités",
   "description": "Actualités de Cesium",
   "home_page_url": "https://forum.monnaie-libre.fr/tag/cesium",
   "feed_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-fr.json",
-  "author": {
+  "authors": [{
     "name": "Benoit Lavenier",
     "url": "@BenoitLavenier",
     "avatar": "https://g1.data.duniter.fr/user/profile/38MEAZN68Pz1DTvT3tqgxx4yQP6snJCQhPqEFxbDk4aE/_image/avatar.png"
-  },
+  }],
   "items": [
     {
       "title": "Cesium évolue ! Aïe, ça va piquer mais…",
-      "author": {
+      "authors": [{
         "name": "Elois",
         "url": "@elois",
         "avatar": "https://forum.monnaie-libre.fr/user_avatar/forum.monnaie-libre.fr/elois/45/185_2.png"
-      },
+      }],
       "date_published": "2020-03-07T19:42:00+01:00",
       "id": "https://forum.monnaie-libre.fr/t/cesium-evolue-aie-ca-va-piquer-mais/10015",
       "url": "https://forum.monnaie-libre.fr/t/cesium-evolue-aie-ca-va-piquer-mais/10015",
diff --git a/doc/feed/feed-en-GB.json b/doc/feed/feed-en-GB.json
new file mode 100644
index 000000000..babed8914
--- /dev/null
+++ b/doc/feed/feed-en-GB.json
@@ -0,0 +1,9 @@
+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "News",
+  "user_comment": "Ce fichier redirige vers le forum Duniter (via 'next_url')",
+  "feed_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en-GB.json",
+
+  "home_page_url": "https://forum.duniter.org/t/new-version-of-cesium-en/11460",
+  "next_url": "https://forum.duniter.org/t/new-version-of-cesium-en/11460.json"
+}
diff --git a/doc/feed/feed-en.json b/doc/feed/feed-en.json
new file mode 100644
index 000000000..933843344
--- /dev/null
+++ b/doc/feed/feed-en.json
@@ -0,0 +1,9 @@
+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "News",
+  "user_comment": "Ce fichier redirige vers le forum Duniter (via 'next_url')",
+  "feed_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en.json",
+
+  "home_page_url": "https://forum.duniter.org/t/new-version-of-cesium-en/11460",
+  "next_url": "https://forum.duniter.org/t/new-version-of-cesium-en/11460.json"
+}
diff --git a/doc/feed/feed-es-ES.json b/doc/feed/feed-es-ES.json
new file mode 100644
index 000000000..8c6c2a7da
--- /dev/null
+++ b/doc/feed/feed-es-ES.json
@@ -0,0 +1,9 @@
+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "Noticias",
+  "user_comment": "Ce fichier redirige vers le forum Duniter (via 'next_url')",
+  "feed_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-es-ES.json",
+
+  "home_page_url": "https://forum.duniter.org/t/nueva-version-de-cesium-es/11459",
+  "next_url": "https://forum.duniter.org/t/nueva-version-de-cesium-es/11459.json"
+}
diff --git a/doc/feed/feed-fr-FR.json b/doc/feed/feed-fr-FR.json
index d6a2e32ec..469a14e30 100644
--- a/doc/feed/feed-fr-FR.json
+++ b/doc/feed/feed-fr-FR.json
@@ -1,10 +1,9 @@
 {
   "version": "https://jsonfeed.org/version/1.1",
   "title": "Actualités",
-  "user_comment": "Ce fichier permet la redirection vers le forum Duniter, via 'next_url'",
-
-  "home_page_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-fr-FR",
+  "user_comment": "Ce fichier redirige vers le forum Duniter (via 'next_url')",
   "feed_url": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-fr-FR.json",
 
-  "next_url": "https://forum.duniter.org/c/clients/cesium/22.json"
+  "home_page_url": "https://forum.duniter.org/t/nouvelle-version-de-cesium-fr/11458/2",
+  "next_url": "https://forum.duniter.org/t/nouvelle-version-de-cesium-fr/11458/2.json"
 }
diff --git a/scss/ionic.app.scss b/scss/ionic.app.scss
index 217a7abdf..aa39c4bfb 100644
--- a/scss/ionic.app.scss
+++ b/scss/ionic.app.scss
@@ -2756,6 +2756,19 @@ div[drop-zone]:hover {
     color: white !important;
   }
 
+  h1, .h1 {
+    font-size: 18pt;
+  }
+  h2, .h2 {
+    font-size: 16pt;
+  }
+  h3, .h3 {
+    font-size: 14pt;
+  }
+  h4, .h4 {
+    font-size: 12pt;
+  }
+
   ul {
     list-style: unset;
     padding-left: 40px;
@@ -2779,24 +2792,63 @@ div[drop-zone]:hover {
       border: 1px solid $positive;
     }
     .avatar-left-padding {
-      padding-left: 30px;
+      padding-inline-start: 30px;
     }
   }
 
   .feed-title,
   .card .title {
     margin-top: 5px;
-    font-size: 18pt;
     a {
       color: white !important;;
     }
   }
 
+  .tags {
+    font-size: small;
+    color: grey !important;
+  }
+
   .feed-content,
   .card .content {
     text-align: start;
     color: lightgrey !important;
+
+    img.emoji {
+      display: unset;
+      max-width: 12px;
+      max-height: 12px;
+    }
+
+    blockquote p {
+      font-weight: unset;
+      font-size: unset;
+      line-height: unset;
+    }
+
+    blockquote, .quote .title {
+      padding: 5px 10px;
+      border-left: 5px solid gray;
+      margin: 0 0 10px 0;
+      font-size: 10pt;
+    }
+    .quote .title {
+      margin: 0;
+      font-size: 10pt;
+      padding-left: 32px;
+
+      .avatar {
+        position: absolute;
+        margin-top: -1px;
+        margin-left: -3px;
+        height: 20px;
+        width: 20px;
+        border: 1px solid $positive;
+      }
+
+    }
   }
+
   .feed-footer,
   .card .footer {
     a:hover {
diff --git a/www/index.html b/www/index.html
index 05b20173d..59b00cdad 100644
--- a/www/index.html
+++ b/www/index.html
@@ -160,6 +160,7 @@
     <!-- controllers -->
     <script src="dist/dist_js/app/controllers/app-controllers.js"></script>
     <script src="dist/dist_js/app/controllers/home-controllers.js"></script>
+    <script src="dist/dist_js/app/controllers/feed-controllers.js"></script>
     <script src="dist/dist_js/app/controllers/join-controllers.js"></script>
     <script src="dist/dist_js/app/controllers/login-controllers.js"></script>
     <script src="dist/dist_js/app/controllers/help-controllers.js"></script>
diff --git a/www/js/config.js b/www/js/config.js
index 86f2af399..3885ccfd5 100644
--- a/www/js/config.js
+++ b/www/js/config.js
@@ -43,14 +43,21 @@ angular.module("cesium.config", [])
 		"de-DE": "license/license_g1-de-DE"
 	},
 	"feed": {
-		"jsonFeed": {
-			/*"fr-FR": "https://forum.monnaie-libre.fr/t/actualites-test/28088/2.json",*/
-      "fr-FR": "https://forum.monnaie-libre.fr/c/tools/cesium/110.json",
-			"en": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en.json",
-			"es": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-es.json"
+    "jsonFeed": {
+      "ca": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-ca.json",
+      "de-DE": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-de-DE.json",
+      "en": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en.json",
+      "en-GB": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-en-GB.json",
+      "eo-EO": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-eo-EO.json",
+      "es-ES": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-es-ES.json",
+      "fr-FR": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-fr-FR.json",
+      "it-IT": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-it-IT.json",
+      "nl-NL": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-nl-NL.json",
+      "pt-PT": "https://raw.githubusercontent.com/duniter/cesium/master/doc/feed/feed-pt-PT.json"
 		},
 		"maxContentLength": 1300,
-    "maxAgeInMonths": 15
+    "maxAgeInMonths": -1,
+    "maxCount": 3
 	},
 	"fallbackNodes": [
     {
diff --git a/www/js/controllers.js b/www/js/controllers.js
index b36e1e613..b4812bce0 100644
--- a/www/js/controllers.js
+++ b/www/js/controllers.js
@@ -1,17 +1,17 @@
 
 angular.module('cesium.controllers', [
-    'cesium.app.controllers',
-    'cesium.home.controllers',
-    'cesium.join.controllers',
-    'cesium.login.controllers',
-    'cesium.help.controllers',
-    'cesium.wallet.controllers',
-    'cesium.wallets.controllers',
-    'cesium.currency.controllers',
-    'cesium.wot.controllers',
-    'cesium.transfer.controllers',
-    'cesium.settings.controllers',
-    'cesium.network.controllers',
-    'cesium.blockchain.controllers'
-  ])
-;
+  'cesium.app.controllers',
+  'cesium.home.controllers',
+  'cesium.feed.controllers',
+  'cesium.join.controllers',
+  'cesium.login.controllers',
+  'cesium.help.controllers',
+  'cesium.wallet.controllers',
+  'cesium.wallets.controllers',
+  'cesium.currency.controllers',
+  'cesium.wot.controllers',
+  'cesium.transfer.controllers',
+  'cesium.settings.controllers',
+  'cesium.network.controllers',
+  'cesium.blockchain.controllers'
+]);
diff --git a/www/js/controllers/feed-controllers.js b/www/js/controllers/feed-controllers.js
new file mode 100644
index 000000000..6907ff3b1
--- /dev/null
+++ b/www/js/controllers/feed-controllers.js
@@ -0,0 +1,365 @@
+angular.module('cesium.feed.controllers', ['cesium.services'])
+
+
+  .controller('FeedCtrl', FeedController)
+;
+
+function FeedController($scope, $timeout, $http, $translate, $q, csConfig, csHttp, csCache, csSettings) {
+  'ngInject';
+
+  $scope.search = {
+    loading: true,
+    maxCount: (csConfig.feed && csConfig.feed.maxCount) || 3, // 3 month by default
+    maxContentLength: (csConfig.feed && csConfig.feed.maxContentLength) || 1300,
+    maxAgeInMonths: (csConfig.feed && csConfig.feed.maxAgeInMonths) || 3, // 3 month by default
+    minTime: undefined, // unix time
+    loadCount: 0,
+    maxLoadCount: 2 // = 1 redirection max
+  };
+
+  $scope.enter = function(e, state) {
+    // Wait platform to be ready
+    csSettings.ready()
+      .then(function() {
+        return $scope.load();
+      })
+      .catch(function() {
+        // Continue
+        return null;
+      })
+      .then(function(feed) {
+        // Clean title (remove duplicated titles)
+        $scope.prepareJsonFeed(feed);
+
+        $scope.loading = false;
+        $scope.feed = feed;
+        var showFeed = feed && feed.items && feed.items.length > 0 || false;
+        $scope.$parent.toggleFeed(showFeed);
+      });
+  };
+
+  $scope.$on('$ionicParentView.enter', $scope.enter);
+
+  $scope.load = function() {
+    var feedUrl = csSettings.getFeedUrl();
+    if (!feedUrl || typeof feedUrl !== 'string') return; // Skip
+
+    // Min unix time, to exclude old topics
+    var maxAgeInMonths = $scope.search.maxAgeInMonths;
+    var minDate = maxAgeInMonths > 0 ? moment().subtract(maxAgeInMonths, 'month').startOf('day').utc() : undefined;
+    $scope.search.minTime = minDate && minDate.unix();
+
+    var now = Date.now();
+    console.debug("[feed] Loading from {url: {0}, minTime: '{1}'}".format(feedUrl, minDate && minDate.toISOString() || 'all'));
+
+    return $scope.getJsonFeed(feedUrl)
+      .then(function (feed) {
+        console.debug('[feed] {0} items loaded in {0}ms'.format(feed && feed.items && feed.items.length || 0, Date.now() - now));
+
+        if (!feed || !feed.items) return null; // skip
+
+        return feed;
+      })
+      .catch(function(err) {
+        console.error('[feed] Failed to load.', err);
+        return null;
+      });
+  };
+
+  $scope.getJsonFeed = function(feedUrl, maxCount) {
+    var locale = $translate.use();
+    maxCount = maxCount || $scope.search.maxCount;
+    var minTime = $scope.search.minTime;
+
+    $scope.search.loadCount++;
+    return $scope.getJson(feedUrl)
+      .then(function(json) {
+
+        // Detect Discourse category, then convert
+        if ($scope.isDiscourseCategory(json)) {
+          return $scope.parseDiscourseCategory(feedUrl, json);
+        }
+
+        // Detect Discourse topic, then convert
+        else if ($scope.isDiscourseTopic(json)) {
+          return $scope.parseDiscourseTopic(feedUrl, json);
+        }
+
+        return json;
+      })
+      .then(function(feed) {
+        if (!feed || !feed.items && !feed.next_url) return null; // skip
+
+        // SKip if incompatible language
+        if (feed.language && !$scope.isCompatibleLanguage(locale, feed.language)) {
+          console.debug("[feed] Skip feed item '{0}' - Expected language: '{1}', actual: '{2}'".format(feed.title, locale, feed.language));
+          return null;
+        }
+
+        feed.items = (feed.items || []).reduce(function (res, item) {
+
+          // Skip if empty (missing title and content)
+          if ($scope.isEmptyFeedItem(item)) return res;
+
+          item = $scope.prepareJsonFeedItem(item, feed);
+
+          // Skip if too old items
+          if (minTime > 0 && item.creationTime && (item.creationTime < minTime)) return res;
+
+          // Skip if not same language
+          if (item.language && !$scope.isCompatibleLanguage(locale, item.language)) {
+            console.debug("[feed] Feed item '{0}' EXCLUDED - expected locale: {1}, actual language: {2}".format(item.title || feed.title, locale, item.language));
+            return res;
+          }
+
+          return res.concat(item);
+        }, []);
+
+        return feed;
+      })
+      .then(function(feed) {
+        if (!feed) return null; // skip
+        feed.items = feed.items || [];
+
+        // Slice to keep last (more recent) items
+        if (feed.items.length > maxCount) {
+          feed.items = feed.items.slice(feed.items.length - maxCount);
+          return feed;
+        }
+
+        // Not enough items: try to fetch more
+        var canFetchMore = feed.next_url && feed.next_url !== feedUrl && $scope.search.loadCount < $scope.search.maxLoadCount;
+        if (canFetchMore && feed.items.length < maxCount) {
+
+          console.debug("[feed] Loading from {next_url: '{0}'}".format(feed.next_url));
+
+          // Fetch more
+          return $scope.getJsonFeed(feed.next_url, maxCount - feed.items.length)
+            .then(function(moreFeed) {
+              // Append new items
+              if (moreFeed && moreFeed.items && moreFeed.items.length) {
+                feed.items = feed.items.concat(moreFeed.items.slice(0, maxCount - feed.items.length));
+              }
+
+              return feed;
+            });
+        }
+
+        return feed;
+      });
+  }
+
+  $scope.getJson = function(url) {
+    return $q(function(resolve, reject) {
+      $http.get(url, {
+        timeout: csConfig.timeout,
+        responseType: 'json',
+        cache: csCache.get('csFeed-', csCache.constants.LONG)
+      })
+      .success(resolve)
+      .error(reject)
+    });
+  };
+
+  $scope.isEmptyFeedItem = function(item) {
+    return (!item || (!item.title && !item.content_text && !item.content_html));
+  };
+
+  $scope.prepareJsonFeedItem = function(item, feed) {
+    if ($scope.isEmptyFeedItem(item)) throw Error('Empty feed item')
+
+    var maxContentLength = $scope.search.maxContentLength;
+
+    // Convert UTC time
+    if (item.date_published) {
+      item.creationTime = moment.utc(item.date_published).unix();
+    }
+    if (item.date_modified) {
+      item.time = moment.utc(item.date_modified).unix();
+    }
+
+    // Convert content to HTML
+    if (item.content_html) {
+      item.content = item.content_html;
+    }
+    else {
+      item.content = (item.content_text||'').replace(/\n/g, '<br/>');
+    }
+
+    // Trunc content, if need
+    if (maxContentLength > 0 && item.content && item.content.length > maxContentLength) {
+      var endIndex = Math.max(item.content.lastIndexOf(" ", maxContentLength), item.content.lastIndexOf("<", maxContentLength));
+      item.content = item.content.substring(0, endIndex) + ' (...)';
+      item.truncated = true;
+    }
+
+    // If author is missing, copy the main author
+    item.authors = item.authors || feed.authors;
+
+    return item;
+  }
+
+
+  /**
+   * Prepare feed (e.g. clean duplicated title, when feed URL is a discourse topic, all item will have the same title)
+   * @param feed
+   * @returns {*}
+   */
+  $scope.prepareJsonFeed = function(feed) {
+    if (!feed || !feed.items) return feed;
+
+    _.forEach(feed.items, function(item, index) {
+      if (item.title && index > 0 && (item.title === feed.items[0].title)) {
+        delete item.title;
+      }
+    });
+    return feed;
+  };
+
+  $scope.isDiscourseCategory = function(category) {
+    return category && category.topic_list && Array.isArray(category.topic_list.topics);
+  }
+
+  $scope.parseDiscourseCategory = function(url, category, locale) {
+    // Make sure this is a valid topic
+    if (!$scope.isDiscourseCategory(category)) throw new Error('Not a discourse category');
+
+    locale = locale || $translate.use();
+    var uri = csHttp.uri.parse(url);
+    var baseUrl = uri.protocol + '//' + uri.host + (uri.port != 443 && uri.port != 80 ? uri.port : '');
+    var pageUrl = baseUrl + category.topic_list.more_topics_url.replace(/\?page=[0-9]+/, '');
+    var feed = {
+      version: "https://jsonfeed.org/version/1.1", // fixed value
+      home_page_url: pageUrl,
+      feed_url: url,
+      title: 'HOME.FEEDS_TITLE'
+    };
+
+    return $q.all(
+      category.topic_list.topics.reduce(function(res, topic) {
+        if (!topic.pinned || !topic.visible) return res; // Skip not pinned topic
+
+        // Exclude category description (=tag 'about-category')
+        if (topic.tags && topic.tags.includes('about-category')) return res;
+
+        // Skip if not expected language
+        var language = $scope.getLanguageFromTitle(topic.title);
+        if (!$scope.isCompatibleLanguage(locale, language)) return res;
+
+        var topicUrl = [baseUrl, 't', topic.slug, topic.id].join('/') + '.json';
+        return res.concat($scope.getJson(topicUrl))
+      }, [])
+    ).then(function(topics) {
+      feed.items = topics.reduce(function(res, topic) {
+        if (!$scope.isDiscourseTopic(topic)) return res; // Not a topic: skip
+        var feedTopic = $scope.parseDiscourseTopic(baseUrl, topic, feed);
+
+        if (!feedTopic.items || !feedTopic.items.length) return res; // Topic is empty: skip
+        return res.concat(feedTopic.items[0]);
+      }, []);
+      return feed;
+    });
+  }
+
+  $scope.isDiscourseTopic = function(topic) {
+    return topic && topic.title && topic.post_stream && Array.isArray(topic.post_stream.posts);
+  }
+
+  $scope.parseDiscourseTopic = function(url, topic, feed) {
+    // Make sure this is a valid topic
+    if (!$scope.isDiscourseTopic(topic)) throw new Error('Not a discourse topic');
+
+    var uri = csHttp.uri.parse(url);
+    var baseUrl = uri.protocol + '//' + uri.host + (uri.port != 443 && uri.port != 80 ? uri.port : '');
+
+    // Clean title (e.g. remove '(fr)' or '(en)' or '(es)')
+    var title = $scope.cleanTitle(topic.title);
+    var language = $scope.getLanguageFromTitle(topic.title);
+
+    // Prepare root feed, if not yet exists
+    feed = feed || {
+      version: "https://jsonfeed.org/version/1.1", // fixed value
+      home_page_url: [baseUrl, 't', topic.slug, topic.id].join('/'),
+      feed_url: url,
+      title: 'HOME.FEEDS_TITLE',
+      language: language
+    };
+    feed.language = feed.language || language;
+
+    feed.items = topic.post_stream.posts.reduce(function(res, post) {
+      if (!post.cooked || post.cooked.trim() === '') return res; // Skip if empty
+
+      // SKip if hidden, or deleted post
+      if (post.hidden || post.deleted_at) return res;
+
+      var author = {
+        name: post.display_username,
+        url: [baseUrl, 'u', post.username].join('/'),
+        avatar: post.avatar_template ? (baseUrl + post.avatar_template.replace('{size}', '60')) : undefined
+      }
+
+      // Try to resolve author pubkey, to replace author url by a link to wot identity
+      var developer = _.find(csConfig.developers || [], function(developer) {
+        return developer.name && (
+          (post.display_username && developer.name.toLowerCase() === post.display_username.toLowerCase()) ||
+          (post.username && developer.name.toLowerCase() === post.username.toLowerCase())
+        );
+      });
+      if (developer && developer.pubkey) {
+        author.url = '@' + developer.pubkey;
+      }
+
+      // Fill parent feed defaults
+      feed.authors = feed.authors || [author];
+
+      return res.concat({
+        id: post.id,
+        url: [baseUrl, 't', post.topic_slug, post.topic_id, post.post_number].join('/'),
+        title: title,
+        date_published: post.created_at,
+        date_modified: post.updated_at,
+        content_html: post.cooked,
+        authors: [author],
+        language: language,
+        tags: post.tags || topic.tags
+      });
+    }, []);
+
+    return feed;
+  };
+
+  /**
+   * Clean a title : remove locale string at the end (e.g. '(fr)' or '(en)' or '(es)')
+   * @param title
+   */
+  $scope.cleanTitle = function(title) {
+    if (!title) return undefined;
+    return title.replace(/\s\([a-z]{2}(:?-[A-Z]{2})?\)$/, '');
+  }
+
+  /**
+   * Clean a title : remove locale string at the end (e.g. '(fr)' or '(en)' or '(es)')
+   * @param title
+   */
+  $scope.getLanguageFromTitle = function(title) {
+    if (!title) return undefined;
+    var matches = /\s\(([a-z]{2}(:?-[A-Z]{2})?)\)$/.exec(title);
+    return matches && matches[1];
+  }
+
+  $scope.isCompatibleLanguage = function(expectedLocale, language) {
+    if (!expectedLocale || !language || expectedLocale === language) return true;
+
+    // Extract the language from the locale, then compare
+    // E.g. 'fr-FR' => 'fr'
+    const expectedLanguage = expectedLocale.split('-', 2)[0];
+
+    return expectedLanguage.toLowerCase() === language.toLowerCase();
+  }
+
+  csSettings.api.locale.on.changed($scope, function() {
+    if ($scope.loading) return;
+    console.debug("[feed] Locale changed. Reload feed...");
+    $scope.enter();
+  });
+}
diff --git a/www/js/controllers/home-controllers.js b/www/js/controllers/home-controllers.js
index 27529ebc0..981a87c26 100644
--- a/www/js/controllers/home-controllers.js
+++ b/www/js/controllers/home-controllers.js
@@ -33,6 +33,7 @@ function HomeController($scope, $state, $timeout, $ionicHistory, $translate, $ht
   $scope.locales = angular.copy(csSettings.locales);
   $scope.smallscreen = UIUtils.screen.isSmall();
   $scope.showInstallHelp = false;
+  $scope.showFeed = false;
 
   $scope.enter = function(e, state) {
     if (ionic.Platform.isIOS() && window.StatusBar) {
@@ -59,15 +60,12 @@ function HomeController($scope, $state, $timeout, $ionicHistory, $translate, $ht
 
       // Wait platform to be ready
       csPlatform.ready()
-        .then(function() {
-          $scope.loading = false;
-          $scope.loadingMessage = '';
-          $scope.loadFeeds();
-        })
         .catch(function(err) {
           $scope.node =  csCurrency.data.node;
-          $scope.loading = false;
           $scope.error = err;
+        })
+        .then(function() {
+          $scope.loading = false;
           $scope.loadingMessage = '';
         });
     }
@@ -81,185 +79,6 @@ function HomeController($scope, $state, $timeout, $ionicHistory, $translate, $ht
     $timeout($scope.enter, 200);
   };
 
-  $scope.loadFeeds = function() {
-    var feedUrl = csSettings.getFeedUrl();
-    if (!feedUrl || typeof feedUrl !== 'string') return; // Skip
-
-    var maxContentLength = (csConfig.feed && csConfig.feed.maxContentLength) || 650;
-    var maxAgeInMonths = (csConfig.feed && csConfig.feed.maxAgeInMonths) || 3; // 3 month by default
-
-    // Min unix time, to exclude old topics
-    var minDate = moment().subtract(maxAgeInMonths, 'month').utc();
-    var minTime = minDate.unix();
-    var now = Date.now();
-    console.debug("[home] Loading recent feeds at {url: {0}, minTime: '{1}'}".format(feedUrl, minDate.toISOString()));
-
-    $scope.getJson(feedUrl)
-      .then(function(feed) {
-        console.debug('[home] Feeds loaded in {0}ms'.format(Date.now() - now));
-
-        // Detect Discourse category, then convert
-        if ($scope.isDiscourseCategory(feed)) {
-          return $scope.parseDiscourseCategory(feedUrl, feed);
-        }
-
-        // Detect Discourse topic, then convert
-        else if ($scope.isDiscourseTopic(feed)) {
-          return $scope.parseDiscourseTopic(feedUrl, feed);
-        }
-
-        return feed;
-      })
-      .then(function(feed) {
-        if (!feed || !feed.items) return; // skip
-
-        feed.items = feed.items.reduce(function(res, item) {
-          if (!item || (!item.title && !item.content_text && !item.content_html)) return res; // Skip
-
-          // Convert UTC time
-          if (item.date_published) {
-            item.creationTime = moment.utc(item.date_published).unix();
-          }
-          if (item.date_modified) {
-            item.time = moment.utc(item.date_modified).unix();
-          }
-
-          // Skip if too old items
-          if (item.creationTime && (item.creationTime < minTime)) return res;
-
-          // Convert content to HTML
-          if (item.content_html) {
-            item.content = item.content_html;
-          }
-          else {
-            item.content = (item.content_text||'').replace(/\n/g, '<br/>');
-          }
-
-          // Trunc content, if need
-          if (maxContentLength !== -1 && item.content && item.content.length > maxContentLength) {
-            var endIndex = Math.max(item.content.lastIndexOf(" ", maxContentLength), item.content.lastIndexOf("<", maxContentLength));
-            item.content = item.content.substring(0, endIndex) + ' (...)';
-            item.truncated = true;
-          }
-
-          // If author is missing, copy the main author
-          item.author = item.author || feed.author;
-
-          return res.concat(item);
-        }, []);
-
-        if (!feed.items.length) return; // No items: skip
-
-        $scope.feed = feed;
-      })
-      .catch(function(err) {
-        console.error('[home] Failed to load feeds.', err);
-        $scope.feed = null;
-      });
-  };
-
-  $scope.getJson = function(url) {
-    return $q(function(resolve, reject) {
-      $http.get(url, {
-        timeout: csConfig.timeout,
-        responseType: 'json',
-        cache: csCache.get(null, csCache.constants.LONG)
-      })
-      .success(resolve)
-      .error(reject)
-    });
-  };
-
-  $scope.isDiscourseCategory = function(category) {
-    return category && category.topic_list && Array.isArray(category.topic_list.topics);
-  }
-
-  $scope.parseDiscourseCategory = function(url, category) {
-    // Make sure this is a valid topic
-    if (!$scope.isDiscourseCategory(category)) throw new Error('Not a discourse category');
-
-    var uri = csHttp.uri.parse(url);
-    var baseUrl = uri.protocol + '//' + uri.host + (uri.port != 443 && uri.port != 80 ? uri.port : '');
-    var feed = {
-      version: "https://jsonfeed.org/version/1", // fixed value
-      home_page_url: category.topic_list.more_topics_url.replace(/\?page=[0-9]+/, ''),
-      feed_url: url,
-      title: 'HOME.FEEDS_TITLE' // FIXME: how get the category title ?
-    };
-
-    return $q.all(
-      category.topic_list.topics.reduce(function(res, topic) {
-        if (!topic.pinned) return res; // Skip not pinned topic
-
-        var topicUrl = [baseUrl, 't', topic.slug, topic.id].join('/') + '.json';
-        return res.concat($scope.getJson(topicUrl))
-      }, [])
-    ).then(function(topics) {
-      feed.items = topics.reduce(function(res, topic) {
-        if (!$scope.isDiscourseTopic(topic)) return res; // Not a topic: skip
-        var feedTopic = $scope.parseDiscourseTopic(baseUrl, topic, feed);
-
-        if (!feedTopic.items || !feedTopic.items.length) return res; // Topic is empty: skip
-        return res.concat(feedTopic.items[0]);
-      }, []);
-      return feed;
-    });
-  }
-
-  $scope.isDiscourseTopic = function(topic) {
-    return topic && topic.title && topic.post_stream && Array.isArray(topic.post_stream.posts);
-  }
-
-  $scope.parseDiscourseTopic = function(url, topic, feed) {
-    // Make sure this is a valid topic
-    if (!$scope.isDiscourseTopic(topic)) throw new Error('Not a discourse topic');
-
-    var uri = csHttp.uri.parse(url);
-    var baseUrl = uri.protocol + '//' + uri.host + (uri.port != 443 && uri.port != 80 ? uri.port : '');
-
-    // Prepare root feed, if not yet exists
-    feed = feed || {
-      version: "https://jsonfeed.org/version/1", // fixed value
-      home_page_url: [baseUrl, 't', topic.slug, topic.id].join('/'),
-      feed_url: url,
-      title: topic.title
-    };
-
-    feed.items = topic.post_stream.posts.reduce(function(res, post) {
-      if (!post.cooked || post.cooked.trim() === '') return res; // Skip if empty
-
-      var author = {
-        name: post.display_username,
-        url: [baseUrl, 'u', post.username].join('/'),
-        avatar: post.avatar_template ? (baseUrl + post.avatar_template.replace('{size}', '60')) : undefined
-      }
-
-      // Try to resolve author pubkey, to replace author url by a link to wot identity
-      var developer = _.find(csConfig.developers || [], function(developer) {
-        return developer.name && (
-          (post.display_username && developer.name.toLowerCase() === post.display_username.toLowerCase()) ||
-          (post.username && developer.name.toLowerCase() === post.username.toLowerCase())
-        );
-      });
-      if (developer && developer.pubkey) {
-        author.url = '@' + developer.pubkey;
-      }
-
-      return res.concat({
-        id: post.id,
-        url: [baseUrl, 't', post.topic_slug, post.topic_id, post.post_number].join('/'),
-        title: feed.title === topic.title ? '' : topic.title, // Only if different
-        date_published: post.created_at,
-        date_modified: post.updated_at,
-        content_html: post.cooked,
-        author: author,
-        tags: post.tags || topic.tags
-      });
-    }, []);
-
-    return feed;
-  };
-
   /**
    * Catch click for quick fix
    * @param action
@@ -278,9 +97,13 @@ function HomeController($scope, $state, $timeout, $ionicHistory, $translate, $ht
     $scope.hideLocalesPopover();
     csSettings.data.locale = _.findWhere($scope.locales, {id: langKey});
     csSettings.store();
-    $scope.loadFeeds();
   };
 
+  $scope.toggleFeed = function(show) {
+    $scope.showFeed = (show !== undefined) ? show : !$scope.showFeed
+    $scope.$broadcast('$$rebind::feed'); // force rebind feed
+  }
+
   /* -- show/hide locales popup -- */
 
   $scope.showLocalesPopover = function(event) {
diff --git a/www/js/services/settings-services.js b/www/js/services/settings-services.js
index 84594f2f4..7a37c0de4 100644
--- a/www/js/services/settings-services.js
+++ b/www/js/services/settings-services.js
@@ -308,7 +308,7 @@ angular.module('cesium.settings.services', ['ngApi', 'cesium.config'])
   function getFeedUrl() {
     var locale = data.locale && data.locale.id || csConfig.defaultLanguage || 'en';
     return (csConfig.feed && csConfig.feed.jsonFeed) ?
-      (csConfig.feed.jsonFeed[locale] ? csConfig.feed.jsonFeed[locale] : defaultSettings.feed.jsonFeed[csConfig.defaultLanguage || 'en'] || csConfig.feed) : undefined;
+      (csConfig.feed.jsonFeed[locale] ? csConfig.feed.jsonFeed[locale] : defaultSettings.feed.jsonFeed[csConfig.defaultLanguage || 'en'] || csConfig.feed.jsonFeed) : undefined;
   }
 
   // Detect locale successful changes, then apply to vendor libs
diff --git a/www/templates/feed/feed.html b/www/templates/feed/feed.html
new file mode 100644
index 000000000..ec30b864e
--- /dev/null
+++ b/www/templates/feed/feed.html
@@ -0,0 +1,62 @@
+
+  <!-- feed -->
+  <div class="feed padding-horizontal no-padding-xs padding-top"
+       ng-controller="FeedCtrl">
+    <h3 class="padding-left">
+      <i class="icon ion-speakerphone"></i>
+      {{feed.title|translate}}
+      <small><a ng-click="openLink($event, feed.home_page_url)" class="gray">
+        <span translate>HOME.SHOW_ALL_FEED</span>
+        <i class="icon ion-chevron-right"></i>
+      </a></small>
+    </h3>
+
+    <!-- card for each item -->
+    <div ng-repeat="item in feed.items" class="card padding no-margin-xs">
+
+      <div class="header">
+
+        <!-- authors -->
+        <ng-repeat ng-repeat="author in item.authors track by author.name">
+          <i ng-if="author.avatar" class="avatar" style="background-image: url({{author.avatar}});"></i>
+          <a ng-class="{'avatar-left-padding': author.avatar}" class="author"
+             ng-click="author.url && openLink($event, author.url)">
+            {{author.name}}
+          </a>
+        </ng-repeat>
+
+        <!-- time -->
+        <a ng-if="item.time"
+           title="{{item.time|formatDate}}"
+           ng-click="openLink($event, item.url)"
+           class="item-note ">
+          <small><i class="icon ion-clock"></i>&nbsp;{{item.creationTime|formatFromNow}}</small>
+        </a>
+      </div>
+
+      <!-- title -->
+      <h2 class="title feed-title">
+        <a ng-click="openLink($event, item.url)">{{item.title}}</a>
+
+        <!-- tags -->
+        <div class="tags" ng-if="item.tags && item.tags.length">
+          <span ng-repeat="tag in item.tags">#{{tag}}</span>
+        </div>
+      </h2>
+
+
+      <!-- content -->
+      <div ng-if="item.content"
+           class="content feed-content"
+           trust-as-html="item.content"></div>
+
+      <!-- footer -->
+      <h4 class="card-footer feed-footer text-right positive-100">
+        <a ng-click="openLink($event, item.url)">
+          <span ng-if="item.truncated" translate>HOME.READ_MORE</span>
+          <span ng-if="!item.truncated" translate>COMMON.BTN_SHOW</span>
+          <i class="icon ion-chevron-right"></i>
+        </a>
+      </h4>
+  </div>
+</div>
diff --git a/www/templates/home/home.html b/www/templates/home/home.html
index c49100cad..0b98de007 100644
--- a/www/templates/home/home.html
+++ b/www/templates/home/home.html
@@ -1,4 +1,4 @@
-<ion-view id="home" bind-notifier="{locale:$root.settings.locale.id}">
+<ion-view id="home" bind-notifier="{locale:$root.settings.locale.id, feed: showFeed}">
   <!-- no title -->
   <ion-nav-title></ion-nav-title>
 
@@ -156,62 +156,15 @@
 
         </div>
 
-
       </div>
-      <div class="col no-padding" ng-class="{'col-30': !feed, 'col-10': feed}">&nbsp;
+
+      <!-- left col spacer-->
+      <div class="col no-padding" ng-class=":feed:{'col-30': !showFeed, 'col-10': showFeed}">&nbsp;
       </div>
-      <div class="col col-30 no-padding" ng-if="feed">
-
-        <!-- feed -->
-        <div class="feed padding-horizontal no-padding-xs padding-top">
-          <h3 class="padding-left">
-            <i class="icon ion-speakerphone"></i>
-            {{feed.title|translate}}
-            <small><a ng-click="openLink($event, feed.home_page_url)" class="gray">
-              <span translate>HOME.SHOW_ALL_FEED</span>
-              <i class="icon ion-chevron-right"></i>
-            </a></small>
-          </h3>
-
-          <!-- feed items -->
-          <div class="animate-show-hide ng-hide" ng-show="feed">
-            <div ng-repeat="item in feed.items"
-                 class="card padding no-margin-xs">
-
-              <div class="header ">
-                <!-- author -->
-                <i ng-if="item.author.avatar" class="avatar" style="background-image: url({{item.author.avatar}});"></i>
-                <a ng-class="{'avatar-left-padding': item.author.avatar}" class="author"
-                   ng-click="item.author.url && openLink($event, item.author.url)">
-                  {{item.author.name}}
-                </a>
-
-                <!-- time -->
-                <a ng-if="item.time"
-                   title="{{item.time|formatDate}}"
-                   ng-click="openLink($event, item.url)"
-                   class="item-note ">
-                  <small><i class="icon ion-clock"></i>&nbsp;{{item.creationTime|formatFromNow}}</small>
-                </a>
-              </div>
-              <!-- title -->
-              <h2 class="title feed-title">
-                <a ng-click="openLink($event, item.url)">{{item.title}}</a></h2>
-              <div ng-if="item.content"
-                   class="content feed-content"
-                   trust-as-html="item.content"></div>
-
-              <!-- footer -->
-              <h4 class="card-footer feed-footer text-right positive-100">
-                <a ng-click="openLink($event, item.url)">
-                  <span ng-if="item.truncated" translate>HOME.READ_MORE</span>
-                  <span ng-if="!item.truncated" translate>COMMON.BTN_SHOW</span>
-                  <i class="icon ion-chevron-right"></i>
-                </a>
-              </h4>
-            </div>
-          </div>
-        </div>
+
+      <!-- include feeds -->
+      <div class="col col-30 no-padding animate-show-hide ng-hide" ng-show=":feed:showFeed">
+        <ng-include src="::'templates/feed/feed.html'"></ng-include>
       </div>
     </div>
 
-- 
GitLab