From f129d9c49bdd95eae9c8da5d2dae95d9af74c83a Mon Sep 17 00:00:00 2001 From: mgabdev <> Date: Thu, 29 Oct 2020 18:46:54 -0500 Subject: [PATCH] Added LinkTimeline and PreviewCard fetching by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added: - LinkTimeline and PreviewCard fetching by id --- app/controllers/api/v1/links_controller.rb | 17 +++ .../v1/timelines/preview_card_controller.rb | 62 ++++++++++ app/javascript/gabsocial/actions/links.js | 30 +++++ app/javascript/gabsocial/actions/streaming.js | 1 + app/javascript/gabsocial/actions/timelines.js | 1 + .../gabsocial/features/link_timeline.js | 108 ++++++++++++++++++ app/javascript/gabsocial/features/ui/ui.js | 4 + .../features/ui/util/async_components.js | 1 + app/javascript/gabsocial/pages/link_page.js | 46 ++++++++ app/javascript/gabsocial/reducers/index.js | 2 + app/javascript/gabsocial/reducers/links.js | 40 +++++++ .../rest/preview_card_serializer.rb | 2 +- config/routes.rb | 2 + config/settings.yml | 2 + streaming/index.js | 8 ++ 15 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/links_controller.rb create mode 100644 app/controllers/api/v1/timelines/preview_card_controller.rb create mode 100644 app/javascript/gabsocial/actions/links.js create mode 100644 app/javascript/gabsocial/features/link_timeline.js create mode 100644 app/javascript/gabsocial/pages/link_page.js create mode 100644 app/javascript/gabsocial/reducers/links.js diff --git a/app/controllers/api/v1/links_controller.rb b/app/controllers/api/v1/links_controller.rb new file mode 100644 index 00000000..87d0a10a --- /dev/null +++ b/app/controllers/api/v1/links_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::LinksController < Api::BaseController + before_action :require_user! + before_action :set_link + + def show + render json: @link, serializer: REST::PreviewCardSerializer + end + + private + + def set_link + @link = PreviewCard.find(params[:id]) + end + +end diff --git a/app/controllers/api/v1/timelines/preview_card_controller.rb b/app/controllers/api/v1/timelines/preview_card_controller.rb new file mode 100644 index 00000000..8f0d15c2 --- /dev/null +++ b/app/controllers/api/v1/timelines/preview_card_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::PreviewCardController < Api::BaseController + before_action :require_user! + before_action :set_link + before_action :set_statuses + + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + def show + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + end + + private + + def set_link + @link = PreviewCard.find(params[:id]) +end + + def set_statuses + @statuses = cached_link_statuses + end + + def cached_link_statuses + cache_collection link_statuses, Status + end + + def link_statuses + statuses = Status.joins( + "LEFT JOIN preview_cards_statuses ON statuses.id = preview_cards_statuses.status_id" + ).where("preview_cards_statuses.preview_card_id": params[:id]).paginate_by_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ).reject { |status| FeedManager.instance.filter?(:home, status, current_account.id) } + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def next_path + api_v1_timelines_preview_card_url params[:id], pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_preview_card_url params[:id], pagination_params(min_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/javascript/gabsocial/actions/links.js b/app/javascript/gabsocial/actions/links.js new file mode 100644 index 00000000..4954ef1f --- /dev/null +++ b/app/javascript/gabsocial/actions/links.js @@ -0,0 +1,30 @@ +import api from '../api' + +export const LINK_FETCH_REQUEST = 'LINK_FETCH_REQUEST' +export const LINK_FETCH_SUCCESS = 'LINK_FETCH_SUCCESS' +export const LINK_FETCH_FAIL = 'LINK_FETCH_FAIL' + +export const fetchLinkCard = (cardId) => (dispatch, getState) => { + dispatch(fetchLinkCardRequest(cardId)) + + api(getState).get(`/api/v1/links/${cardId}`).then(({ data }) => { + dispatch(fetchLinkCardSuccess(data)) + }) + .catch((err) => dispatch(fetchLinkCardFail(err))) +} + +export const fetchLinkCardRequest = (cardId) => ({ + type: LINK_FETCH_REQUEST, + cardId, +}) + +export const fetchLinkCardSuccess = (card) => ({ + type: LINK_FETCH_SUCCESS, + card, +}) + +export const fetchLinkCardFail = (error, cardId) => ({ + type: LINK_FETCH_FAIL, + error, + cardId, +}) \ No newline at end of file diff --git a/app/javascript/gabsocial/actions/streaming.js b/app/javascript/gabsocial/actions/streaming.js index ea43aecd..5530e8b9 100644 --- a/app/javascript/gabsocial/actions/streaming.js +++ b/app/javascript/gabsocial/actions/streaming.js @@ -52,6 +52,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, export const connectUserStream = () => connectTimelineStream('home', 'user'); export const connectProStream = () => connectTimelineStream('pro', 'pro'); +export const connectLinkStream = (linkId, accept) => connectTimelineStream(`link:${linkId}`, `link&linkId=${linkId}`, null, accept); export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`); diff --git a/app/javascript/gabsocial/actions/timelines.js b/app/javascript/gabsocial/actions/timelines.js index ddbcd914..300bb1bf 100644 --- a/app/javascript/gabsocial/actions/timelines.js +++ b/app/javascript/gabsocial/actions/timelines.js @@ -176,6 +176,7 @@ export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTim export const expandGroupTimeline = (id, { sortBy, maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { sort_by: sortBy, max_id: maxId, only_media: onlyMedia }, done); export const expandGroupFeaturedTimeline = (groupId, done = noOp) => expandTimeline(`group:${groupId}:pinned`, `/api/v1/timelines/group_pins/${groupId}`, {}, done); export const expandGroupCollectionTimeline = (collectionType, { sortBy, maxId } = {}, done = noOp) => expandTimeline(`group_collection:${collectionType}`, `/api/v1/timelines/group_collection/${collectionType}`, { sort_by: sortBy, max_id: maxId }, done); +export const expandLinkTimeline = (linkId, { maxId } = {}, done = noOp) => expandTimeline(`link:${linkId}`, `/api/v1/timelines/preview_card/${linkId}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, diff --git a/app/javascript/gabsocial/features/link_timeline.js b/app/javascript/gabsocial/features/link_timeline.js new file mode 100644 index 00000000..a580a491 --- /dev/null +++ b/app/javascript/gabsocial/features/link_timeline.js @@ -0,0 +1,108 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import ImmutablePropTypes from 'react-immutable-proptypes' +import ImmutablePureComponent from 'react-immutable-pure-component' +import { FormattedMessage } from 'react-intl' +import { connectLinkStream } from '../actions/streaming' +import { expandLinkTimeline } from '../actions/timelines' +import { fetchLinkCard } from '../actions/links' +import { openModal } from '../actions/modal' +import StatusList from '../components/status_list' +import ColumnIndicator from '../components/column_indicator' +import Button from '../components/button' +import Text from '../components/text' + +class LinkTimeline extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + } + + componentDidMount() { + this.handleConnect(this.props.params.id) + } + + componentWillUnmount() { + this.handleDisconnect() + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.id !== this.props.params.id) { + this.handleDisconnect() + this.handleConnect(nextProps.params.id) + } + } + + handleConnect(id) { + const { dispatch } = this.props + + dispatch(fetchLinkCard(id)) + dispatch(expandLinkTimeline(id)) + + this.disconnect = dispatch(connectLinkStream(id)) + } + + handleDisconnect() { + if (this.disconnect) { + this.disconnect() + this.disconnect = null + } + } + + handleLoadMore = (maxId) => { + const { id } = this.props.params + this.props.dispatch(expandLinkTimeline(id, { maxId })) + } + + render() { + const { + link, + items, + isFetched, + isLoading, + } = this.props + const { id } = this.props.params + + if (typeof link === 'undefined' && isLoading) { + return + } else if (!link) { + return + } + + const emptyMessage = ( +
+ No statuses with this url yet. +
+ ) + + return ( + + ) + } + +} + +const mapStateToProps = (state, props) => ({ + items: state.getIn(['links', 'items']), + link: state.getIn(['links', 'items', `${props.params.id}`]), + isFetched: state.getIn(['links', 'isFetched']), + isLoading: state.getIn(['links', 'isLoading']), +}) + +LinkTimeline.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + link: PropTypes.oneOfType([ + ImmutablePropTypes.map, + PropTypes.bool, + ]), + intl: PropTypes.object.isRequired, +} + +export default connect(mapStateToProps)(LinkTimeline) \ No newline at end of file diff --git a/app/javascript/gabsocial/features/ui/ui.js b/app/javascript/gabsocial/features/ui/ui.js index 3716ceaa..3b65971d 100644 --- a/app/javascript/gabsocial/features/ui/ui.js +++ b/app/javascript/gabsocial/features/ui/ui.js @@ -44,6 +44,7 @@ import ProPage from '../../pages/pro_page' import ExplorePage from '../../pages/explore_page' import NewsPage from '../../pages/news_page' import AboutPage from '../../pages/about_page' +import LinkPage from '../../pages/link_page' import { About, @@ -77,6 +78,7 @@ import { HomeTimeline, Investors, LikedStatuses, + LinkTimeline, ListCreate, ListsDirectory, ListEdit, @@ -213,6 +215,8 @@ class SwitchingArea extends React.PureComponent { + + diff --git a/app/javascript/gabsocial/features/ui/util/async_components.js b/app/javascript/gabsocial/features/ui/util/async_components.js index 7088260a..089154fd 100644 --- a/app/javascript/gabsocial/features/ui/util/async_components.js +++ b/app/javascript/gabsocial/features/ui/util/async_components.js @@ -61,6 +61,7 @@ export function HotkeysModal() { return import(/* webpackChunkName: "components/ export function Introduction() { return import(/* webpackChunkName: "features/introduction" */'../../introduction') } export function Investors() { return import(/* webpackChunkName: "features/about/investors" */'../../about/investors') } export function LinkFooter() { return import(/* webpackChunkName: "components/link_footer" */'../../../components/link_footer') } +export function LinkTimeline() { return import(/* webpackChunkName: "features/link_timeline" */'../../link_timeline') } export function ListAddUserModal() { return import(/* webpackChunkName: "features/list_add_user_modal" */'../../../components/modal/list_add_user_modal') } export function ListCreate() { return import(/* webpackChunkName: "features/list_create" */'../../list_create') } export function ListCreateModal() { return import(/* webpackChunkName: "components/list_create_modal" */'../../../components/modal/list_create_modal') } diff --git a/app/javascript/gabsocial/pages/link_page.js b/app/javascript/gabsocial/pages/link_page.js new file mode 100644 index 00000000..d1795f14 --- /dev/null +++ b/app/javascript/gabsocial/pages/link_page.js @@ -0,0 +1,46 @@ +import React from 'react' +import PropTypes from 'prop-types' +import PageTitle from '../features/ui/util/page_title' +import DefaultLayout from '../layouts/default_layout' +import { + LinkFooter, + TrendsPanel, + UserSuggestionsPanel, +} from '../features/ui/util/async_components' + +class LinkPage extends React.PureComponent { + + render() { + const { + children, + page, + title, + } = this.props + + return ( + + + {children} + + ) + } + +} + +LinkPage.propTypes = { + children: PropTypes.node.isRequired, + page: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +} + +export default LinkPage \ No newline at end of file diff --git a/app/javascript/gabsocial/reducers/index.js b/app/javascript/gabsocial/reducers/index.js index ee34ae40..92fa9ddf 100644 --- a/app/javascript/gabsocial/reducers/index.js +++ b/app/javascript/gabsocial/reducers/index.js @@ -15,6 +15,7 @@ import group_lists from './group_lists' import group_relationships from './group_relationships' import hashtags from './hashtags' import height_cache from './height_cache' +import links from './links.js' import lists from './lists' import listAdder from './list_adder' import listEditor from './list_editor' @@ -59,6 +60,7 @@ const reducers = { group_relationships, hashtags, height_cache, + links, lists, listAdder, listEditor, diff --git a/app/javascript/gabsocial/reducers/links.js b/app/javascript/gabsocial/reducers/links.js new file mode 100644 index 00000000..518f97f8 --- /dev/null +++ b/app/javascript/gabsocial/reducers/links.js @@ -0,0 +1,40 @@ +import { + Map as ImmutableMap, + List as ImmutableList, + fromJS, +} from 'immutable' +import { + LINK_FETCH_REQUEST, + LINK_FETCH_SUCCESS, + LINK_FETCH_FAIL, +} from '../actions/links' + +const initialState = ImmutableMap({ + isFetched: false, + isError: false, + isLoading: false, + items: ImmutableMap(), +}) + +export default function links(state = initialState, action) { + switch(action.type) { + case LINK_FETCH_REQUEST: + return state.set('isLoading', true) + case LINK_FETCH_SUCCESS: + return state.withMutations((mutable) => { + mutable.setIn(['items', `${action.card.id}`], fromJS(action.card)) + mutable.set('isLoading', false) + mutable.set('isFetched', false) + mutable.set('isError', false) + }) + case LINK_FETCH_FAIL: + return state.withMutations((mutable) => { + mutable.set('isLoading', false) + mutable.set('isFetched', false) + mutable.set('isError', true) + }) + default: + return state + } +} + diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb index 2df9d07a..81ffa5a5 100644 --- a/app/serializers/rest/preview_card_serializer.rb +++ b/app/serializers/rest/preview_card_serializer.rb @@ -3,7 +3,7 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer include RoutingHelper - attributes :url, :title, :description, :type, + attributes :id, :url, :title, :description, :type, :author_name, :author_url, :provider_name, :provider_url, :html, :width, :height, :image, :embed_url diff --git a/config/routes.rb b/config/routes.rb index 9b5a10a1..95cd6dc3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -331,10 +331,12 @@ Rails.application.routes.draw do resources :group, only: :show resources :group_collection, only: :show resources :group_pins, only: :show + resources :preview_card, only: :show resource :explore, only: :show, controller: :explore end resources :gab_trends, only: [:index] + resources :links, only: :show resources :shop, only: [:index] resources :streaming, only: [:index] resources :custom_emojis, only: [:index] diff --git a/config/settings.yml b/config/settings.yml index 80e42dc7..bc3bccfd 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -88,6 +88,8 @@ defaults: &defaults - welcome - bookmarks - suggestions + - link + - links disallowed_hashtags: # space separated string or list of hashtags without the hash bootstrap_timeline_accounts: '' activity_api_enabled: true diff --git a/streaming/index.js b/streaming/index.js index 22094852..f15ac9d8 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -623,6 +623,14 @@ const startWorker = (workerId) => { streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; + case 'link': + if (!location.query.linkId || location.query.linkId.length === 0) { + ws.close(); + return; + } + + streamFrom(`timeline:link:${location.query.linkId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + break; case 'hashtag:local': if (!location.query.tag || location.query.tag.length === 0) { ws.close();