From be067b9ee35e5a7ff6abd98d627fc6309bec9c38 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 23 Mar 2020 10:44:09 +0100
Subject: [PATCH] See #170: subscriptions management UI

---
 api/funkwhale_api/audio/serializers.py        |   9 +-
 api/funkwhale_api/common/utils.py             |  12 +-
 api/tests/audio/test_serializers.py           |   8 +-
 front/src/components/RemoteSearchForm.vue     | 188 ++++++++++++++++++
 front/src/components/Sidebar.vue              |   3 +
 front/src/components/audio/ChannelCard.vue    |  27 ++-
 .../src/components/common/InlineSearchBar.vue |   7 +-
 front/src/components/semantic/Modal.vue       |   5 +-
 front/src/filters.js                          |  30 ++-
 front/src/router/index.js                     |  13 ++
 front/src/style/_main.scss                    |  40 +++-
 front/src/views/Search.vue                    | 154 +-------------
 .../src/views/channels/SubscriptionsList.vue  | 103 ++++++++++
 13 files changed, 414 insertions(+), 185 deletions(-)
 create mode 100644 front/src/components/RemoteSearchForm.vue
 create mode 100644 front/src/views/channels/SubscriptionsList.vue

diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py
index cffa49b28..6b9227ded 100644
--- a/api/funkwhale_api/audio/serializers.py
+++ b/api/funkwhale_api/audio/serializers.py
@@ -384,12 +384,9 @@ def get_channel_from_rss_url(url, raise_exception=False):
         library=channel.library,
         delete_existing=True,
     )
-    latest_upload_date = max([upload.creation_date for upload in uploads])
-    if (
-        not channel.artist.modification_date
-        or channel.artist.modification_date < latest_upload_date
-    ):
-        common_utils.update_modification_date(channel.artist)
+    if uploads:
+        latest_track_date = max([upload.track.creation_date for upload in uploads])
+        common_utils.update_modification_date(channel.artist, date=latest_track_date)
     return channel, uploads
 
 
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 98fd21355..6c4238fc4 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -410,15 +410,15 @@ def get_audio_mimetype(mt):
     return aliases.get(mt, mt)
 
 
-def update_modification_date(obj, field="modification_date"):
+def update_modification_date(obj, field="modification_date", date=None):
     IGNORE_DELAY = 60
     current_value = getattr(obj, field)
-    now = timezone.now()
-    ignore = current_value is not None and current_value < now - datetime.timedelta(
+    date = date or timezone.now()
+    ignore = current_value is not None and current_value < date - datetime.timedelta(
         seconds=IGNORE_DELAY
     )
     if ignore:
-        setattr(obj, field, now)
-        obj.__class__.objects.filter(pk=obj.pk).update(**{field: now})
+        setattr(obj, field, date)
+        obj.__class__.objects.filter(pk=obj.pk).update(**{field: date})
 
-    return now
+    return date
diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py
index f1dcd21c6..5ddef7c29 100644
--- a/api/tests/audio/test_serializers.py
+++ b/api/tests/audio/test_serializers.py
@@ -791,7 +791,7 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
                     <itunes:subtitle>Subtitle</itunes:subtitle>
                     <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
                     <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
-                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <pubDate>Wed, 11 Mar 2020 18:00:00 GMT</pubDate>
                     <itunes:duration>00:22:37</itunes:duration>
                     <itunes:keywords>pop rock</itunes:keywords>
                     <itunes:season>2</itunes:season>
@@ -806,7 +806,7 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
                     <itunes:subtitle>Subtitle</itunes:subtitle>
                     <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
                     <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-910e-2746218c4f32]]></guid>
-                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <pubDate>Wed, 11 Mar 2020 17:00:00 GMT</pubDate>
                     <itunes:duration>00:22:37</itunes:duration>
                     <itunes:keywords>pop rock</itunes:keywords>
                     <itunes:season>2</itunes:season>
@@ -865,7 +865,9 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
         library=channel.library,
         delete_existing=True,
     )
