~cytrogen/masto-fe

96ba9cbc15c562529b2eba4892226dfe6455a349 — Cytrogen 14 days ago 1ff7088 + 42b9e89
Merge PR #64: Bookmark folders

Adds bookmark folder support: create/manage folders, assign bookmarks
to folders, browse bookmarks by folder. Also refactors alert/toast
system to support clickable actions.

From: mkljczk (https://codeberg.org/superseriousbusiness/masto-fe-standalone/pulls/64)
33 files changed, 1046 insertions(+), 155 deletions(-)

M app/javascript/flavours/glitch/actions/alerts.js
A app/javascript/flavours/glitch/actions/bookmark_folders.js
M app/javascript/flavours/glitch/actions/bookmarks.js
M app/javascript/flavours/glitch/actions/compose.js
M app/javascript/flavours/glitch/actions/interactions.js
M app/javascript/flavours/glitch/components/status.jsx
M app/javascript/flavours/glitch/components/status_action_bar.jsx
M app/javascript/flavours/glitch/components/status_list.jsx
M app/javascript/flavours/glitch/containers/status_container.js
A app/javascript/flavours/glitch/features/bookmark_folder/index.jsx
A app/javascript/flavours/glitch/features/bookmark_folders/index.jsx
M app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx
M app/javascript/flavours/glitch/features/getting_started/index.jsx
M app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/bookmark_folder.jsx
A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/new_folder_form.jsx
A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/index.jsx
M app/javascript/flavours/glitch/features/status/components/action_bar.jsx
M app/javascript/flavours/glitch/features/status/index.jsx
M app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
M app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
M app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
M app/javascript/flavours/glitch/features/ui/index.jsx
M app/javascript/flavours/glitch/features/ui/util/async-components.js
M app/javascript/flavours/glitch/reducers/alerts.js
A app/javascript/flavours/glitch/reducers/bookmark_folder_editor.js
A app/javascript/flavours/glitch/reducers/bookmark_folders.js
M app/javascript/flavours/glitch/reducers/index.ts
M app/javascript/flavours/glitch/reducers/status_lists.js
M app/javascript/flavours/glitch/selectors/index.js
A app/javascript/flavours/glitch/styles/components/bookmark_folders.scss
M app/javascript/flavours/glitch/styles/components/index.scss
M app/javascript/flavours/glitch/styles/components/misc.scss
M app/javascript/flavours/glitch/actions/alerts.js => app/javascript/flavours/glitch/actions/alerts.js +17 -16
@@ 25,12 25,10 @@ export function clearAlert() {
  };
}

export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
export function showAlert(alert) {
  return {
    type: ALERT_SHOW,
    title,
    message,
    message_values,
    alert,
  };
}



@@ 44,20 42,23 @@ export function showAlertForError(error, skipNotFound = false) {
    }

    if (status === 429 && headers["x-ratelimit-reset"]) {
      const reset_date = new Date(headers["x-ratelimit-reset"]);
      return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { "retry_time": reset_date });
      return showAlert({
        title: messages.rateLimitedTitle,
        message: messages.rateLimitedMessage,
        values: { "retry_time": new Date(headers["x-ratelimit-reset"]) },
      });
    }

    let message = statusText;
    let title   = `${status}`;
    return showAlert({
      title: `${status}`,
      message: data.error || statusText,
    });
  }

    if (data.error) {
      message = data.error;
    }
  console.error(error);

    return showAlert(title, message);
  } else {
    console.error(error);
    return showAlert();
  }
  return showAlert({
    title: messages.unexpectedTitle,
    message: messages.unexpectedMessage,
  });
}

A app/javascript/flavours/glitch/actions/bookmark_folders.js => app/javascript/flavours/glitch/actions/bookmark_folders.js +146 -0
@@ 0,0 1,146 @@
import api from '../api';

export const BOOKMARK_FOLDERS_FETCH_REQUEST = 'BOOKMARK_FOLDERS_FETCH_REQUEST';
export const BOOKMARK_FOLDERS_FETCH_SUCCESS = 'BOOKMARK_FOLDERS_FETCH_SUCCESS';
export const BOOKMARK_FOLDERS_FETCH_FAIL    = 'BOOKMARK_FOLDERS_FETCH_FAIL';

export const BOOKMARK_FOLDER_CREATE_REQUEST = 'BOOKMARK_FOLDER_CREATE_REQUEST';
export const BOOKMARK_FOLDER_CREATE_SUCCESS = 'BOOKMARK_FOLDER_CREATE_SUCCESS';
export const BOOKMARK_FOLDER_CREATE_FAIL    = 'BOOKMARK_FOLDER_CREATE_FAIL';

export const BOOKMARK_FOLDER_UPDATE_REQUEST = 'BOOKMARK_FOLDER_UPDATE_REQUEST';
export const BOOKMARK_FOLDER_UPDATE_SUCCESS = 'BOOKMARK_FOLDER_UPDATE_SUCCESS';
export const BOOKMARK_FOLDER_UPDATE_FAIL    = 'BOOKMARK_FOLDER_UPDATE_FAIL';

export const BOOKMARK_FOLDER_DELETE_REQUEST = 'BOOKMARK_FOLDER_DELETE_REQUEST';
export const BOOKMARK_FOLDER_DELETE_SUCCESS = 'BOOKMARK_FOLDER_DELETE_SUCCESS';
export const BOOKMARK_FOLDER_DELETE_FAIL    = 'BOOKMARK_FOLDER_DELETE_FAIL';

export const BOOKMARK_FOLDER_EDITOR_NAME_CHANGE = 'BOOKMARK_FOLDER_EDITOR_NAME_CHANGE';
export const BOOKMARK_FOLDER_EDITOR_RESET       = 'BOOKMARK_FOLDER_EDITOR_RESET';
export const BOOKMARK_FOLDER_EDITOR_SETUP       = 'BOOKMARK_FOLDER_EDITOR_SETUP';

export const fetchBookmarkFolders = () => (dispatch, getState) => {
  dispatch(fetchBookmarkFoldersRequest());

  api(getState).get('/api/v1/bookmark_folders')
    .then(({ data }) => dispatch(fetchBookmarkFoldersSuccess(data)))
    .catch(err => dispatch(fetchBookmarkFoldersFail(err)));
};

export const fetchBookmarkFoldersRequest = () => ({
  type: BOOKMARK_FOLDERS_FETCH_REQUEST,
});

export const fetchBookmarkFoldersSuccess = folders => ({
  type: BOOKMARK_FOLDERS_FETCH_SUCCESS,
  folders,
});

export const fetchBookmarkFoldersFail = error => ({
  type: BOOKMARK_FOLDERS_FETCH_FAIL,
  error,
});

export const createBookmarkFolder = (name, shouldReset) => (dispatch, getState) => {
  dispatch(createBookmarkFolderRequest());

  api(getState).post('/api/v1/bookmark_folders', { name }).then(({ data }) => {
    dispatch(createBookmarkFolderSuccess(data));
    
    if (shouldReset) {
      dispatch(resetBookmarkFolderEditor());
    }
  }).catch(err => dispatch(createBookmarkFolderFail(err)));
};

export const createBookmarkFolderRequest = () => ({
  type: BOOKMARK_FOLDER_CREATE_REQUEST,
});

export const createBookmarkFolderSuccess = folder => ({
  type: BOOKMARK_FOLDER_CREATE_SUCCESS,
  folder,
});

export const createBookmarkFolderFail = error => ({
  type: BOOKMARK_FOLDER_CREATE_FAIL,
  error,
});

