Browse Source

Merge branch 'bookmark-folders' into 'develop'

Bookmark folders

Closes #1335

See merge request pleroma/pleroma-fe!1945
HJ 1 month ago
parent
commit
bb0a7a81c5
39 changed files with 942 additions and 35 deletions
  1. 1 0
      changelog.d/bookmark-folders.add
  2. 2 1
      package.json
  3. 1 0
      src/boot/after_store.js
  4. 7 1
      src/boot/routes.js
  5. 22 0
      src/components/bookmark_folder_card/bookmark_folder_card.js
  6. 111 0
      src/components/bookmark_folder_card/bookmark_folder_card.vue
  7. 80 0
      src/components/bookmark_folder_edit/bookmark_folder_edit.js
  8. 198 0
      src/components/bookmark_folder_edit/bookmark_folder_edit.vue
  9. 27 0
      src/components/bookmark_folders/bookmark_folders.js
  10. 37 0
      src/components/bookmark_folders/bookmark_folders.vue
  11. 16 0
      src/components/bookmark_folders_menu/bookmark_folders_menu_content.js
  12. 19 0
      src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue
  13. 17 2
      src/components/bookmark_timeline/bookmark_timeline.js
  14. 1 0
      src/components/bookmark_timeline/bookmark_timeline.vue
  15. 1 1
      src/components/emoji_picker/emoji_picker.js
  16. 6 1
      src/components/extra_buttons/extra_buttons.js
  17. 4 0
      src/components/extra_buttons/extra_buttons.vue
  18. 12 3
      src/components/nav_panel/nav_panel.js
  19. 33 0
      src/components/nav_panel/nav_panel.vue
  20. 11 1
      src/components/navigation/filter.js
  21. 2 1
      src/components/navigation/navigation.js
  22. 35 3
      src/components/navigation/navigation_entry.vue
  23. 38 0
      src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js
  24. 40 0
      src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue
  25. 3 0
      src/components/timeline/timeline.js
  26. 18 7
      src/components/timeline_menu/timeline_menu.js
  27. 4 0
      src/components/timeline_menu/timeline_menu.vue
  28. 15 0
      src/i18n/en.json
  29. 3 2
      src/main.js
  30. 15 2
      src/modules/api.js
  31. 66 0
      src/modules/bookmark_folders.js
  32. 1 0
      src/modules/instance.js
  33. 3 1
      src/modules/statuses.js
  34. 2 0
      src/modules/users.js
  35. 55 4
      src/services/api/api.service.js
  36. 7 2
      src/services/backend_interactor_service/backend_interactor_service.js
  37. 22 0
      src/services/bookmark_folders_fetcher/bookmark_folders_fetcher.service.js
  38. 1 0
      src/services/entity_normalizer/entity_normalizer.service.js
  39. 6 3
      src/services/timeline_fetcher/timeline_fetcher.service.js

+ 1 - 0
changelog.d/bookmark-folders.add

@@ -0,0 +1 @@
+Support bookmark folders

+ 2 - 1
package.json

@@ -132,5 +132,6 @@
   "engines": {
     "node": ">= 16.0.0",
     "npm": ">= 3.0.0"
-  }
+  },
+  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
 }

+ 1 - 0
src/boot/after_store.js