-    update_modification_date.assert_called_once_with(channel.artist)
+    update_modification_date.assert_called_once_with(
+        channel.artist, date=uploads[0].track.creation_date
+    )
 
 
 def test_get_channel_from_rss_honor_mrf_inbox_before_http(
diff --git a/front/src/components/RemoteSearchForm.vue b/front/src/components/RemoteSearchForm.vue
new file mode 100644
index 000000000..56167216f
--- /dev/null
+++ b/front/src/components/RemoteSearchForm.vue
@@ -0,0 +1,188 @@
+<template>
+  <div>
+    <form id="remote-search" :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
+      <div v-if="errors.length > 0" class="ui negative message">
+        <div class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+      <div class="ui required field">
+        <label for="object-id">
+          {{ labels.fieldLabel }}
+        </label>
+        <p v-if="type === 'rss'">
+          <translate translate-context="Content/Fetch/Paragraph">Paste here the RSS url or the fediverse address to subscribe to its feed.</translate>
+        </p>
+        <p v-else>
+          <translate translate-context="Content/Fetch/Paragraph">Use this form to retrieve an object hosted somewhere else in the fediverse.</translate>
+        </p>
+        <input type="text" name="object-id" id="object-id" :placeholder="labels.fieldPlaceholder" v-model="id" required>
+      </div>
+      <button v-if="showSubmit" type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0">
+        <translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
+      </button>
+    </form>
+    <div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" class="ui warning message">
+      <p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
+    </div>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: {
+    initialId: { type: String, required: false},
+    type: { type: String, required: false},
+    redirect: { type: Boolean, default: true},
+    showSubmit: { type: Boolean, default: true},
+    standalone: { type: Boolean, default: true},
+  },
+
+  data () {
+    return {
+      id: this.initialId,
+      fetch: null,
+      obj: null,
+      isLoading: false,
+      errors: [],
+    }
+  },
+  created () {
+    if (this.id) {
+      if (this.type === 'rss') {
+        this.rssSubscribe()
+
+      } else {
+        this.createFetch()
+      }
+    }
+  },
+  computed: {
+    labels() {
+      let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
+      let fieldLabel = this.$pgettext('Head/Fetch/Field.Label', "URL or @username")
+      let fieldPlaceholder = ""
+      if (this.type === "rss") {
+        title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
+        fieldLabel = this.$pgettext('*/*/*', "Channel location")
+        fieldLabel = this.$pgettext('*/*/*', "Channel location")
+        fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', "@channel@pod.example or https://website.example/rss.xml")
+      }
+      return {
+        title,
+        fieldLabel,
+        fieldPlaceholder,
+      }
+    },
+    objInfo () {
+      if (this.fetch && this.fetch.status === 'finished') {
+        return this.fetch.object
+      }
+    },
+    redirectRoute () {
+      if (!this.objInfo) {
+        return
+      }
+      switch (this.objInfo.type) {
+        case 'account':
+          let [username, domain] = this.objInfo.full_username.split('@')
+          return {name: 'profile.full', params: {username, domain}}
+        case 'library':
+          return {name: 'library.detail', params: {id: this.objInfo.uuid}}
+        case 'artist':
+          return {name: 'library.artists.detail', params: {id: this.objInfo.id}}
+        case 'album':
+          return {name: 'library.albums.detail', params: {id: this.objInfo.id}}
+        case 'track':
+          return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
+        case 'upload':
+          return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
+
+        default:
+          break;
+      }
+    }
+  },
+
+  methods: {
+    submit () {
+      if (this.type === 'rss') {
+        return this.rssSubscribe()
+      } else {
+        return this.createFetch()
+      }
+    },
+    createFetch () {
+      if (!this.id) {
+        return
+      }
+      if (this.standalone) {
+        this.$router.replace({name: "search", query: {id: this.id}})
+      }
+      this.fetch = null
+      let self = this
+      self.errors = []
+      self.isLoading = true
+      let payload = {
+        object: this.id
+      }
+
+      axios.post('federation/fetches/', payload).then((response) => {
+        self.isLoading = false
+        self.fetch = response.data
+        if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
+          self.errors.push(
+            self.$pgettext("Content/*/Error message.Title", "This object cannot be retrieved")
+          )
+        }
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    rssSubscribe () {
+      if (!this.id) {
+        return
+      }
+      if (this.standalone) {
+        console.log('HELLO')
+        this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
+      }
+      this.fetch = null
+      let self = this
+      self.errors = []
+      self.isLoading = true
+      let payload = {
+        url: this.id
+      }
+
+      axios.post('channels/rss-subscribe/', payload).then((response) => {
+        self.isLoading = false
+        self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
+        self.$emit('subscribed', response.data)
+        if (self.redirect) {
+          self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
+        }
+
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  },
+
+  watch: {
+    initialId (v) {
+      this.id = v
+      this.createFetch()
+    },
+    redirectRoute (v) {
+      if (v && this.redirect) {
+        this.$router.push(v)
+      }
+    }
+  }
+}
+</script>
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 732847ef5..a6e63f4d9 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -134,6 +134,9 @@
             <router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link>
           </div>
         </div>
+        <router-link class="header item" :to="{name: 'subscriptions'}" v-if="$store.state.auth.authenticated">
+          <translate translate-context="*/*/*/Noun">Subscriptions</translate>
+        </router-link>
         <div class="item">
           <header class="header">
             <translate translate-context="Footer/About/List item.Link">More</translate>
diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue
index 8adbb9d2c..37db4805e 100644
--- a/front/src/components/audio/ChannelCard.vue
+++ b/front/src/components/audio/ChannelCard.vue
@@ -12,16 +12,24 @@
         </router-link>
       </strong>
       <div class="description">
+        <translate class="meta ellipsis" translate-context="Content/Channel/Paragraph"
+          translate-plural="%{ count } episodes"
+          :translate-n="object.artist.tracks_count"
+          :translate-params="{count: object.artist.tracks_count}">
+          %{ count } episode
+        </translate>
         <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
       </div>
+
     </div>
     <div class="extra content">
-      <translate translate-context="Content/Channel/Paragraph"
-        translate-plural="%{ count } episodes"
-        :translate-n="object.artist.tracks_count"
-        :translate-params="{count: object.artist.tracks_count}">
-        %{ count } episode
-      </translate>
+      <time
+        v-translate
+        class="meta ellipsis"
+        :datetime="object.artist.modification_date"
+        :title="updatedTitle">
+        {{ object.artist.modification_date | fromNow }}
+      </time>
       <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
     </div>
   </div>
@@ -31,6 +39,8 @@
 import PlayButton from '@/components/audio/PlayButton'
 import TagsList from "@/components/tags/List"
 
+import {momentFormat} from '@/filters'
+
 export default {
   props: {
     object: {type: Object},
@@ -58,6 +68,11 @@ export default {
       } else {
         return this.object.uuid
       }
+    },
+    updatedTitle () {
+      let d = momentFormat(this.object.artist.modification_date)
+      let message = this.$pgettext('*/*/*', 'Updated on %{ date }')
+      return this.$gettextInterpolate(message, {date: d})
     }
   }
 }
diff --git a/front/src/components/common/InlineSearchBar.vue b/front/src/components/common/InlineSearchBar.vue
index 0ba6d85a9..3a32b84c0 100644
--- a/front/src/components/common/InlineSearchBar.vue
+++ b/front/src/components/common/InlineSearchBar.vue
@@ -4,8 +4,8 @@
       <label for="search-query" class="hidden">
         <translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
       </label>
-      <input id="search-query" name="search-query" type="text" :placeholder="labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
-      <i v-if="isClearable" class="x link icon" :title="labels.clear" @click="$emit('input', ''); $emit('search', value)"></i>
+      <input id="search-query" name="search-query" type="text" :placeholder="placeholder || labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
+      <i v-if="isClearable" class="x link icon" :title="labels.clear" @click.stop.prevent="$emit('input', ''); $emit('search', value)"></i>
       <button type="submit" class="ui icon basic button">
         <i class="search icon"></i>
       </button>
@@ -15,7 +15,8 @@
 <script>
 export default {
   props: {
-    value: {type: String, required: true}
+    value: {type: String, required: true},
+    placeholder: {type: String, required: false},
   },
   computed: {
     labels () {
diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue
index 599ef0222..e3334c685 100644
--- a/front/src/components/semantic/Modal.vue
+++ b/front/src/components/semantic/Modal.vue
@@ -1,5 +1,5 @@
 <template>
-  <div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
+  <div :class="['ui', {'active': show}, {'overlay fullscreen': fullscreen && ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
     <i class="close inside icon"></i>
     <slot v-if="show">
 
@@ -12,7 +12,8 @@ import $ from 'jquery'
 
 export default {
   props: {
-    show: {type: Boolean, required: true}
+    show: {type: Boolean, required: true},
+    fullscreen: {type: Boolean, default: true},
   },
   data () {
     return {
diff --git a/front/src/filters.js b/front/src/filters.js
index 6465e973a..b88df2f0e 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -38,12 +38,38 @@ export function ago (date, locale) {
     lastDay: 'L',
     lastWeek: 'L',
     sameElse: 'L'
-})
-
+  })
 }
 
 Vue.filter('ago', ago)
 
+export function fromNow (date, locale) {
+  locale = 'en'
+  moment.locale('en', {
+    relativeTime: {
+      future: 'in %s',
+      past: '%s ago',
+      s:  'seconds',
+      ss: '%ss',
+      m:  'a minute',
+      mm: '%dm',
+      h:  'an hour',
+      hh: '%dh',
+      d:  'a day',
+      dd: '%dd',
+      M:  'a month',
+      MM: '%dM',
+      y:  'a year',
+      yy: '%dY'
+    }
+  });
+  const m = moment(date)
+  m.locale(locale)
+  return m.fromNow(true)
+}
+
+Vue.filter('fromNow', fromNow)
+
 export function secondsToObject (seconds) {
   let m = moment.duration(seconds, 'seconds')
   return {
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 71ef0c86c..cc5e166db 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -906,6 +906,19 @@ export default new Router({
         },
       ]
     },
+    {
+      path: "/subscriptions",
+      name: "subscriptions",
+      props: route => {
+        return {
+          defaultQuery: route.query.q
+        }
+      },
+      component: () =>
+        import(
+          /* webpackChunkName: "channels-auth" */ "@/views/channels/SubscriptionsList"
+        ),
+    },
     {
       path: "*/index.html",
       redirect: "/"
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 76bbc35cb..fbebc66ed 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -177,6 +177,9 @@ html {
 .ui.stripe.segment,
 #footer {
   padding: 1em;
+  &.ui.container {
+    margin: 0;
+  }
   @include media(">tablet") {
     padding: 2em;
   }
@@ -372,9 +375,6 @@ input + .help {
   margin-top: 0.5em;
 }
 
-.tag-list {
-  margin-top: 0.5em;
-}
 
 .expandable {
   &:not(.expanded) {
@@ -444,9 +444,9 @@ input + .help {
 }
 .ui.cards.app-cards {
   $card-width: 14em;
-  $card-height: 22em;
+  $card-height: 23em;
   $small-card-width: 11em;
-  $small-card-height: 19em;
+  $small-card-height: 20em;
   .app-card {
     display: flex;
     width: $small-card-width;
@@ -619,9 +619,11 @@ input + .help {
   }
 }
 .header.with-actions {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
+  @include media(">tablet") {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
   .actions {
     font-weight: normal;
     font-size: 0.6em;
@@ -662,5 +664,27 @@ input + .help {
 .ui.header .content {
   display: block;
 }
+.with-image.item {
+  display: flex !important;
+  align-items: center;
+  height: 3em;
+  img.image {
+    width: 3em;
+    height: 3em;
+    margin-right: 1em;
+  }
+  .icon.image {
+    width: 3em;
+    margin-right: 1em;
+    display: block;
+  }
+  .content {
+    font-size: 1em;
+  }
+  .meta {
+    margin-top: 0.5em;
+    font-size: 0.8em;
+  }
+}
 @import "./themes/_light.scss";
 @import "./themes/_dark.scss";
diff --git a/front/src/views/Search.vue b/front/src/views/Search.vue
index 06a355738..f2c141bd5 100644
--- a/front/src/views/Search.vue
+++ b/front/src/views/Search.vue
@@ -2,178 +2,34 @@
   <main class="main pusher" v-title="labels.title">
     <section class="ui vertical stripe segment">
       <div class="ui small text container">
-        <form :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
-          <h2>{{ labels.title }}</h2>
-          <p v-if="type === 'rss'">
-            <translate translate-context="Content/Fetch/Paragraph">Use this form to subscribe to a podcast using its RSS feed.</translate>
-          </p>
-          <p v-else>
-            <translate translate-context="Content/Fetch/Paragraph">Use this form to retrieve an object hosted somewhere else in the fediverse.</translate>
-          </p>
-          <div v-if="errors.length > 0" class="ui negative message">
-            <div class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></div>
-            <ul class="list">
-              <li v-for="error in errors">{{ error }}</li>
-            </ul>
-          </div>
-          <div class="ui required field">
-            <label for="object-id">
-              {{ labels.fieldLabel }}
-            </label>
-            <input type="text" name="object-id" id="object-id" v-model="id" required>
-          </div>
-          <button type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0">
-            <translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
-          </button>
-        </form>
-        <div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" class="ui warning message">
-          <p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
-        </div>
-        </div>
+        <h2>{{ labels.title }}</h2>
+        <remote-search-form :initial-id="initialId" :type="type"></remote-search-form>
       </div>
     </section>
   </main>
 </template>
 
 <script>
-import axios from 'axios'
-
+import RemoteSearchForm from '@/components/RemoteSearchForm'
 
 export default {
   props: {
     initialId: { type: String, required: false},
     type: { type: String, required: false},
   },
-  components: {},
-  data () {
-    return {
-      id: this.initialId,
-      fetch: null,
-      obj: null,
-      isLoading: false,
-      errors: [],
-    }
-  },
-  created () {
-    if (this.id) {
-      if (this.type === 'rss') {
-        this.rssSubscribe()
-
-      } else {
-        this.createFetch()
-      }
-    }
+  components: {
+    RemoteSearchForm,
   },
   computed: {
     labels() {
       let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
-      let fieldLabel = this.$pgettext('Head/Fetch/Field.Placeholder', "URL or @username")
       if (this.type === "rss") {
         title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
-        fieldLabel = this.$pgettext('*/*/*', "RSS Feed URL")
       }
       return {
         title,
-        fieldLabel
-      }
-    },
-    objInfo () {
-      if (this.fetch && this.fetch.status === 'finished') {
-        return this.fetch.object
       }
     },
-    redirectRoute () {
-      if (!this.objInfo) {
-        return
-      }
-      switch (this.objInfo.type) {
-        case 'account':
-          let [username, domain] = this.objInfo.full_username.split('@')
-          return {name: 'profile.full', params: {username, domain}}
-        case 'library':
-          return {name: 'library.detail', params: {id: this.objInfo.uuid}}
-        case 'artist':
-          return {name: 'library.artists.detail', params: {id: this.objInfo.id}}
-        case 'album':
-          return {name: 'library.albums.detail', params: {id: this.objInfo.id}}
-        case 'track':
-          return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
-        case 'upload':
-          return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
-
-        default:
-          break;
-      }
-    }
   },
-  methods: {
-    submit () {
-      if (this.type === 'rss') {
-        return this.rssSubscribe()
-      } else {
-        return this.createFetch()
-      }
-    },
-    createFetch () {
-      if (!this.id) {
-        return
-      }
-      this.$router.replace({name: "search", query: {id: this.id}})
-      this.fetch = null
-      let self = this
-      self.errors = []
-      self.isLoading = true
-      let payload = {
-        object: this.id
-      }
-
-      axios.post('federation/fetches/', payload).then((response) => {
-        self.isLoading = false
-        self.fetch = response.data
-        if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
-          self.errors.push(
-            self.$pgettext("Content/*/Error message.Title", "This object cannot be retrieved")
-          )
-        }
-      }, error => {
-        self.isLoading = false
-        self.errors = error.backendErrors
-      })
-    },
-    rssSubscribe () {
-      if (!this.id) {
-        return
-      }
-      this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
-      this.fetch = null
-      let self = this
-      self.errors = []
-      self.isLoading = true
-      let payload = {
-        url: this.id
-      }
-
-      axios.post('channels/rss-subscribe/', payload).then((response) => {
-        self.isLoading = false
-        self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
-        self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
-
-      }, error => {
-        self.isLoading = false
-        self.errors = error.backendErrors
-      })
-    },
-  },
-  watch: {
-    initialId (v) {
-      this.id = v
-      this.createFetch()
-    },
-    redirectRoute (v) {
-      if (v) {
-        this.$router.push(v)
-      }
-    }
-  }
 }
 </script>
diff --git a/front/src/views/channels/SubscriptionsList.vue b/front/src/views/channels/SubscriptionsList.vue
new file mode 100644
index 000000000..f3b3ff796
--- /dev/null
+++ b/front/src/views/channels/SubscriptionsList.vue
@@ -0,0 +1,103 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <section class="ui head vertical stripe segment container">
+      <h1 class="ui with-actions header">
+        {{ labels.title }}
+        <div class="actions">
+          <a @click.stop.prevent="showSubscribeModal = true">
+            <i class="plus icon"></i>
+            <translate translate-context="Content/Profile/Button">Add new</translate>
+          </a>
+        </div>
+      </h1>
+      <modal class="tiny" :show.sync="showSubscribeModal" :fullscreen="false">
+        <h2 class="header">
+          <translate translate-context="*/*/*/Noun">Subscription</translate>
+        </h2>
+        <div class="scrolling content" ref="modalContent">
+          <remote-search-form
+            type="rss"
+            :show-submit="false"
+            :standalone="false"
+            @subscribed="showSubscribeModal = false; reloadWidget()"
+            :redirect="false"></remote-search-form>
+        </div>
+        <div class="actions">
+          <div class="ui basic deny button">
+            <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+          </div>
+          <button form="remote-search" type="submit" class="ui primary button">
+            <i class="bookmark icon"></i>
+            <translate translate-context="*/*/*/Verb">Subscribe</translate>
+          </button>
+        </div>
+      </modal>
+
+
+
+      <inline-search-bar v-model="query" @search="reloadWidget" :placeholder="labels.searchPlaceholder"></inline-search-bar>
+      <channels-widget
+        :key="widgetKey"
+        :limit="50"
+        :show-modification-date="true"
+        :filters="{q: query, subscribed: 'true', ordering: '-modification_date'}"></channels-widget>
+    </section>
+  </main>
+</template>
+
+<script>
+import axios from "axios"
+import Modal from '@/components/semantic/Modal'
+
+import ChannelsWidget from "@/components/audio/ChannelsWidget"
+import RemoteSearchForm from "@/components/RemoteSearchForm"
+
+export default {
+  props: ["defaultQuery"],
+  components: {
+    ChannelsWidget,
+    RemoteSearchForm,
+    Modal,
+  },
+  data() {
+    return {
+      query: this.defaultQuery || '',
+      channels: [],
+      count: 0,
+      isLoading: false,
+      errors: null,
+      previousPage: null,
+      nextPage: null,
+      widgetKey: String(new Date()),
+      showSubscribeModal: false,
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  computed: {
+    labels () {
+      return {
+        title: this.$pgettext("Content/Subscriptions/Header", "Subscribed Channels"),
+        searchPlaceholder: this.$pgettext("Content/Subscriptions/Form.Placeholder", "Filter by name…"),
+      }
+    },
+  },
+  methods: {
+    fetchData() {
+      var self = this
+      this.isLoading = true
+      axios.get('channels/', {params: {subscribed: "true", q: this.query}}).then(response => {
+        self.previousPage = response.data.previous
+        self.nextPage = response.data.next
+        self.isLoading = false
+        self.channels = [...self.channels, ...response.data.results]
+        self.count = response.data.count
+      })
+    },
+    reloadWidget () {
+      this.widgetKey = String(new Date())
+    }
+  },
+}
+</script>
-- 
GitLab