export const updateBookmarkFolder = (id, name, shouldReset) => (dispatch, getState) => {
  dispatch(updateBookmarkFolderRequest(id));

  api(getState).put(`/api/v1/bookmark_folders/${id}`, { name }).then(({ data }) => {
    dispatch(updateBookmarkFolderSuccess(data));
    
    if (shouldReset) {
      dispatch(resetBookmarkFolderEditor());
    }
  }).catch(err => dispatch(updateBookmarkFolderFail(id, err)));
};

export const updateBookmarkFolderRequest = id => ({
  type: BOOKMARK_FOLDER_UPDATE_REQUEST,
  id,
});

export const updateBookmarkFolderSuccess = folder => ({
  type: BOOKMARK_FOLDER_UPDATE_SUCCESS,
  folder,
});

export const updateBookmarkFolderFail = (id, error) => ({
  type: BOOKMARK_FOLDER_UPDATE_FAIL,
  id,
  error,
});

export const deleteBookmarkFolder = id => (dispatch, getState) => {
  dispatch(deleteBookmarkFolderRequest(id));

  api(getState).delete(`/api/v1/bookmark_folders/${id}`)
    .then(() => dispatch(deleteBookmarkFolderSuccess(id)))
    .catch(err => dispatch(deleteBookmarkFolderFail(id, err)));
};

export const deleteBookmarkFolderRequest = id => ({
  type: BOOKMARK_FOLDER_DELETE_REQUEST,
  id,
});

export const deleteBookmarkFolderSuccess = id => ({
  type: BOOKMARK_FOLDER_DELETE_SUCCESS,
  id,
});

export const deleteBookmarkFolderFail = (id, error) => ({
  type: BOOKMARK_FOLDER_DELETE_FAIL,
  id,
  error,
});

export const submitBookmarkFolderEditor = shouldReset => (dispatch, getState) => {
  const folderId = getState().getIn(['bookmarkFolderEditor', 'folderId']);
  const name  = getState().getIn(['bookmarkFolderEditor', 'name']);

  if (folderId === null) {
    dispatch(createBookmarkFolder(name, shouldReset));
  } else {
    dispatch(updateBookmarkFolder(folderId, name, shouldReset));
  }
};

export const resetBookmarkFolderEditor = () => ({
  type: BOOKMARK_FOLDER_EDITOR_RESET,
});

export const setupBookmarkFolderEditor = folderId => (dispatch, getState) => dispatch({
  type: BOOKMARK_FOLDER_EDITOR_SETUP,
  folder: getState().getIn(['bookmarkFolders', folderId]),
});

export const changeBookmarkFolderEditorName = value => ({
  type: BOOKMARK_FOLDER_EDITOR_NAME_CHANGE,
  value,
});

M app/javascript/flavours/glitch/actions/bookmarks.js => app/javascript/flavours/glitch/actions/bookmarks.js +28 -18
@@ 10,82 10,92 @@ export const BOOKMARKED_STATUSES_EXPAND_REQUEST = "BOOKMARKED_STATUSES_EXPAND_RE
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = "BOOKMARKED_STATUSES_EXPAND_SUCCESS";
export const BOOKMARKED_STATUSES_EXPAND_FAIL    = "BOOKMARKED_STATUSES_EXPAND_FAIL";