@@ -253,6 +253,7 @@ const getNodeInfo = async ({ store }) => {
       store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
       store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
       store.dispatch('setInstanceOption', { name: 'pleromaCustomEmojiReactionsAvailable', value: features.includes('pleroma_custom_emoji_reactions') })
+      store.dispatch('setInstanceOption', { name: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') })
       store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
       store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
       store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })

+ 7 - 1
src/boot/routes.js

@@ -26,6 +26,8 @@ import ListsEdit from 'components/lists_edit/lists_edit.vue'
 import NavPanel from 'src/components/nav_panel/nav_panel.vue'
 import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
 import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
+import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue'
+import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue'
 
 export default (store) => {
   const validateAuthenticatedRoute = (to, from, next) => {
@@ -86,7 +88,11 @@ export default (store) => {
     { name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
     { name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
     { name: 'lists-new', path: '/lists/new', component: ListsEdit },
-    { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
+    { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute },
+    { name: 'bookmark-folders', path: '/bookmark_folders', component: BookmarkFolders },
+    { name: 'bookmark-folder-new', path: '/bookmarks/new-folder', component: BookmarkFolderEdit },
+    { name: 'bookmark-folder', path: '/bookmarks/:id', component: BookmarkTimeline },
+    { name: 'bookmark-folder-edit', path: '/bookmarks/:id/edit', component: BookmarkFolderEdit }
   ]
 
   if (store.state.instance.pleromaChatMessagesAvailable) {

+ 22 - 0
src/components/bookmark_folder_card/bookmark_folder_card.js

@@ -0,0 +1,22 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faEllipsisH
+)
+
+const BookmarkFolderCard = {
+  props: [
+    'folder',
+    'allBookmarks'
+  ],
+  computed: {
+    firstLetter () {
+      return this.folder ? this.folder.name[0] : null
+    }
+  }
+}
+
+export default BookmarkFolderCard

+ 111 - 0
src/components/bookmark_folder_card/bookmark_folder_card.vue

@@ -0,0 +1,111 @@
+<template>
+  <div
+    v-if="allBookmarks"
+    class="bookmark-folder-card"
+  >
+    <router-link
+      :to="{ name: 'bookmarks' }"
+      class="bookmark-folder-name"
+    >
+      <span class="icon">
+        <FAIcon
+          fixed-width
+          class="fa-scale-110 menu-icon"
+          icon="bookmark"
+        />
+      </span>{{ $t('nav.all_bookmarks') }}
+    </router-link>
+  </div>
+  <div
+    v-else
+    class="bookmark-folder-card"
+  >
+    <router-link
+      :to="{ name: 'bookmark-folder', params: { id: folder.id } }"
+      class="bookmark-folder-name"
+    >
+      <img
+        v-if="folder.emoji_url"
+        class="iconEmoji iconEmoji-image"
+        :src="folder.emoji_url"
+        :alt="folder.emoji"
+        :title="folder.emoji"
+      >
+      <span
+        v-else-if="folder.emoji"
+        class="iconEmoji"
+      >
+        <span>
+          {{ folder.emoji }}
+        </span>
+      </span>
+      <span
+        v-else-if="firstLetter"
+        class="icon iconLetter fa-scale-110"
+      >{{ firstLetter }}</span>{{ folder.name }}
+    </router-link>
+    <router-link
+      :to="{ name: 'bookmark-folder-edit', params: { id: folder.id } }"
+      class="button-folder-edit"
+    >
+      <FAIcon
+        class="fa-scale-110 fa-old-padding"
+        icon="ellipsis-h"
+      />
+    </router-link>
+  </div>
+</template>
+
+<script src="./bookmark_folder_card.js"></script>
+
+<style lang="scss">
+.bookmark-folder-card {
+  display: flex;
+  align-items: center;
+}
+
+a.bookmark-folder-name {
+  display: flex;
+  align-items: center;
+  flex-grow: 1;
+
+  .icon,
+  .iconLetter,
+  .iconEmoji {
+    display: inline-block;
+    height: 2.5rem;
+    width: 2.5rem;
+    margin-right: 0.5rem;
+  }
+
+  .icon,
+  .iconLetter {
+    font-size: 1.5rem;
+    line-height: 2.5rem;
+    text-align: center;
+  }
+
+  .iconEmoji {
+    text-align: center;
+    object-fit: contain;
+    vertical-align: middle;
+
+    > span {
+      font-size: 1.5rem;
+      line-height: 2.5rem;
+    }
+  }
+
+  img.iconEmoji {
+    padding: 0.25em;
+    box-sizing: border-box;
+  }
+}
+
+.bookmark-folder-name,
+.button-folder-edit {
+  margin: 0;
+  padding: 1em;
+  color: var(--link);
+}
+</style>

+ 80 - 0
src/components/bookmark_folder_edit/bookmark_folder_edit.js

@@ -0,0 +1,80 @@
+import EmojiPicker from '../emoji_picker/emoji_picker.vue'
+import apiService from '../../services/api/api.service'
+
+const BookmarkFolderEdit = {
+  data () {
+    return {
+      name: '',
+      nameDraft: '',
+      emoji: '',
+      emojiUrl: null,
+      emojiDraft: '',
+      emojiUrlDraft: null,
+      emojiPickerExpanded: false,
+      reallyDelete: false
+    }
+  },
+  components: {
+    EmojiPicker
+  },
+  created () {
+    if (!this.id) return
+    const credentials = this.$store.state.users.currentUser.credentials
+    apiService.fetchBookmarkFolders({ credentials })
+      .then((folders) => {
+        const folder = folders.find(folder => folder.id === this.id)
+        if (!folder) return
+
+        this.nameDraft = this.name = folder.name
+        this.emojiDraft = this.emoji = folder.emoji
+        this.emojiUrlDraft = this.emojiUrl = folder.emoji_url
+      })
+  },
+  computed: {
+    id () {
+      return this.$route.params.id
+    }
+  },
+  methods: {
+    selectEmoji (event) {
+      this.emojiDraft = event.insertion
+      this.emojiUrlDraft = event.insertionUrl
+    },
+    showEmojiPicker () {
+      if (!this.emojiPickerExpanded) {
+        this.$refs.picker.showPicker()
+      }
+    },
+    onShowPicker () {
+      this.emojiPickerExpanded = true
+    },
+    onClosePicker () {
+      this.emojiPickerExpanded = false
+    },
+    updateFolder () {
+      this.$store.dispatch('setBookmarkFolder', { folderId: this.id, name: this.nameDraft, emoji: this.emojiDraft })
+        .then(() => {
+          this.$router.push({ name: 'bookmark-folders' })
+        })
+    },
+    createFolder () {
+      this.$store.dispatch('createBookmarkFolder', { name: this.nameDraft, emoji: this.emojiDraft })
+        .then(() => {
+          this.$router.push({ name: 'bookmark-folders' })
+        })
+        .catch((e) => {
+          this.$store.dispatch('pushGlobalNotice', {
+            messageKey: 'bookmark_folders.error',
+            messageArgs: [e.message],
+            level: 'error'
+          })
+        })
+    },
+    deleteFolder () {
+      this.$store.dispatch('deleteBookmarkFolder', { folderId: this.id })
+      this.$router.push({ name: 'bookmark-folders' })
+    }
+  }
+}
+
+export default BookmarkFolderEdit

+ 198 - 0
src/components/bookmark_folder_edit/bookmark_folder_edit.vue

@@ -0,0 +1,198 @@
+<template>
+  <div class="panel-default panel BookmarkFolderEdit">
+    <div
+      ref="header"
+      class="panel-heading folder-edit-heading"
+    >
+      <button
+        class="button-unstyled go-back-button"
+        @click="$router.back"
+      >
+        <FAIcon
+          size="lg"
+          icon="chevron-left"
+        />
+      </button>
+      <div class="title">
+        <i18n-t
+          v-if="id"
+          keypath="bookmark_folders.editing_folder"
+        >
+          <template #folderName>
+            {{ name }}
+          </template>
+        </i18n-t>
+        <i18n-t
+          v-else
+          keypath="bookmark_folders.creating_folder"
+        />
+      </div>
+    </div>
+    <div class="panel-body">
+      <div class="input-wrap">
+        <label for="folder-edit-title">{{ $t('bookmark_folders.emoji') }}</label>
+        <button
+          class="input input-emoji"
+          :title="$t('bookmark_folder.emoji_pick')"
+          @click="showEmojiPicker"
+        >
+          <img
+            v-if="emojiUrlDraft"
+            class="iconEmoji iconEmoji-image"
+            :src="emojiUrlDraft"
+            :alt="emojiDraft"
+            :title="emojiDraft"
+          >
+          <span
+            v-else-if="emojiDraft"
+            class="iconEmoji"
+          >
+            <span>
+              {{ emojiDraft }}
+            </span>
+          </span>
+        </button>
+        <EmojiPicker
+          ref="picker"
+          class="emoji-picker-panel"
+          @emoji="selectEmoji"
+          @show="onShowPicker"
+          @close="onClosePicker"
+        />
+      </div>
+      <div class="input-wrap">
+        <label for="folder-edit-title">{{ $t('bookmark_folders.name') }}</label>
+        <input
+          id="folder-edit-title"
+          ref="name"
+          v-model="nameDraft"
+          class="input"
+        >
+      </div>
+    </div>
+    <div class="panel-footer">
+      <span class="spacer" />
+      <button
+        v-if="!id"
+        class="btn button-default footer-button"
+        @click="createFolder"
+      >
+        {{ $t('bookmark_folders.create') }}
+      </button>
+      <button
+        v-else-if="!reallyDelete"
+        class="btn button-default footer-button"
+        @click="reallyDelete = true"
+      >
+        {{ $t('bookmark_folders.delete') }}
+      </button>
+      <template v-else>
+        {{ $t('bookmark_folders.really_delete') }}
+        <button
+          class="btn button-default footer-button"
+          @click="deleteFolder"
+        >
+          {{ $t('general.yes') }}
+        </button>
+        <button
+          class="btn button-default footer-button"
+          @click="reallyDelete = false"
+        >
+          {{ $t('general.no') }}
+        </button>
+      </template>
+      <div
+        v-if="id && !reallyDelete"
+      >
+        <button
+          class="btn button-default follow-button"
+          @click="updateFolder"
+        >
+          {{ $t('bookmark_folders.update_folder') }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./bookmark_folder_edit.js"></script>
+
+<style lang="scss">
+.BookmarkFolderEdit {
+  --panel-body-padding: 0.5em;
+
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  .folder-edit-heading {
+    grid-template-columns: auto minmax(50%, 1fr);
+  }
+
+  .panel-body {
+    display: flex;
+    gap: 0.5em;
+  }
+
+  .emoji-picker-panel {
+    position: absolute;
+    z-index: 20;
+    margin-top: 2px;
+
+    &.hide {
+      display: none;
+    }
+  }
+
+  .input-emoji {
+    height: 2.5em;
+    width: 2.5em;
+    padding: 0;
+
+    .iconEmoji {
+      display: inline-block;
+      text-align: center;
+      object-fit: contain;
+      vertical-align: middle;
+      height: 2.5em;
+      width: 2.5em;
+
+      > span {
+        font-size: 1.5rem;
+        line-height: 2.5rem;
+      }
+    }
+
+    img.iconEmoji {
+      padding: 0.25em;
+      box-sizing: border-box;
+    }
+  }
+
+  .input-wrap {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5em;
+  }
+
+  .go-back-button {
+    text-align: center;
+    line-height: 1;
+    height: 100%;
+    align-self: start;
+    width: var(--__panel-heading-height-inner);
+  }
+
+  .btn {
+    margin: 0 0.5em;
+  }
+
+  .panel-footer {
+    grid-template-columns: minmax(10%, 1fr);
+
+    .footer-button {
+      min-width: 9em;
+    }
+  }
+}
+</style>

+ 27 - 0
src/components/bookmark_folders/bookmark_folders.js

@@ -0,0 +1,27 @@
+import BookmarkFolderCard from '../bookmark_folder_card/bookmark_folder_card.vue'
+
+const BookmarkFolders = {
+  data () {
+    return {
+      isNew: false
+    }
+  },
+  components: {
+    BookmarkFolderCard
+  },
+  computed: {
+    bookmarkFolders () {
+      return this.$store.state.bookmarkFolders.allFolders
+    }
+  },
+  methods: {
+    cancelNewFolder () {
+      this.isNew = false
+    },
+    newFolder () {
+      this.isNew = true
+    }
+  }
+}
+
+export default BookmarkFolders

+ 37 - 0
src/components/bookmark_folders/bookmark_folders.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="Bookmark-folders panel panel-default">
+    <div class="panel-heading">
+      <div class="title">
+        {{ $t('nav.bookmark_folders') }}
+      </div>
+      <router-link
+        :to="{ name: 'bookmark-folder-new' }"
+        class="button-default btn new-folder-button"
+      >
+        {{ $t("bookmark_folders.new") }}
+      </router-link>
+    </div>
+    <div class="panel-body">
+      <BookmarkFolderCard
+        :all-bookmarks="true"
+        class="list-item"
+      />
+      <BookmarkFolderCard
+        v-for="folder in bookmarkFolders.slice().reverse()"
+        :key="folder"
+        :folder="folder"
+        class="list-item"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./bookmark_folders.js"></script>
+
+<style lang="scss">
+.Bookmark-folders {
+  .new-folder-button {
+    padding: 0 0.5em;
+  }
+}
+</style>

+ 16 - 0
src/components/bookmark_folders_menu/bookmark_folders_menu_content.js

@@ -0,0 +1,16 @@
+import { mapState } from 'vuex'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { getBookmarkFolderEntries } from 'src/components/navigation/filter.js'
+
+export const BookmarkFoldersMenuContent = {
+  components: {
+    NavigationEntry
+  },
+  computed: {
+    ...mapState({
+      folders: getBookmarkFolderEntries
+    })
+  }
+}
+
+export default BookmarkFoldersMenuContent

+ 19 - 0
src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue

@@ -0,0 +1,19 @@
+<template>
+  <ul>
+    <NavigationEntry
+      :item="{
+        name: 'bookmarks',
+        routeObject: { name: 'bookmarks' },
+        label: 'nav.all_bookmarks',
+        icon: 'bookmark'
+      }"
+    />
+    <NavigationEntry
+      v-for="item in folders"
+      :key="item.id"
+      :item="item"
+    />
+  </ul>
+</template>
+
+<script src="./bookmark_folders_menu_content.js"></script>

+ 17 - 2
src/components/bookmark_timeline/bookmark_timeline.js

@@ -1,16 +1,31 @@
 import Timeline from '../timeline/timeline.vue'
 
 const Bookmarks = {
+  created () {
+    this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
+    this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null })
+  },
+  components: {
+    Timeline
+  },
   computed: {
+    folderId () {
+      return this.$route.params.id
+    },
     timeline () {
       return this.$store.state.statuses.timelines.bookmarks
     }
   },
-  components: {
-    Timeline
+  watch: {
+    folderId () {
+      this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
+      this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
+      this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null })
+    }
   },
   unmounted () {
     this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
+    this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
   }
 }
 

+ 1 - 0
src/components/bookmark_timeline/bookmark_timeline.vue

@@ -3,6 +3,7 @@
     :title="$t('nav.bookmarks')"
     :timeline="timeline"
     :timeline-name="'bookmarks'"
+    :bookmark-folder-id="folderId"
   />
 </template>
 

+ 1 - 1
src/components/emoji_picker/emoji_picker.js

@@ -180,7 +180,7 @@ const EmojiPicker = {
       if (!this.keepOpen) {
         this.$refs.popover.hidePopover()
       }
-      this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
+      this.$emit('emoji', { insertion: value, insertionUrl: emoji.imageUrl, keepOpen: this.keepOpen })
     },
     onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
       const target = this.$refs['emoji-groups'].$el

+ 6 - 1
src/components/extra_buttons/extra_buttons.js

@@ -1,6 +1,7 @@
 import Popover from '../popover/popover.vue'
 import genRandomSeed from '../../services/random_seed/random_seed.service.js'
 import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+import StatusBookmarkFolderMenu from '../status_bookmark_folder_menu/status_bookmark_folder_menu.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faEllipsisH,
@@ -36,7 +37,8 @@ const ExtraButtons = {
   props: ['status'],
   components: {
     Popover,
-    ConfirmModal
+    ConfirmModal,
+    StatusBookmarkFolderMenu
   },
   data () {
     return {
@@ -145,6 +147,9 @@ const ExtraButtons = {
     canBookmark () {
       return !!this.currentUser
     },
+    bookmarkFolders () {
+      return this.$store.state.instance.pleromaBookmarkFoldersAvailable
+    },
     statusLink () {
       return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
     },

+ 4 - 0
src/components/extra_buttons/extra_buttons.vue

@@ -87,6 +87,10 @@
               icon="bookmark"
             /><span>{{ $t("status.unbookmark") }}</span>
           </button>
+          <StatusBookmarkFolderMenu
+            v-if="status.bookmarked && bookmarkFolders"
+            :status="status"
+          />
         </template>
         <button
           v-if="ownStatus && editingAvailable"

+ 12 - 3
src/components/nav_panel/nav_panel.js

@@ -1,3 +1,4 @@
+import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
 import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
 import { mapState, mapGetters } from 'vuex'
 import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
@@ -41,6 +42,7 @@ const NavPanel = {
   created () {
   },
   components: {
+    BookmarkFoldersMenuContent,
     ListsMenuContent,
     NavigationEntry,
     NavigationPins,
@@ -51,6 +53,7 @@ const NavPanel = {
       editMode: false,
       showTimelines: false,
       showLists: false,
+      showBookmarkFolders: false,
       timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
       rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k }))
     }
@@ -62,6 +65,9 @@ const NavPanel = {
     toggleLists () {
       this.showLists = !this.showLists
     },
+    toggleBookmarkFolders () {
+      this.showBookmarkFolders = !this.showBookmarkFolders
+    },
     toggleEditMode () {
       this.editMode = !this.editMode
     },
@@ -90,7 +96,8 @@ const NavPanel = {
       pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
       supportsAnnouncements: state => state.announcements.supportsAnnouncements,
       pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
-      collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
+      collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav,
+      bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
     }),
     timelinesItems () {
       return filterNavigation(
@@ -102,7 +109,8 @@ const NavPanel = {
           hasAnnouncements: this.supportsAnnouncements,
           isFederating: this.federating,
           isPrivate: this.privateMode,
-          currentUser: this.currentUser
+          currentUser: this.currentUser,
+          supportsBookmarkFolders: this.bookmarkFolders
         }
       )
     },
@@ -116,7 +124,8 @@ const NavPanel = {
           hasAnnouncements: this.supportsAnnouncements,
           isFederating: this.federating,
           isPrivate: this.privateMode,
-          currentUser: this.currentUser
+          currentUser: this.currentUser,
+          supportsBookmarkFolders: this.bookmarkFolders
         }
       )
     },

+ 33 - 0
src/components/nav_panel/nav_panel.vue

@@ -83,6 +83,39 @@
             class="timelines"
           />
         </div>
+        <NavigationEntry
+          v-if="currentUser && bookmarkFolders"
+          :show-pin="false"
+          :item="{ icon: 'bookmark', label: 'nav.bookmarks' }"
+          :aria-expanded="showBookmarkFolders ? 'true' : 'false'"
+          @click="toggleBookmarkFolders"
+        >
+          <router-link
+            :title="$t('bookmarks.manage_bookmark_folders')"
+            class="button-unstyled extra-button"
+            :to="{ name: 'bookmark-folders' }"
+            @click.stop
+          >
+            <FAIcon
+              fixed-width
+              icon="wrench"
+            />
+          </router-link>
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="showBookmarkFolders ? 'chevron-up' : 'chevron-down'"
+          />
+        </NavigationEntry>
+        <div
+          v-show="showBookmarkFolders"
+          class="timelines-background menu-item-collapsible"
+          :class="{ '-expanded': showBookmarkFolders }"
+        >
+          <BookmarkFoldersMenuContent
+            class="timelines"
+          />
+        </div>
         <NavigationEntry
           v-for="item in rootItems"
           :key="item.name"

+ 11 - 1
src/components/navigation/filter.js

@@ -1,4 +1,4 @@
-export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser }) => {
+export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser, supportsBookmarkFolders }) => {
   return list.filter(({ criteria, anon, anonRoute }) => {
     const set = new Set(criteria || [])
     if (!isFederating && set.has('federating')) return false
@@ -7,6 +7,7 @@ export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFede
     if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
     if (!hasChats && set.has('chats')) return false
     if (!hasAnnouncements && set.has('announcements')) return false
+    if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) return false
     return true
   })
 }