export function fetchBookmarkedStatuses() {
export function fetchBookmarkedStatuses(folderId) {
  return (dispatch, getState) => {
    if (getState().getIn(["status_lists", "bookmarks", "isLoading"])) {
    const key = folderId ? `bookmarks:${folderId}`: "bookmarks";

    if (getState().getIn(["status_lists", key, "isLoading"])) {
      return;
    }

    dispatch(fetchBookmarkedStatusesRequest());
    dispatch(fetchBookmarkedStatusesRequest(folderId));

    api(getState).get("/api/v1/bookmarks").then(response => {
    api(getState).get("/api/v1/bookmarks", { params: { folder_id: folderId } }).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === "next");
      dispatch(importFetchedStatuses(response.data));
      dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
      dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
    }).catch(error => {
      dispatch(fetchBookmarkedStatusesFail(error));
      dispatch(fetchBookmarkedStatusesFail(error, folderId));
    });
  };
}

export function fetchBookmarkedStatusesRequest() {
export function fetchBookmarkedStatusesRequest(folderId) {
  return {
    type: BOOKMARKED_STATUSES_FETCH_REQUEST,
    folderId,
  };
}

export function fetchBookmarkedStatusesSuccess(statuses, next) {
export function fetchBookmarkedStatusesSuccess(statuses, next, folderId) {
  return {
    type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
    statuses,
    next,
    folderId,
  };
}

export function fetchBookmarkedStatusesFail(error) {
export function fetchBookmarkedStatusesFail(error, folderId) {
  return {
    type: BOOKMARKED_STATUSES_FETCH_FAIL,
    error,
    folderId,
  };
}

export function expandBookmarkedStatuses() {
export function expandBookmarkedStatuses(folderId) {
  return (dispatch, getState) => {
    const url = getState().getIn(["status_lists", "bookmarks", "next"], null);
    const key = folderId ? `bookmarks:${folderId}`: "bookmarks";

    const url = getState().getIn(["status_lists", key, "next"], null);

    if (url === null || getState().getIn(["status_lists", "bookmarks", "isLoading"])) {
    if (url === null || getState().getIn(["status_lists", key, "isLoading"])) {
      return;
    }

    dispatch(expandBookmarkedStatusesRequest());
    dispatch(expandBookmarkedStatusesRequest(folderId));

    api(getState).get(url).then(response => {
      const next = getLinks(response).refs.find(link => link.rel === "next");
      dispatch(importFetchedStatuses(response.data));
      dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
      dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
    }).catch(error => {
      dispatch(expandBookmarkedStatusesFail(error));
      dispatch(expandBookmarkedStatusesFail(error, folderId));
    });
  };
}

export function expandBookmarkedStatusesRequest() {
export function expandBookmarkedStatusesRequest(folderId) {
  return {
    type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
    folderId,
  };
}

export function expandBookmarkedStatusesSuccess(statuses, next) {
export function expandBookmarkedStatusesSuccess(statuses, next, folderId) {
  return {
    type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
    statuses,
    next,
    folderId,
  };
}

export function expandBookmarkedStatusesFail(error) {
export function expandBookmarkedStatusesFail(error, folderId) {
  return {
    type: BOOKMARKED_STATUSES_EXPAND_FAIL,
    error,
    folderId,
  };
}

M app/javascript/flavours/glitch/actions/compose.js => app/javascript/flavours/glitch/actions/compose.js +12 -2
@@ 89,6 89,9 @@ export const COMPOSE_SET_STATUS = "COMPOSE_SET_STATUS";
const messages = defineMessages({
  uploadErrorLimit: { id: "upload_error.limit", defaultMessage: "File upload limit exceeded." },
  uploadErrorPoll:  { id: "upload_error.poll", defaultMessage: "File upload not allowed with polls." },
  open: { id: "compose.published.open", defaultMessage: "Open" },
  published: { id: "compose.published.body", defaultMessage: "Post published." },
  saved: { id: "compose.saved.body", defaultMessage: "Post saved." },
});

export const ensureComposeIsVisible = (getState, routerHistory) => {


@@ 262,6 265,13 @@ export function submitCompose(routerHistory) {
      } else if (statusId === null && response.data.visibility === "direct") {
        insertIfOnline("direct");
      }

      dispatch(showAlert({
        message: statusId === null ? messages.published : messages.saved,
        action: messages.open,
        dismissAfter: 10000,
        onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
      }));
    }).catch(function (error) {
      dispatch(submitComposeFail(error));
    });


@@ 304,12 314,12 @@ export function uploadCompose(files) {
    let total = Array.from(files).reduce((a, v) => a + v.size, 0);

    if (files.length + media.size + pending > uploadLimit) {
      dispatch(showAlert(undefined, messages.uploadErrorLimit));
      dispatch(showAlert({ message: messages.uploadErrorLimit }));
      return;
    }

    if (getState().getIn(["compose", "poll"])) {
      dispatch(showAlert(undefined, messages.uploadErrorPoll));
      dispatch(showAlert({ message: messages.uploadErrorPoll }));
      return;
    }


M app/javascript/flavours/glitch/actions/interactions.js => app/javascript/flavours/glitch/actions/interactions.js +27 -4
@@ 1,7 1,11 @@
import { defineMessages } from "react-intl";

import api, { getLinks } from "../api";

import { fetchRelationships } from "./accounts";
import { showAlert } from "./alerts";
import { importFetchedAccounts, importFetchedStatus } from "./importer";
import { openModal } from "./modal";

export const REBLOG_REQUEST = "REBLOG_REQUEST";
export const REBLOG_SUCCESS = "REBLOG_SUCCESS";


@@ 51,6 55,14 @@ export const UNBOOKMARK_REQUEST = "UNBOOKMARKED_REQUEST";
export const UNBOOKMARK_SUCCESS = "UNBOOKMARKED_SUCCESS";
export const UNBOOKMARK_FAIL    = "UNBOOKMARKED_FAIL";

const messages = defineMessages({
  bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
  folderChanged: { id: 'status.bookmark_folder_changed', defaultMessage: 'Changed folder' },
  view: { id: 'toast.view', defaultMessage: 'View' },
  selectFolder: { id: 'status.bookmark.select_folder', defaultMessage: 'Select folder' },

});

export function reblog(status, visibility) {
  return function (dispatch, getState) {
    dispatch(reblogRequest(status));


@@ 193,13 205,23 @@ export function unfavouriteFail(status, error) {
  };
}

export function bookmark(status) {
export function bookmark(status, folder_id, routerHistory) {
  return function (dispatch, getState) {
    dispatch(bookmarkRequest(status));

    api(getState).post(`/api/v1/statuses/${status.get("id")}/bookmark`).then(function (response) {
    return api(getState).post(`/api/v1/statuses/${status.get("id")}/bookmark`, { folder_id }).then(function (response) {
      dispatch(importFetchedStatus(response.data));
      dispatch(bookmarkSuccess(status));
      dispatch(bookmarkSuccess(status, folder_id));

      dispatch(showAlert({
        message: folder_id !== undefined ? messages.folderChanged : messages.bookmarkAdded,
        action: folder_id !== undefined ? messages.view : messages.selectFolder,
        dismissAfter: 10000,
        onClick: () => folder_id !== undefined ? routerHistory.push(`/bookmarks/${folder_id}`) : dispatch(openModal({
          modalType: 'SELECT_BOOKMARK_FOLDER',
          modalProps: { statusId: status.get('id') },
        })),
      }));
    }).catch(function (error) {
      dispatch(bookmarkFail(status, error));
    });


@@ 226,10 248,11 @@ export function bookmarkRequest(status) {
  };
}

export function bookmarkSuccess(status) {
export function bookmarkSuccess(status, folderId) {
  return {
    type: BOOKMARK_SUCCESS,
    status: status,
    folderId,
  };
}


M app/javascript/flavours/glitch/components/status.jsx => app/javascript/flavours/glitch/components/status.jsx +4 -2
@@ 96,11 96,13 @@ class Status extends ImmutablePureComponent {
    onToggleHidden: PropTypes.func,
    onTranslate: PropTypes.func,
    onInteractionModal: PropTypes.func,
    onChangeBookmarkFolder: PropTypes.func,
    muted: PropTypes.bool,
    hidden: PropTypes.bool,
    unread: PropTypes.bool,
    prepend: PropTypes.string,
    withDismiss: PropTypes.bool,
    fromBookmarks: PropTypes.bool,
    onMoveUp: PropTypes.func,
    onMoveDown: PropTypes.func,
    getScrollPosition: PropTypes.func,


@@ 457,8 459,8 @@ class Status extends ImmutablePureComponent {
    this.props.onReblog(this.props.status, e);
  };

  handleHotkeyBookmark = e => {
    this.props.onBookmark(this.props.status, e);
  handleHotkeyBookmark = () => {
    this.props.onBookmark(this.props.status, undefined, this.context.router.history);
  };

  handleHotkeyMention = e => {

M app/javascript/flavours/glitch/components/status_action_bar.jsx => app/javascript/flavours/glitch/components/status_action_bar.jsx +15 -3
@@ 48,6 48,7 @@ const messages = defineMessages({
  edited: { id: "status.edited", defaultMessage: "Edited {date}" },
  filter: { id: "status.filter", defaultMessage: "Filter this post" },
  openOriginalPage: { id: "account.open_original_page", defaultMessage: "Open original page" },
  changeBookmarkFolder: { id: "status.bookmark.change_folder", defaultMessage: "Change bookmark folder" },
});

class StatusActionBar extends ImmutablePureComponent {


@@ 75,9 76,11 @@ class StatusActionBar extends ImmutablePureComponent {
    onFilter: PropTypes.func,
    onAddFilter: PropTypes.func,
    onInteractionModal: PropTypes.func,
    onChangeBookmarkFolder: PropTypes.func,
    withDismiss: PropTypes.bool,
    withCounters: PropTypes.bool,
    showReplyCount: PropTypes.bool,
    fromBookmarks: PropTypes.bool,
    scrollKey: PropTypes.string,
    intl: PropTypes.object.isRequired,
  };


@@ 127,8 130,8 @@ class StatusActionBar extends ImmutablePureComponent {
    }
  };

  handleBookmarkClick = (e) => {
    this.props.onBookmark(this.props.status, e);
  handleBookmarkClick = () => {
    this.props.onBookmark(this.props.status, undefined, this.context.router.history);
  };

  handleDeleteClick = () => {


@@ 197,8 200,12 @@ class StatusActionBar extends ImmutablePureComponent {
    this.props.onAddFilter(this.props.status);
  };

  handleChangeBookmarkFolder = () => {
    this.props.onChangeBookmarkFolder(this.props.status);
  };

  render () {
    const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
    const { status, intl, withDismiss, withCounters, showReplyCount, fromBookmarks, scrollKey } = this.props;
    const { permissions, signedIn } = this.context.identity;

    const mutingConversation = status.get("muted");


@@ 212,6 219,11 @@ class StatusActionBar extends ImmutablePureComponent {
    let replyIcon;
    let replyTitle;

    if (status.get('bookmarked') && fromBookmarks) {
      menu.push({ text: intl.formatMessage(messages.changeBookmarkFolder), action: this.handleChangeBookmarkFolder });
      menu.push(null);
    }

    menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });

    if (publicStatus && isRemote) {

M app/javascript/flavours/glitch/components/status_list.jsx => app/javascript/flavours/glitch/components/status_list.jsx +3 -0
@@ 28,6 28,7 @@ export default class StatusList extends ImmutablePureComponent {
    emptyMessage: PropTypes.node,
    alwaysPrepend: PropTypes.bool,
    withCounters: PropTypes.bool,
    fromBookmarks: PropTypes.bool,
    timelineId: PropTypes.string.isRequired,
    lastId: PropTypes.string,
    regex: PropTypes.string,


@@ 107,6 108,7 @@ export default class StatusList extends ImmutablePureComponent {
          contextType={timelineId}
          scrollKey={this.props.scrollKey}
          withCounters={this.props.withCounters}
          fromBookmarks={this.props.fromBookmarks}
        />
      ))
    ) : null;


@@ 122,6 124,7 @@ export default class StatusList extends ImmutablePureComponent {
          contextType={timelineId}
          scrollKey={this.props.scrollKey}
          withCounters={this.props.withCounters}
          fromBookmarks={this.props.fromBookmarks}
        />
      )).concat(scrollableContent);
    }

M app/javascript/flavours/glitch/containers/status_container.js => app/javascript/flavours/glitch/containers/status_container.js +9 -2
@@ 135,11 135,11 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
    });
  },

  onBookmark (status) {
  onBookmark (status, folderId, router) {
    if (status.get("bookmarked")) {
      dispatch(unbookmark(status));
    } else {
      dispatch(bookmark(status));
      dispatch(bookmark(status, folderId, router));
    }
  },



@@ 298,6 298,13 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
    }));
  },

  onChangeBookmarkFolder (status) {
    dispatch(openModal({
      modalType: 'SELECT_BOOKMARK_FOLDER',
      modalProps: { statusId: status.get('id') },
    }));
  },

});

export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

A app/javascript/flavours/glitch/features/bookmark_folder/index.jsx => app/javascript/flavours/glitch/features/bookmark_folder/index.jsx +142 -0
@@ 0,0 1,142 @@
import PropTypes from 'prop-types';

import { injectIntl, FormattedMessage } from 'react-intl';

import { Helmet } from 'react-helmet';

import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { debounce } from 'lodash';

import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import StatusList from 'flavours/glitch/components/status_list';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import Column from 'flavours/glitch/features/ui/components/column';
import { getStatusList } from 'flavours/glitch/selectors';

const mapStateToProps = (state, props) => {
  const key = `bookmarks:${props.params.folderId}`;
  return {
    folder: state.getIn(['bookmarkFolders', props.params.folderId]),
    statusIds: getStatusList(state, key),
    isLoading: state.getIn(['status_lists', key, 'isLoading'], true),
    hasMore: !!state.getIn(['status_lists', key, 'next']),
  };
};

class BookmarkFolder extends ImmutablePureComponent {

  static propTypes = {
    dispatch: PropTypes.func.isRequired,
    statusIds: ImmutablePropTypes.list.isRequired,
    intl: PropTypes.object.isRequired,
    columnId: PropTypes.string,
    multiColumn: PropTypes.bool,
    hasMore: PropTypes.bool,
    isLoading: PropTypes.bool,
  };

  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchBookmarkedStatuses(this.props.params.folderId));
  }
  
  UNSAFE_componentWillReceiveProps (nextProps) {
    const { folderId } = nextProps.params;
  
    if (folderId !== this.props.params.folderId) {
  
      this.props.dispatch(fetchBookmarkedStatuses(folderId));

    }
  }

  handlePin = () => {
    const { columnId, dispatch } = this.props;

    if (columnId) {
      dispatch(removeColumn(columnId));
    } else {
      dispatch(addColumn('BOOKMARK_FOLDER', { folderId: this.props.params.folderId }));
    }
  };

  handleMove = (dir) => {
    const { columnId, dispatch } = this.props;
    dispatch(moveColumn(columnId, dir));
  };

  handleHeaderClick = () => {
    this.column.scrollTop();
  };

  setRef = c => {
    this.column = c;
  };

  handleLoadMore = debounce(() => {
    this.props.dispatch(expandBookmarkedStatuses(this.props.params.folderId));
  }, 300, { leading: true });

  render () {
    const { statusIds, columnId, multiColumn, folder, hasMore, isLoading, params } = this.props;
    const { folderId } = params;
    const pinned = !!columnId;
    const name = folder ? folder.get('name') : folderId;

    const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses.folder' defaultMessage="You don't have any bookmarked posts in this folder yet. When you ad one, it will show up here." />;
    
    if (typeof folder === 'undefined') {
      return (
        <Column>
          <div className='scrollable'>
            <LoadingIndicator />
          </div>
        </Column>
      );
    } else if (folder === false) {
      return (
        <BundleColumnError multiColumn={multiColumn} errorType='routing' />
      );
    }

    return (
      <Column bindToDocument={!multiColumn} ref={this.setRef} label={name}>
        <ColumnHeader
          icon='bookmark'
          title={name}
          onPin={this.handlePin}
          onMove={this.handleMove}
          onClick={this.handleHeaderClick}
          pinned={pinned}
          multiColumn={multiColumn}
          showBackButton
        />

        <StatusList
          trackScroll={!pinned}
          statusIds={statusIds}
          scrollKey={`bookmarked_statuses:${folderId}-${columnId}`}
          hasMore={hasMore}
          isLoading={isLoading}
          onLoadMore={this.handleLoadMore}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
          fromBookmarks
        />

        <Helmet>
          <title>{name}</title>
          <meta name='robots' content='noindex' />
        </Helmet>
      </Column>
    );
  }

}

export default connect(mapStateToProps)(injectIntl(BookmarkFolder));

A app/javascript/flavours/glitch/features/bookmark_folders/index.jsx => app/javascript/flavours/glitch/features/bookmark_folders/index.jsx +96 -0
@@ 0,0 1,96 @@
import PropTypes from 'prop-types';

import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';

import { Helmet } from 'react-helmet';

import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';

import { fetchBookmarkFolders } from 'flavours/glitch/actions/bookmark_folders';
import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import Column from 'flavours/glitch/features/ui/components/column';
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';

import NewFolderForm from '../select_bookmark_folder_modal/components/new_folder_form';

const messages = defineMessages({
  heading: { id: 'column.bookmark_folders', defaultMessage: 'Bookmark folders' },
  subheading: { id: 'bookmark_folders.subheading', defaultMessage: 'Your bookmark folders' },
});

const getOrderedBookmarkFolders = createSelector([state => state.get('bookmarkFolders')], folders => {
  if (!folders) {
    return folders;
  }

  return folders.toList().filter(item => !!item).sort((a, b) => a.get('name').localeCompare(b.get('name')));
});

const mapStateToProps = state => ({
  folders: getOrderedBookmarkFolders(state),
});

class BookmarkFolders extends ImmutablePureComponent {

  static propTypes = {
    params: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
    folders: ImmutablePropTypes.list,
    intl: PropTypes.object.isRequired,
    multiColumn: PropTypes.bool,
  };

  UNSAFE_componentWillMount () {
    this.props.dispatch(fetchBookmarkFolders());
  }

  render () {
    const { intl, folders, multiColumn } = this.props;

    if (!folders) {
      return (
        <Column>
          <LoadingIndicator />
        </Column>
      );
    }

    const children = [
      <ColumnLink key='all' to='/bookmarks' icon='bookmark' text={<FormattedMessage id='bookmark_folders.all' defaultMessage='All bookmarks' />} />,
    ];

    for (const folder of folders) {
      children.push(<ColumnLink key={folder.get('id')} to={`/bookmarks/${folder.get('id')}`} icon='folder' text={folder.get('name')} />)
    }

    return (
      <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}>
        <ColumnBackButtonSlim />

        <NewFolderForm />

        <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
        <ScrollableList
          scrollKey='bookmark_folders'
          bindToDocument={!multiColumn}
        >
          {children}
        </ScrollableList>

        <Helmet>
          <title>{intl.formatMessage(messages.heading)}</title>
          <meta name='robots' content='noindex' />
        </Helmet>
      </Column>
    );
  }

}

export default connect(mapStateToProps)(injectIntl(BookmarkFolders));

M app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx => app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx +1 -0
@@ 98,6 98,7 @@ class Bookmarks extends ImmutablePureComponent {
          onLoadMore={this.handleLoadMore}
          emptyMessage={emptyMessage}
          bindToDocument={!multiColumn}
          fromBookmarks
        />

        <Helmet>

M app/javascript/flavours/glitch/features/getting_started/index.jsx => app/javascript/flavours/glitch/features/getting_started/index.jsx +6 -1
@@ 11,6 11,7 @@ import { connect } from "react-redux";
import { createSelector } from "reselect";

import { fetchFollowRequests } from "flavours/glitch/actions/accounts";
import { fetchBookmarkFolders } from "flavours/glitch/actions/bookmark_folders";
import { fetchLists } from "flavours/glitch/actions/lists";
import { openModal } from "flavours/glitch/actions/modal";
import Column from "flavours/glitch/features/ui/components/column";


@@ 69,6 70,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = dispatch => ({
  fetchFollowRequests: () => dispatch(fetchFollowRequests()),
  fetchLists: () => dispatch(fetchLists()),
  fetchBookmarkFolders: () => dispatch(fetchBookmarkFolders()),
  openSettings: () => dispatch(openModal({
    modalType: "SETTINGS",
    modalProps: {},


@@ 102,15 104,17 @@ class GettingStarted extends ImmutablePureComponent {
    unreadNotifications: PropTypes.number,
    lists: ImmutablePropTypes.list,
    fetchLists: PropTypes.func.isRequired,
    fetchBookmarkFolders: PropTypes.func.isRequired,
    openSettings: PropTypes.func.isRequired,
  };

  UNSAFE_componentWillMount () {
    this.props.fetchLists();
    this.props.fetchBookmarkFolders();
  }

  componentDidMount () {
    const { fetchFollowRequests } = this.props;
    const { fetchFollowRequests, fetchBookmarkFolders } = this.props;
    const { signedIn } = this.context.identity;

    if (!signedIn) {


@@ 118,6 122,7 @@ class GettingStarted extends ImmutablePureComponent {
    }

    fetchFollowRequests();
    fetchBookmarkFolders();
  }

  render () {

M app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js => app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js +2 -2
@@ 33,7 33,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
          if (permission === "granted") {
            dispatch(changePushNotifications(path.slice(1), checked));
          } else {
            dispatch(showAlert(undefined, messages.permissionDenied));
            dispatch(showAlert({ message: messages.permissionDenied }));
          }
        }));
      } else {


@@ 48,7 48,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
          if (permission === "granted") {
            dispatch(changeSetting(["notifications", ...path], checked));
          } else {
            dispatch(showAlert(undefined, messages.permissionDenied));
            dispatch(showAlert({ message: messages.permissionDenied }));
          }
        }));
      } else {

A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/bookmark_folder.jsx => app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/bookmark_folder.jsx +71 -0
@@ 0,0 1,71 @@
import PropTypes from 'prop-types';

import { FormattedMessage, injectIntl } from 'react-intl';

import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';

import { bookmark } from 'flavours/glitch/actions/interactions';
import { closeModal } from 'flavours/glitch/actions/modal';
import { RadioButton } from 'flavours/glitch/components/radio_button';

const mapStateToProps = (state, { statusId }) => ({
  status: state.getIn(['statuses', statusId]),
});

const mapDispatchToProps = (dispatch, { folder }) => ({
  onChange: (status, router) => {
    dispatch(bookmark(status, folder ? folder.get('id') : null, router)).then(() => {
      dispatch(closeModal({
        modalType: 'SELECT_BOOKMARK_FOLDER',
      }));
    }).catch(() => {});
  },
});

class BookmarkFolder extends ImmutablePureComponent {

  static contextTypes = {
    router: PropTypes.object,
  };

  static propTypes = {
    status: ImmutablePropTypes.map.isRequired,
    folder: ImmutablePropTypes.map,
    statusId: PropTypes.string.isRequired,
    statusFolderId: PropTypes.string,
    intl: PropTypes.object.isRequired,
    onChange: PropTypes.func.isRequired,
  };

  static defaultProps = {
    added: false,
  };

  handleChange = () => {
    const { onChange, status } = this.props;
    onChange(status, this.context.router.history);
  };

  render () {
    const { folder, statusFolderId } = this.props;

    return (
      <div className='bookmark-folder'>
        <div className='bookmark-folder__wrapper'>
          <RadioButton
            name='folder_id'
            value={statusFolderId || ''}
            label={folder ? folder.get('name') : <FormattedMessage id='bookmark_folders.uncategorized' defaultMessage='Uncategorized' />}
            checked={statusFolderId === (folder ? folder.get('id') : null)}
            onChange={this.handleChange}
          />
        </div>
      </div>
    );
  }

}

export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BookmarkFolder));

A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/new_folder_form.jsx => app/javascript/flavours/glitch/features/select_bookmark_folder_modal/components/new_folder_form.jsx +81 -0
@@ 0,0 1,81 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';

import { defineMessages, injectIntl } from 'react-intl';

import { connect } from 'react-redux';

import { changeBookmarkFolderEditorName, submitBookmarkFolderEditor } from 'flavours/glitch/actions/bookmark_folders';
import { IconButton } from 'flavours/glitch/components/icon_button';

const messages = defineMessages({
  label: { id: 'bookmark_folders.new.name_placeholder', defaultMessage: 'New folder name' },
  title: { id: 'bookmark_folders.new.create', defaultMessage: 'Add folder' },
});

const mapStateToProps = state => ({
  value: state.getIn(['bookmarkFolderEditor', 'name']),
  disabled: state.getIn(['bookmarkFolderEditor', 'isSubmitting']),
});

const mapDispatchToProps = dispatch => ({
  onChange: value => dispatch(changeBookmarkFolderEditorName(value)),
  onSubmit: () => dispatch(submitBookmarkFolderEditor(true)),
});

class NewFolderForm extends PureComponent {

  static propTypes = {
    value: PropTypes.string.isRequired,
    disabled: PropTypes.bool,
    intl: PropTypes.object.isRequired,
    onChange: PropTypes.func.isRequired,
    onSubmit: PropTypes.func.isRequired,
  };

  handleChange = e => {
    this.props.onChange(e.target.value);
  };

  handleSubmit = e => {
    e.preventDefault();
    this.props.onSubmit();
  };

  handleClick = () => {
    this.props.onSubmit();
  };

  render () {
    const { value, disabled, intl } = this.props;

    const label = intl.formatMessage(messages.label);
    const title = intl.formatMessage(messages.title);

    return (
      <form className='column-inline-form' onSubmit={this.handleSubmit}>
        <label>
          <span style={{ display: 'none' }}>{label}</span>

          <input
            className='setting-text'
            value={value}
            disabled={disabled}
            onChange={this.handleChange}
            placeholder={label}
          />
        </label>

        <IconButton
          disabled={disabled || !value}
          icon='plus'
          title={title}
          onClick={this.handleClick}
        />
      </form>
    );
  }

}

export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewFolderForm));

A app/javascript/flavours/glitch/features/select_bookmark_folder_modal/index.jsx => app/javascript/flavours/glitch/features/select_bookmark_folder_modal/index.jsx +73 -0
@@ 0,0 1,73 @@
import PropTypes from 'prop-types';

import { injectIntl } from 'react-intl';

import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';

import { fetchBookmarkFolders } from 'flavours/glitch/actions/bookmark_folders';
import { makeGetStatus } from 'flavours/glitch/selectors';

import BookmarkFolder from './components/bookmark_folder';
import NewFolderForm from './components/new_folder_form';

const getOrderedBookmarkFolders = createSelector([state => state.get('bookmarkFolders')], folders => {
  if (!folders) {
    return folders;
  }

  return folders.toList().filter(item => !!item).sort((a, b) => a.get('name').localeCompare(b.get('name')));
});

const makeMapStateToProps = () => {
  const getStatus = makeGetStatus();

  const mapStateToProps = (state, { statusId }) => ({
    folders: getOrderedBookmarkFolders(state),
    status: getStatus(state, { id: statusId }),
  });

  return mapStateToProps;
};

const mapDispatchToProps = (dispatch) => ({
  onInitialize () {
    dispatch(fetchBookmarkFolders());
  }
});

class SelectBookmarkFolderModal extends ImmutablePureComponent {

  static propTypes = {
    statusId: PropTypes.string.isRequired,
    onClose: PropTypes.func.isRequired,
    intl: PropTypes.object.isRequired,
    onInitialize: PropTypes.func.isRequired,
    folders: ImmutablePropTypes.list.isRequired,
  };

  componentDidMount () {
    const { onInitialize } = this.props;
    onInitialize();
  }

  render () {
    const { folders, status } = this.props;

    return (
      <div className='modal-root__modal select-bookmark-folder'>
        <NewFolderForm />

        <div className='select-bookmark-folder__folders'>
          <BookmarkFolder statusId={status.get('id')} statusFolderId={status.get('bookmark_folder')} />
          {folders.map(folder => <BookmarkFolder key={folder.get('id')} folder={folder} statusId={status.get('id')} statusFolderId={status.get('bookmark_folder')} />)}
        </div>
      </div>
    );
  }

}

export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(SelectBookmarkFolderModal));

M app/javascript/flavours/glitch/features/status/components/action_bar.jsx => app/javascript/flavours/glitch/features/status/components/action_bar.jsx +2 -2
@@ 81,8 81,8 @@ class ActionBar extends PureComponent {
    this.props.onFavourite(this.props.status, e);
  };

  handleBookmarkClick = (e) => {
    this.props.onBookmark(this.props.status, e);
  handleBookmarkClick = () => {
    this.props.onBookmark(this.props.status, undefined, this.context.router.history);
  };

  handleDeleteClick = () => {

M app/javascript/flavours/glitch/features/status/index.jsx => app/javascript/flavours/glitch/features/status/index.jsx +1 -1
@@ 382,7 382,7 @@ class Status extends ImmutablePureComponent {
    if (status.get("bookmarked")) {
      this.props.dispatch(unbookmark(status));
    } else {
      this.props.dispatch(bookmark(status));
      this.props.dispatch(bookmark(status, undefined, this.context.router.history));
    }
  };


M app/javascript/flavours/glitch/features/ui/components/columns_area.jsx => app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +2 -0
@@ 21,6 21,7 @@ import {
  BookmarkedStatuses,
  ListTimeline,
  Directory,
  BookmarkFolder,
} from "../util/async-components";

import BundleColumnError from "./bundle_column_error";


@@ 40,6 41,7 @@ const componentMap = {
  "DIRECT": DirectTimeline,
  "FAVOURITES": FavouritedStatuses,
  "BOOKMARKS": BookmarkedStatuses,
  "BOOKMARK_FOLDER": BookmarkFolder,
  "LIST": ListTimeline,
  "DIRECTORY": Directory,
};

M app/javascript/flavours/glitch/features/ui/components/modal_root.jsx => app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +2 -0
@@ 19,6 19,7 @@ import {
  InteractionModal,
  SubscribedLanguagesModal,
  ClosedRegistrationsModal,
  SelectBookmarkFolderModal,
} from "flavours/glitch/features/ui/util/async-components";
import { getScrollbarWidth } from "flavours/glitch/utils/scrollbar";



@@ 64,6 65,7 @@ export const MODAL_COMPONENTS = {
  "SUBSCRIBED_LANGUAGES": SubscribedLanguagesModal,
  "INTERACTION": InteractionModal,
  "CLOSED_REGISTRATIONS": ClosedRegistrationsModal,
  "SELECT_BOOKMARK_FOLDER": SelectBookmarkFolderModal,
};

export default class ModalRoot extends PureComponent {

M app/javascript/flavours/glitch/features/ui/containers/notifications_container.js => app/javascript/flavours/glitch/features/ui/containers/notifications_container.js +19 -18
@@ 7,26 7,27 @@ import { NotificationStack } from "react-notification";
import { dismissAlert } from "flavours/glitch/actions/alerts";
import { getAlerts } from "flavours/glitch/selectors";

const mapStateToProps = (state, { intl }) => {
  const notifications = getAlerts(state);
const formatIfNeeded = (intl, message, values) => {
  if (typeof message === 'object') {
    return intl.formatMessage(message, values);
  }

  notifications.forEach(notification => ["title", "message"].forEach(key => {
    const value = notification[key];

    if (typeof value === "object") {
      notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
    }
  }));

  return { notifications };
  return message;
};

const mapDispatchToProps = (dispatch) => {
  return {
    onDismiss: alert => {
      dispatch(dismissAlert(alert));
    },
  };
};
const mapStateToProps = (state, { intl }) => ({
  notifications: getAlerts(state).map(alert => ({
    ...alert,
    action: formatIfNeeded(intl, alert.action, alert.values),
    title: formatIfNeeded(intl, alert.title, alert.values),
    message: formatIfNeeded(intl, alert.message, alert.values),
  })),
});

const mapDispatchToProps = (dispatch) => ({
  onDismiss (alert) {
    dispatch(dismissAlert(alert));
  },
});

export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));

M app/javascript/flavours/glitch/features/ui/index.jsx => app/javascript/flavours/glitch/features/ui/index.jsx +4 -0
@@ 64,6 64,8 @@ import {
  FollowRecommendations,
  About,
  PrivacyPolicy,
  BookmarkFolders,
  BookmarkFolder,
} from "./util/async-components";
import { WrappedSwitch, WrappedRoute } from "./util/react_router_helpers";
// Dummy import, to make sure that <Status /> ends up in the application bundle.


@@ 212,7 214,9 @@ class SwitchingColumnsArea extends PureComponent {
          <WrappedRoute path='/notifications' component={Notifications} content={children} />
          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />

          <WrappedRoute path='/bookmarks/:folderId' component={BookmarkFolder} content={children} />
          <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
          <WrappedRoute path='/bookmark_folders' component={BookmarkFolders} content={children} />
          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />

          <WrappedRoute path='/start' component={FollowRecommendations} content={children} />

M app/javascript/flavours/glitch/features/ui/util/async-components.js => app/javascript/flavours/glitch/features/ui/util/async-components.js +12 -0
@@ 205,3 205,15 @@ export function About () {
export function PrivacyPolicy () {
  return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */"flavours/glitch/features/privacy_policy");
}

export function SelectBookmarkFolderModal () {
  return import(/*webpackChunkName: "flavours/glitch/async/modals/select_bookmark_folder_modal" */'flavours/glitch/features/select_bookmark_folder_modal');
}

export function BookmarkFolders () {
  return import(/*webpackChunkName: "flavours/glitch/async/bookmark_folders" */'flavours/glitch/features/bookmark_folders');
}

export function BookmarkFolder () {
  return import(/*webpackChunkName: "flavours/glitch/async/bookmark_folder" */'flavours/glitch/features/bookmark_folder');
}

M app/javascript/flavours/glitch/reducers/alerts.js => app/javascript/flavours/glitch/reducers/alerts.js +17 -14
@@ 1,4 1,4 @@
import { Map as ImmutableMap, List as ImmutableList } from "immutable";
import { List as ImmutableList } from "immutable";

import {
  ALERT_SHOW,


@@ 8,20 8,23 @@ import {

const initialState = ImmutableList([]);

let id = 0;

const addAlert = (state, alert) =>
  state.push({
    key: id++,
    ...alert,
  });

export default function alerts(state = initialState, action) {
  switch(action.type) {
    case ALERT_SHOW:
      return state.push(ImmutableMap({
        key: state.size > 0 ? state.last().get("key") + 1 : 0,
        title: action.title,
        message: action.message,
        message_values: action.message_values,
      }));
    case ALERT_DISMISS:
      return state.filterNot(item => item.get("key") === action.alert.key);
    case ALERT_CLEAR:
      return state.clear();
    default:
      return state;
  case ALERT_SHOW:
    return addAlert(state, action.alert);
  case ALERT_DISMISS:
    return state.filterNot(item => item.key === action.alert.key);
  case ALERT_CLEAR:
    return state.clear();
  default:
    return state;
  }
}

A app/javascript/flavours/glitch/reducers/bookmark_folder_editor.js => app/javascript/flavours/glitch/reducers/bookmark_folder_editor.js +54 -0
@@ 0,0 1,54 @@
import { Map as ImmutableMap } from 'immutable';

import {
  BOOKMARK_FOLDER_CREATE_REQUEST,
  BOOKMARK_FOLDER_CREATE_FAIL,
  BOOKMARK_FOLDER_CREATE_SUCCESS,
  BOOKMARK_FOLDER_UPDATE_REQUEST,
  BOOKMARK_FOLDER_UPDATE_FAIL,
  BOOKMARK_FOLDER_UPDATE_SUCCESS,
  BOOKMARK_FOLDER_EDITOR_RESET,
  BOOKMARK_FOLDER_EDITOR_SETUP,
  BOOKMARK_FOLDER_EDITOR_NAME_CHANGE,
} from '../actions/bookmark_folders';

const initialState = ImmutableMap({
  folderId: null,
  isSubmitting: false,
  isChanged: false,
  name: '',
});

export default function listEditorReducer(state = initialState, action) {
  switch(action.type) {
  case BOOKMARK_FOLDER_EDITOR_RESET:
    return initialState;
  case BOOKMARK_FOLDER_EDITOR_SETUP:
    return state.withMutations(map => {
      map.set('folderId', action.folder.get('id'));
      map.set('folder', action.folder.get('title'));
    });
  case BOOKMARK_FOLDER_EDITOR_NAME_CHANGE:
    return state.withMutations(map => {
      map.set('name', action.value);
      map.set('isChanged', true);
    });
  case BOOKMARK_FOLDER_CREATE_REQUEST:
  case BOOKMARK_FOLDER_UPDATE_REQUEST:
    return state.withMutations(map => {
      map.set('isSubmitting', true);
      map.set('isChanged', false);
    });
  case BOOKMARK_FOLDER_CREATE_FAIL:
  case BOOKMARK_FOLDER_UPDATE_FAIL:
    return state.set('isSubmitting', false);
  case BOOKMARK_FOLDER_CREATE_SUCCESS:
  case BOOKMARK_FOLDER_UPDATE_SUCCESS:
    return state.withMutations(map => {
      map.set('isSubmitting', false);
      map.set('folderId', action.folder.id);
    });
  default:
    return state;
  }
}

A app/javascript/flavours/glitch/reducers/bookmark_folders.js => app/javascript/flavours/glitch/reducers/bookmark_folders.js +34 -0
@@ 0,0 1,34 @@
import { Map as ImmutableMap, fromJS } from 'immutable';

import {
  BOOKMARK_FOLDERS_FETCH_SUCCESS,
  BOOKMARK_FOLDER_CREATE_SUCCESS,
  BOOKMARK_FOLDER_UPDATE_SUCCESS,
  BOOKMARK_FOLDER_DELETE_SUCCESS,
} from '../actions/bookmark_folders';

const initialState = ImmutableMap();

const normalizeBookmarkFolder = (state, folder) => state.set(folder.id, fromJS(folder));

const normalizeBookmarkFolders = (state, folders) => {
  folders.forEach(folder => {
    state = normalizeBookmarkFolder(state, folder);
  });

  return state;
};

export default function bookmarkFolders(state = initialState, action) {
  switch(action.type) {
  case BOOKMARK_FOLDER_CREATE_SUCCESS:
  case BOOKMARK_FOLDER_UPDATE_SUCCESS:
    return normalizeBookmarkFolder(state, action.folder);
  case BOOKMARK_FOLDERS_FETCH_SUCCESS:
    return normalizeBookmarkFolders(state, action.folders);
  case BOOKMARK_FOLDER_DELETE_SUCCESS:
    return state.set(action.id, false);
  default:
    return state;
  }
}

M app/javascript/flavours/glitch/reducers/index.ts => app/javascript/flavours/glitch/reducers/index.ts +4 -0
@@ 10,6 10,8 @@ import accounts_map from "./accounts_map";
import alerts from "./alerts";
import announcements from "./announcements";
import blocks from "./blocks";
import bookmarkFolders from "./bookmark_folders";
import bookmarkFolderEditor from "./bookmark_folder_editor";
import boosts from "./boosts";
import compose from "./compose";
import contexts from "./contexts";


@@ 49,6 51,8 @@ import user_lists from "./user_lists";

const reducers = {
  announcements,
  bookmarkFolders,
  bookmarkFolderEditor,
  dropdownMenu: dropdownMenuReducer,
  timelines,
  meta,

M app/javascript/flavours/glitch/reducers/status_lists.js => app/javascript/flavours/glitch/reducers/status_lists.js +79 -49
@@ 96,56 96,86 @@ const removeOneFromList = (state, listType, status) => {
  return state.updateIn([listType, "items"], (list) => list.delete(status.get("id")));
};

const addBookmark = (state, status, folderId) => {
  state = prependOneToList(state, 'bookmarks', status);
  return state.map((list, key) => {
    if (!key.startsWith('bookmarks:')) return list;
    return list.update('items', (list) => {
      if (key === `bookmarks:${folderId}`) {
        if (list.includes(status.get('id'))) {
          return list;
        } else {
          return ImmutableOrderedSet([status.get('id')]).union(list);
        }
      } else {
        return list.delete(status.get('id'));
      }
    });
  });
};

const removeBookmark = (state, status) => {
  return state.map((list, key) => {
    if (!key.startsWith('bookmarks')) return list;
    return list.update('items', (list) => list.delete(status.get('id')));
  });
};

export default function statusLists(state = initialState, action) {
  let key;
  switch(action.type) {
    case FAVOURITED_STATUSES_FETCH_REQUEST:
    case FAVOURITED_STATUSES_EXPAND_REQUEST:
      return state.setIn(["favourites", "isLoading"], true);
    case FAVOURITED_STATUSES_FETCH_FAIL:
    case FAVOURITED_STATUSES_EXPAND_FAIL:
      return state.setIn(["favourites", "isLoading"], false);
    case FAVOURITED_STATUSES_FETCH_SUCCESS:
      return normalizeList(state, "favourites", action.statuses, action.next);
    case FAVOURITED_STATUSES_EXPAND_SUCCESS:
      return appendToList(state, "favourites", action.statuses, action.next);
    case BOOKMARKED_STATUSES_FETCH_REQUEST:
    case BOOKMARKED_STATUSES_EXPAND_REQUEST:
      return state.setIn(["bookmarks", "isLoading"], true);
    case BOOKMARKED_STATUSES_FETCH_FAIL:
    case BOOKMARKED_STATUSES_EXPAND_FAIL:
      return state.setIn(["bookmarks", "isLoading"], false);
    case BOOKMARKED_STATUSES_FETCH_SUCCESS:
      return normalizeList(state, "bookmarks", action.statuses, action.next);
    case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
      return appendToList(state, "bookmarks", action.statuses, action.next);
    case TRENDS_STATUSES_FETCH_REQUEST:
    case TRENDS_STATUSES_EXPAND_REQUEST:
      return state.setIn(["trending", "isLoading"], true);
    case TRENDS_STATUSES_FETCH_FAIL:
    case TRENDS_STATUSES_EXPAND_FAIL:
      return state.setIn(["trending", "isLoading"], false);
    case TRENDS_STATUSES_FETCH_SUCCESS:
      return normalizeList(state, "trending", action.statuses, action.next);
    case TRENDS_STATUSES_EXPAND_SUCCESS:
      return appendToList(state, "trending", action.statuses, action.next);
    case FAVOURITE_SUCCESS:
      return prependOneToList(state, "favourites", action.status);
    case UNFAVOURITE_SUCCESS:
      return removeOneFromList(state, "favourites", action.status);
    case BOOKMARK_SUCCESS:
      return prependOneToList(state, "bookmarks", action.status);
    case UNBOOKMARK_SUCCESS:
      return removeOneFromList(state, "bookmarks", action.status);
    case PINNED_STATUSES_FETCH_SUCCESS:
      return normalizeList(state, "pins", action.statuses, action.next);
    case PIN_SUCCESS:
      return prependOneToList(state, "pins", action.status);
    case UNPIN_SUCCESS:
      return removeOneFromList(state, "pins", action.status);
    case ACCOUNT_BLOCK_SUCCESS:
    case ACCOUNT_MUTE_SUCCESS:
      return state.updateIn(["trending", "items"], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, "account"]) === action.relationship.id));
    default:
      return state;
  case FAVOURITED_STATUSES_FETCH_REQUEST:
  case FAVOURITED_STATUSES_EXPAND_REQUEST:
    return state.setIn(["favourites", "isLoading"], true);
  case FAVOURITED_STATUSES_FETCH_FAIL:
  case FAVOURITED_STATUSES_EXPAND_FAIL:
    return state.setIn(["favourites", "isLoading"], false);
  case FAVOURITED_STATUSES_FETCH_SUCCESS:
    return normalizeList(state, "favourites", action.statuses, action.next);
  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
    return appendToList(state, "favourites", action.statuses, action.next);
  case BOOKMARKED_STATUSES_FETCH_REQUEST:
  case BOOKMARKED_STATUSES_EXPAND_REQUEST:
    key = action.folderId ? `bookmarks:${action.folderId}`: "bookmarks";
    return state.setIn([key, "isLoading"], true);
  case BOOKMARKED_STATUSES_FETCH_FAIL:
  case BOOKMARKED_STATUSES_EXPAND_FAIL:
    key = action.folderId ? `bookmarks:${action.folderId}`: "bookmarks";
    return state.setIn([key, "isLoading"], false);
  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
    key = action.folderId ? `bookmarks:${action.folderId}`: "bookmarks";
    return normalizeList(state, key, action.statuses, action.next);
  case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
    key = action.folderId ? `bookmarks:${action.folderId}`: "bookmarks";
    return appendToList(state, key, action.statuses, action.next);
  case TRENDS_STATUSES_FETCH_REQUEST:
  case TRENDS_STATUSES_EXPAND_REQUEST:
    return state.setIn(["trending", "isLoading"], true);
  case TRENDS_STATUSES_FETCH_FAIL:
  case TRENDS_STATUSES_EXPAND_FAIL:
    return state.setIn(["trending", "isLoading"], false);
  case TRENDS_STATUSES_FETCH_SUCCESS:
    return normalizeList(state, "trending", action.statuses, action.next);
  case TRENDS_STATUSES_EXPAND_SUCCESS:
    return appendToList(state, "trending", action.statuses, action.next);
  case FAVOURITE_SUCCESS:
    return prependOneToList(state, "favourites", action.status);
  case UNFAVOURITE_SUCCESS:
    return removeOneFromList(state, "favourites", action.status);
  case BOOKMARK_SUCCESS:
    return addBookmark(state, action.status, action.folderId);
  case UNBOOKMARK_SUCCESS:
    return removeBookmark(state, action.status);
  case PINNED_STATUSES_FETCH_SUCCESS:
    return normalizeList(state, "pins", action.statuses, action.next);
  case PIN_SUCCESS:
    return prependOneToList(state, "pins", action.status);
  case UNPIN_SUCCESS:
    return removeOneFromList(state, "pins", action.status);
  case ACCOUNT_BLOCK_SUCCESS:
  case ACCOUNT_MUTE_SUCCESS:
    return state.updateIn(["trending", "items"], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, "account"]) === action.relationship.id));
  default:
    return state;
  }
}

M app/javascript/flavours/glitch/selectors/index.js => app/javascript/flavours/glitch/selectors/index.js +11 -21
@@ 1,4 1,4 @@
import { List as ImmutableList, Map as ImmutableMap } from "immutable";
import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from "immutable";
import { createSelector } from "reselect";

import { me } from "flavours/glitch/initial_state";


@@ 86,26 86,16 @@ export const makeGetPictureInPicture = () => {
  }));
};

const getAlertsBase = state => state.get("alerts");

export const getAlerts = createSelector([getAlertsBase], (base) => {
  let arr = [];

  base.forEach(item => {
    arr.push({
      message: item.get("message"),
      message_values: item.get("message_values"),
      title: item.get("title"),
      key: item.get("key"),
      dismissAfter: 5000,
      barStyle: {
        zIndex: 200,
      },
    });
  });
const ALERT_DEFAULTS = {
  dismissAfter: 5000,
  style: false,
};

  return arr;
});
export const getAlerts = createSelector(state => state.get("alerts"), alerts =>
  alerts.map(item => ({
    ...ALERT_DEFAULTS,
    ...item,
  })).toArray());

export const makeGetNotification = () => createSelector([
  (_, base)             => base,


@@ 141,5 131,5 @@ export const getAccountHidden = createSelector([
});

export const getStatusList = createSelector([
  (state, type) => state.getIn(["status_lists", type, "items"]),
  (state, type) => state.getIn(["status_lists", type, "items"], ImmutableOrderedSet()),
], (items) => items.toList());

A app/javascript/flavours/glitch/styles/components/bookmark_folders.scss => app/javascript/flavours/glitch/styles/components/bookmark_folders.scss +12 -0
@@ 0,0 1,12 @@
.select-bookmark-folder {
  background: $ui-base-color;
  flex-direction: column;
  border-radius: 8px;
  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
  width: 380px;
  overflow: hidden;

  @media screen and (width <= 420px) {
    width: 90%;
  }
}

M app/javascript/flavours/glitch/styles/components/index.scss => app/javascript/flavours/glitch/styles/components/index.scss +1 -0
@@ 24,3 24,4 @@
@import 'signed_out';
@import 'privacy_policy';
@import 'about';
@import 'bookmark_folders';

M app/javascript/flavours/glitch/styles/components/misc.scss => app/javascript/flavours/glitch/styles/components/misc.scss +59 -0
@@ 1614,6 1614,65 @@ button.icon-button.active i.fa-retweet {
  min-width: 75%;
}

.notification-list {
  position: fixed;
  bottom: 2rem;
  inset-inline-start: 0;
  z-index: 999;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.notification-bar {
  flex: 0 0 auto;
  position: relative;
  inset-inline-start: -100%;
  width: auto;
  padding: 15px;
  margin: 0;
  color: $white;
  background: rgba($black, 0.85);
  backdrop-filter: blur(8px);
  border: 1px solid rgba(lighten($classic-base-color, 4%), 0.85);
  border-radius: 8px;
  box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25),
    0 4px 6px -4px rgba($base-shadow-color, 0.25);
  cursor: default;
  transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
  transform: translateZ(0);
  font-size: 15px;
  line-height: 21px;

  &.notification-bar-active {
    inset-inline-start: 1rem;
  }
}

.notification-bar-title {
  margin-inline-end: 5px;
}

.notification-bar-title,
.notification-bar-action {
  font-weight: 700;
}

.notification-bar-action {
  text-transform: uppercase;
  margin-inline-start: 10px;
  cursor: pointer;
  color: $blurple-300;
  border-radius: 4px;
  padding: 0 4px;

  &:hover,
  &:focus,
  &:active {
    background: rgba($ui-base-color, 0.85);
  }
}

::-webkit-scrollbar-thumb {
  border-radius: 0;
}