@@ -17,3 +18,12 @@ export const getListEntries = state => state.lists.allLists.map(list => ({
   labelRaw: list.title,
   iconLetter: list.title[0]
 }))
+
+export const getBookmarkFolderEntries = state => state.bookmarkFolders.allFolders.map(folder => ({
+  name: 'bookmark-folder-' + folder.id,
+  routeObject: { name: 'bookmark-folder', params: { id: folder.id } },
+  labelRaw: folder.name,
+  iconEmoji: folder.emoji,
+  iconEmojiUrl: folder.emoji_url,
+  iconLetter: folder.name[0]
+}))

+ 2 - 1
src/components/navigation/navigation.js

@@ -32,7 +32,8 @@ export const TIMELINES = {
   bookmarks: {
     route: 'bookmarks',
     icon: 'bookmark',
-    label: 'nav.bookmarks'
+    label: 'nav.bookmarks',
+    criteria: ['!supportsBookmarkFolders']
   },
   favorites: {
     routeObject: { name: 'user-profile', query: { tab: 'favorites' } },

+ 35 - 3
src/components/navigation/navigation_entry.vue

@@ -22,11 +22,25 @@
             :icon="item.icon"
           />
         </span>
+        <img
+          v-if="item.iconEmojiUrl"
+          class="menu-icon iconEmoji iconEmoji-image"
+          :src="item.iconEmojiUrl"
+          :alt="item.iconEmoji"
+          :title="item.iconEmoji"
+        >
         <span
-          v-if="item.iconLetter"
-          class="icon iconLetter fa-scale-110 menu-icon"
-        >{{ item.iconLetter }}
+          v-else-if="item.iconEmoji"
+          class="menu-icon iconEmoji"
+        >
+          <span>
+            {{ item.iconEmoji }}
+          </span>
         </span>
+        <span
+          v-else-if="item.iconLetter"
+          class="icon iconLetter fa-scale-110 menu-icon"
+        >{{ item.iconLetter }}</span>
         <span class="label">
           {{ item.labelRaw || $t(item.label) }}
         </span>
@@ -110,5 +124,23 @@
   .badge {
     margin: 0 var(--__horizontal-gap);
   }
+
+  .iconEmoji {
+    display: inline-block;
+    text-align: center;
+    object-fit: contain;
+    vertical-align: middle;
+    height: var(--__line-height);
+    width: var(--__line-height);
+
+    > span {
+      font-size: 1.5rem;
+    }
+  }
+
+  img.iconEmoji {
+    padding: 0.25rem;
+    box-sizing: border-box;
+  }
 }
 </style>

+ 38 - 0
src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.js

@@ -0,0 +1,38 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faChevronRight, faFolder } from '@fortawesome/free-solid-svg-icons'
+import { mapState } from 'vuex'
+
+import Popover from '../popover/popover.vue'
+
+library.add(faChevronRight, faFolder)
+
+const StatusBookmarkFolderMenu = {
+  props: [
+    'status'
+  ],
+  data () {
+    return {}
+  },
+  components: {
+    Popover
+  },
+  computed: {
+    ...mapState({
+      folders: state => state.bookmarkFolders.allFolders
+    }),
+    folderId () {
+      return this.status.bookmark_folder_id
+    }
+  },
+  methods: {
+    toggleFolder (id) {
+      const value = id === this.folderId ? null : id
+
+      this.$store.dispatch('bookmark', { id: this.status.id, bookmark_folder_id: value })
+        .then(() => this.$emit('onSuccess'))
+        .catch(err => this.$emit('onError', err.error.error))
+    }
+  }
+}
+
+export default StatusBookmarkFolderMenu

+ 40 - 0
src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="StatusBookmarkFolderMenu">
+    <Popover
+      trigger="hover"
+      placement="left"
+      remove-padding
+    >
+      <template #content>
+        <div class="dropdown-menu">
+          <button
+            v-for="folder in folders"
+            :key="folder.id"
+            class="menu-item dropdown-item"
+            @click="toggleFolder(folder.id)"
+          >
+            <span
+              class="input menu-checkbox -radio"
+              :class="{ 'menu-checkbox-checked': status.bookmark_folder_id == folder.id }"
+            />
+            {{ folder.name }}
+          </button>
+        </div>
+      </template>
+      <template #trigger>
+        <button class="menu-item dropdown-item dropdown-item-icon -has-submenu">
+          <FAIcon
+            fixed-width
+            icon="folder"
+          />{{ $t('bookmark_folders.select_folder') }}<FAIcon
+            class="chevron-icon"
+            size="lg"
+            icon="chevron-right"
+          />
+        </button>
+      </template>
+    </Popover>
+  </div>
+</template>
+
+<script src="./status_bookmark_folder_menu.js"></script>

+ 3 - 0
src/components/timeline/timeline.js

@@ -26,6 +26,7 @@ const Timeline = {
     'userId',
     'listId',
     'statusId',
+    'bookmarkFolderId',
     'tag',
     'embedded',
     'count',
@@ -123,6 +124,7 @@ const Timeline = {
       userId: this.userId,
       listId: this.listId,
       statusId: this.statusId,
+      bookmarkFolderId: this.bookmarkFolderId,
       tag: this.tag
     })
   },
@@ -186,6 +188,7 @@ const Timeline = {
         userId: this.userId,
         listId: this.listId,
         statusId: this.statusId,
+        bookmarkFolderId: this.bookmarkFolderId,
         tag: this.tag
       }).then(({ statuses }) => {
         if (statuses && statuses.length === 0) {

+ 18 - 7
src/components/timeline_menu/timeline_menu.js

@@ -2,6 +2,7 @@ import Popover from '../popover/popover.vue'
 import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
 import { mapState } from 'vuex'
 import { ListsMenuContent } from '../lists_menu/lists_menu_content.vue'
+import { BookmarkFoldersMenuContent } from '../bookmark_folders_menu/bookmark_folders_menu_content.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { TIMELINES } from 'src/components/navigation/navigation.js'
 import { filterNavigation } from 'src/components/navigation/filter.js'
@@ -13,10 +14,10 @@ library.add(faChevronDown)
 
 // Route -> i18n key mapping, exported and not in the computed
 // because nav panel benefits from the same information.
-export const timelineNames = () => {
+export const timelineNames = (supportsBookmarkFolders) => {
   return {
     friends: 'nav.home_timeline',
-    bookmarks: 'nav.bookmarks',
+    bookmarks: supportsBookmarkFolders ? 'nav.all_bookmarks' : 'nav.bookmarks',
     dms: 'nav.dms',
     'public-timeline': 'nav.public_tl',
     'public-external-timeline': 'nav.twkn',
@@ -28,7 +29,8 @@ const TimelineMenu = {
   components: {
     Popover,
     NavigationEntry,
-    ListsMenuContent
+    ListsMenuContent,
+    BookmarkFoldersMenuContent
   },
   data () {
     return {
@@ -36,7 +38,7 @@ const TimelineMenu = {
     }
   },
   created () {
-    if (timelineNames()[this.$route.name]) {
+    if (timelineNames(this.bookmarkFolders)[this.$route.name]) {
       this.$store.dispatch('setLastTimeline', this.$route.name)
     }
   },
@@ -45,10 +47,15 @@ const TimelineMenu = {
       const route = this.$route.name
       return route === 'lists-timeline'
     },
+    useBookmarkFoldersMenu () {
+      const route = this.$route.name
+      return this.bookmarkFolders && (route === 'bookmark-folder' || route === 'bookmarks')
+    },
     ...mapState({
       currentUser: state => state.users.currentUser,
       privateMode: state => state.instance.private,
-      federating: state => state.instance.federating
+      federating: state => state.instance.federating,
+      bookmarkFolders: state => state.instance.pleromaBookmarkFoldersAvailable
     }),
     timelinesList () {
       return filterNavigation(
@@ -57,7 +64,8 @@ const TimelineMenu = {
           hasChats: this.pleromaChatMessagesAvailable,
           isFederating: this.federating,
           isPrivate: this.privateMode,
-          currentUser: this.currentUser
+          currentUser: this.currentUser,
+          supportsBookmarkFolders: this.bookmarkFolders
         }
       )
     }
@@ -89,7 +97,10 @@ const TimelineMenu = {
       if (route === 'lists-timeline') {
         return this.$store.getters.findListTitle(this.$route.params.id)
       }
-      const i18nkey = timelineNames()[this.$route.name]
+      if (route === 'bookmark-folder') {
+        return this.$store.getters.findBookmarkFolderName(this.$route.params.id)
+      }
+      const i18nkey = timelineNames(this.bookmarkFolders)[this.$route.name]
       return i18nkey ? this.$t(i18nkey) : route
     }
   }

+ 4 - 0
src/components/timeline_menu/timeline_menu.vue

@@ -15,6 +15,10 @@
         :show-pin="false"
         class="timelines"
       />
+      <BookmarkFoldersMenuContent
+        v-else-if="useBookmarkFoldersMenu"
+        class="timelines"
+      />
       <ul v-else>
         <NavigationEntry
           v-for="item in timelinesList"

+ 15 - 0
src/i18n/en.json

@@ -174,6 +174,8 @@
     "home_timeline": "Home timeline",
     "twkn": "Known Network",
     "bookmarks": "Bookmarks",
+    "all_bookmarks": "All bookmarks",
+    "bookmark_folders": "Bookmark folders",
     "user_search": "User Search",
     "search": "Search",
     "search_close": "Close search bar",
@@ -1421,5 +1423,18 @@
     "fun_3": "Suya...",
     "fun_4": "My Pleroma machine is full power!",
     "error": "Something went wrong"
+  },
+  "bookmark_folders": {
+    "select_folder": "Select bookmark folder",
+    "creating_folder": "Creating bookmark folder",
+    "editing_folder": "Editing folder {folderName}",
+    "emoji": "Emoji",
+    "name": "Folder name",
+    "new": "New Folder",
+    "create": "Create folder",
+    "delete": "Delete folder",
+    "update_folder": "Save changes",
+    "really_delete": "Do you really want to delete the folder?",
+    "error": "Error manipulating bookmark folders: {0}"
   }
 }

+ 3 - 2
src/main.js

@@ -24,9 +24,9 @@ import pollsModule from './modules/polls.js'
 import postStatusModule from './modules/postStatus.js'
 import editStatusModule from './modules/editStatus.js'
 import statusHistoryModule from './modules/statusHistory.js'
-
 import chatsModule from './modules/chats.js'
 import announcementsModule from './modules/announcements.js'
+import bookmarkFoldersModule from './modules/bookmark_folders.js'
 
 import { createI18n } from 'vue-i18n'
 
@@ -114,7 +114,8 @@ const persistedStateOptions = {
         editStatus: editStatusModule,
         statusHistory: statusHistoryModule,
         chats: chatsModule,
-        announcements: announcementsModule
+        announcements: announcementsModule,
+        bookmarkFolders: bookmarkFoldersModule
       },
       plugins,
       strict: false // Socket modifies itself, let's ignore this for now.

+ 15 - 2
src/modules/api.js

@@ -203,12 +203,13 @@ const api = {
       tag = false,
       userId = false,
       listId = false,
-      statusId = false
+      statusId = false,
+      bookmarkFolderId = false
     }) {
       if (store.state.fetchers[timeline]) return
 
       const fetcher = store.state.backendInteractor.startFetchingTimeline({
-        timeline, store, userId, listId, statusId, tag
+        timeline, store, userId, listId, statusId, bookmarkFolderId, tag
       })
       store.commit('addFetcher', { fetcherName: timeline, fetcher })
     },
@@ -272,6 +273,18 @@ const api = {
       store.commit('removeFetcher', { fetcherName: 'lists', fetcher })
     },
 
+    // Bookmark folders
+    startFetchingBookmarkFolders (store) {
+      if (store.state.fetchers.bookmarkFolders) return
+      const fetcher = store.state.backendInteractor.startFetchingBookmarkFolders({ store })
+      store.commit('addFetcher', { fetcherName: 'bookmarkFolders', fetcher })
+    },
+    stopFetchingBookmarkFolders (store) {
+      const fetcher = store.state.fetchers.bookmarkFolders
+      if (!fetcher) return
+      store.commit('removeFetcher', { fetcherName: 'bookmarkFolders', fetcher })
+    },
+
     // Pleroma websocket
     setWsToken (store, token) {
       store.commit('setWsToken', token)

+ 66 - 0
src/modules/bookmark_folders.js

@@ -0,0 +1,66 @@
+import { remove, find } from 'lodash'
+
+export const defaultState = {
+  allFolders: []
+}
+
+export const mutations = {
+  setBookmarkFolders (state, value) {
+    state.allFolders = value
+  },
+  setBookmarkFolder (state, { id, name, emoji, emoji_url: emojiUrl }) {
+    const entry = find(state.allFolders, { id })
+    if (!entry) {
+      state.allFolders.push({ id, name, emoji, emoji_url: emojiUrl })
+    } else {
+      entry.name = name
+      entry.emoji = emoji
+      entry.emoji_url = emojiUrl
+    }
+  },
+  deleteBookmarkFolder (state, { folderId }) {
+    remove(state.allFolders, folder => folder.id === folderId)
+  }
+}
+
+const actions = {
+  setBookmarkFolders ({ commit }, value) {
+    commit('setBookmarkFolders', value)
+  },
+  createBookmarkFolder ({ rootState, commit }, { name, emoji }) {
+    return rootState.api.backendInteractor.createBookmarkFolder({ name, emoji })
+      .then((folder) => {
+        commit('setBookmarkFolder', folder)
+        return folder
+      })
+  },
+  setBookmarkFolder ({ rootState, commit }, { folderId, name, emoji }) {
+    return rootState.api.backendInteractor.updateBookmarkFolder({ folderId, name, emoji })
+      .then((folder) => {
+        commit('setBookmarkFolder', folder)
+        return folder
+      })
+  },
+  deleteBookmarkFolder ({ rootState, commit }, { folderId }) {
+    rootState.api.backendInteractor.deleteBookmarkFolder({ folderId })
+    commit('deleteBookmarkFolder', { folderId })
+  }
+}
+
+export const getters = {
+  findBookmarkFolderName: state => id => {
+    const folder = state.allFolders.find(folder => folder.id === id)
+
+    if (!folder) return
+    return folder.name
+  }
+}
+
+const bookmarkFolders = {
+  state: defaultState,
+  mutations,
+  actions,
+  getters
+}
+
+export default bookmarkFolders

+ 1 - 0
src/modules/instance.js

@@ -140,6 +140,7 @@ const defaultState = {
   shoutAvailable: false,
   pleromaChatMessagesAvailable: false,
   pleromaCustomEmojiReactionsAvailable: false,
+  pleromaBookmarkFoldersAvailable: false,
   gopherAvailable: false,
   mediaProxyAvailable: false,
   suggestionsEnabled: false,

+ 3 - 1
src/modules/statuses.js

@@ -385,10 +385,12 @@ export const mutations = {
   setBookmarked (state, { status, value }) {
     const newStatus = state.allStatusesObject[status.id]
     newStatus.bookmarked = value
+    newStatus.bookmark_folder_id = status.bookmark_folder_id
   },
   setBookmarkedConfirm (state, { status }) {
     const newStatus = state.allStatusesObject[status.id]
     newStatus.bookmarked = status.bookmarked
+    if (status.pleroma) newStatus.bookmark_folder_id = status.pleroma.bookmark_folder
   },
   setDeleted (state, { status }) {
     const newStatus = state.allStatusesObject[status.id]
@@ -569,7 +571,7 @@ const statuses = {
     },
     bookmark ({ rootState, commit }, status) {
       commit('setBookmarked', { status, value: true })
-      rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
+      rootState.api.backendInteractor.bookmarkStatus({ id: status.id, folder_id: status.bookmark_folder_id })
         .then(status => {
           commit('setBookmarkedConfirm', { status })
         })

+ 2 - 0
src/modules/users.js

@@ -579,6 +579,7 @@ const users = {
           store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
           store.dispatch('stopFetchingNotifications')
           store.dispatch('stopFetchingLists')
+          store.dispatch('stopFetchingBookmarkFolders')
           store.dispatch('stopFetchingFollowRequests')
           store.commit('clearNotifications')
           store.commit('resetStatuses')
@@ -635,6 +636,7 @@ const users = {
               }
 
               dispatch('startFetchingLists')
+              dispatch('startFetchingBookmarkFolders')
 
               if (user.locked) {
                 dispatch('startFetchingFollowRequests')

+ 55 - 4
src/services/api/api.service.js

@@ -110,6 +110,8 @@ const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcemen
 const PLEROMA_SCROBBLES_URL = id => `/api/v1/pleroma/accounts/${id}/scrobbles`
 const PLEROMA_STATUS_QUOTES_URL = id => `/api/v1/pleroma/statuses/${id}/quotes`
 const PLEROMA_USER_FAVORITES_TIMELINE_URL = id => `/api/v1/pleroma/accounts/${id}/favourites`
+const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
+const PLEROMA_BOOKMARK_FOLDER_URL = id => `/api/v1/pleroma/bookmark_folders/${id}`
 
 const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config'
 const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions'
@@ -690,7 +692,8 @@ const fetchTimeline = ({
   tag = false,
   withMuted = false,
   replyVisibility = 'all',
-  includeTypes = []
+  includeTypes = [],
+  bookmarkFolderId = false
 }) => {
   const timelineUrls = {
     public: MASTODON_PUBLIC_TIMELINE,
@@ -760,6 +763,9 @@ const fetchTimeline = ({
       params.push(['include_types[]', type])
     })
   }
+  if (timeline === 'bookmarks' && bookmarkFolderId) {
+    params.push(['folder_id', bookmarkFolderId])
+  }
 
   params.push(['limit', 20])
 
@@ -829,11 +835,14 @@ const unretweet = ({ id, credentials }) => {
     .then((data) => parseStatus(data))
 }
 
-const bookmarkStatus = ({ id, credentials }) => {
+const bookmarkStatus = ({ id, credentials, ...options }) => {
   return promisedRequest({
     url: MASTODON_BOOKMARK_STATUS_URL(id),
     headers: authHeaders(credentials),
-    method: 'POST'
+    method: 'POST',
+    payload: {
+      folder_id: options.folder_id
+    }
   })
 }
 
@@ -1893,6 +1902,44 @@ const deleteEmojiFile = ({ packName, shortcode }) => {
   return fetch(`${PLEROMA_EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`, { method: 'DELETE' })
 }
 
+const fetchBookmarkFolders = ({ credentials }) => {
+  const url = PLEROMA_BOOKMARK_FOLDERS_URL
+  return fetch(url, { headers: authHeaders(credentials) })
+    .then((data) => data.json())
+}
+
+const createBookmarkFolder = ({ name, emoji, credentials }) => {
+  const url = PLEROMA_BOOKMARK_FOLDERS_URL
+  const headers = authHeaders(credentials)
+  headers['Content-Type'] = 'application/json'
+
+  return fetch(url, {
+    headers,
+    method: 'POST',
+    body: JSON.stringify({ name, emoji })
+  }).then((data) => data.json())
+}
+
+const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) => {
+  const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
+  const headers = authHeaders(credentials)
+  headers['Content-Type'] = 'application/json'
+
+  return fetch(url, {
+    headers,
+    method: 'PATCH',
+    body: JSON.stringify({ name, emoji })
+  }).then((data) => data.json())
+}
+
+const deleteBookmarkFolder = ({ folderId, credentials }) => {
+  const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
+  return fetch(url, {
+    method: 'DELETE',
+    headers: authHeaders(credentials)
+  })
+}
+
 const apiService = {
   verifyCredentials,
   fetchTimeline,
@@ -2023,7 +2070,11 @@ const apiService = {
   updateEmojiFile,
   deleteEmojiFile,
   listRemoteEmojiPacks,
-  downloadRemoteEmojiPack
+  downloadRemoteEmojiPack,
+  fetchBookmarkFolders,
+  createBookmarkFolder,
+  updateBookmarkFolder,
+  deleteBookmarkFolder
 }
 
 export default apiService

+ 7 - 2
src/services/backend_interactor_service/backend_interactor_service.js

@@ -3,10 +3,11 @@ import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
 import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
 import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
 import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
+import bookmarkFoldersFetcher from '../../services/bookmark_folders_fetcher/bookmark_folders_fetcher.service.js'
 
 const backendInteractorService = credentials => ({
-  startFetchingTimeline ({ timeline, store, userId = false, listId = false, statusId = false, tag }) {
-    return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, statusId, tag })
+  startFetchingTimeline ({ timeline, store, userId = false, listId = false, statusId = false, bookmarkFolderId = false, tag }) {
+    return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, statusId, bookmarkFolderId, tag })
   },
 
   fetchTimeline (args) {
@@ -29,6 +30,10 @@ const backendInteractorService = credentials => ({
     return listsFetcher.startFetching({ store, credentials })
   },
 
+  startFetchingBookmarkFolders ({ store }) {
+    return bookmarkFoldersFetcher.startFetching({ store, credentials })
+  },
+
   startUserSocket ({ store }) {
     const serv = store.rootState.instance.server.replace('http', 'ws')
     const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })

+ 22 - 0
src/services/bookmark_folders_fetcher/bookmark_folders_fetcher.service.js

@@ -0,0 +1,22 @@
+import apiService from '../api/api.service.js'
+import { promiseInterval } from '../promise_interval/promise_interval.js'
+
+const fetchAndUpdate = ({ store, credentials }) => {
+  return apiService.fetchBookmarkFolders({ credentials })
+    .then(bookmarkFolders => {
+      store.commit('setBookmarkFolders', bookmarkFolders)
+    }, () => {})
+    .catch(() => {})
+}
+
+const startFetching = ({ credentials, store }) => {
+  const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
+  boundFetchAndUpdate()
+  return promiseInterval(boundFetchAndUpdate, 240000)
+}
+
+const bookmarkFoldersFetcher = {
+  startFetching
+}
+
+export default bookmarkFoldersFetcher

+ 1 - 0
src/services/entity_normalizer/entity_normalizer.service.js

@@ -332,6 +332,7 @@ export const parseStatus = (data) => {
       output.quote_url = pleroma.quote_url
       output.quote_visible = pleroma.quote_visible
       output.quotes_count = pleroma.quotes_count
+      output.bookmark_folder_id = pleroma.bookmark_folder
     } else {
       output.text = data.content
       output.summary = data.spoiler_text

+ 6 - 3
src/services/timeline_fetcher/timeline_fetcher.service.js

@@ -25,6 +25,7 @@ const fetchAndUpdate = ({
   userId = false,
   listId = false,
   statusId = false,
+  bookmarkFolderId = false,
   tag = false,
   until,
   since
@@ -49,6 +50,7 @@ const fetchAndUpdate = ({
   args.userId = userId
   args.listId = listId
   args.statusId = statusId
+  args.bookmarkFolderId = bookmarkFolderId
   args.tag = tag
   args.withMuted = !hideMutedPosts
   if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
@@ -80,15 +82,16 @@ const fetchAndUpdate = ({
     })
 }
 
-const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, statusId = false, tag = false }) => {
+const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, statusId = false, bookmarkFolderId = false, tag = false }) => {
   const rootState = store.rootState || store.state
   const timelineData = rootState.statuses.timelines[camelCase(timeline)]
   const showImmediately = timelineData.visibleStatuses.length === 0
   timelineData.userId = userId
   timelineData.listId = listId
-  fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, statusId, tag })
+  timelineData.bookmarkFolderId = bookmarkFolderId
+  fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, statusId, bookmarkFolderId, tag })
   const boundFetchAndUpdate = () =>
-    fetchAndUpdate({ timeline, credentials, store, userId, listId, statusId, tag })
+    fetchAndUpdate({ timeline, credentials, store, userId, listId, statusId, bookmarkFolderId, tag })
   return promiseInterval(boundFetchAndUpdate, 10000)
 }
 const timelineFetcher = {