@ -0,0 +1,40 @@ | |||
import api from '../api'; | |||
export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; | |||
export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; | |||
export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; | |||
export function fetchStatusCard(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchStatusCardRequest(id)); | |||
api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { | |||
dispatch(fetchStatusCardSuccess(id, response.data)); | |||
}).catch(error => { | |||
dispatch(fetchStatusCardFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchStatusCardRequest(id) { | |||
return { | |||
type: STATUS_CARD_FETCH_REQUEST, | |||
id | |||
}; | |||
}; | |||
export function fetchStatusCardSuccess(id, card) { | |||
return { | |||
type: STATUS_CARD_FETCH_SUCCESS, | |||
id, | |||
card | |||
}; | |||
}; | |||
export function fetchStatusCardFail(id, error) { | |||
return { | |||
type: STATUS_CARD_FETCH_FAIL, | |||
id, | |||
error | |||
}; | |||
}; |
@ -0,0 +1,96 @@ | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
const outerStyle = { | |||
display: 'flex', | |||
cursor: 'pointer', | |||
fontSize: '14px', | |||
border: '1px solid #363c4b', | |||
borderRadius: '4px', | |||
color: '#616b86', | |||
marginTop: '14px', | |||
textDecoration: 'none', | |||
overflow: 'hidden' | |||
}; | |||
const contentStyle = { | |||
flex: '2', | |||
padding: '8px', | |||
paddingLeft: '14px' | |||
}; | |||
const titleStyle = { | |||
display: 'block', | |||
fontWeight: '500', | |||
marginBottom: '5px', | |||
color: '#d9e1e8' | |||
}; | |||
const descriptionStyle = { | |||
color: '#d9e1e8' | |||
}; | |||
const imageOuterStyle = { | |||
flex: '1', | |||
background: '#373b4a' | |||
}; | |||
const imageStyle = { | |||
display: 'block', | |||
width: '100%', | |||
height: 'auto', | |||
margin: '0', | |||
borderRadius: '4px 0 0 4px' | |||
}; | |||
const hostStyle = { | |||
display: 'block', | |||
marginTop: '5px', | |||
fontSize: '13px' | |||
}; | |||
const getHostname = url => { | |||
const parser = document.createElement('a'); | |||
parser.href = url; | |||
return parser.hostname; | |||
}; | |||
const Card = React.createClass({ | |||
propTypes: { | |||
card: ImmutablePropTypes.map | |||
}, | |||
mixins: [PureRenderMixin], | |||
render () { | |||
const { card } = this.props; | |||
if (card === null) { | |||
return null; | |||
} | |||
let image = ''; | |||
if (card.get('image')) { | |||
image = ( | |||
<div style={imageOuterStyle}> | |||
<img src={card.get('image')} alt={card.get('title')} style={imageStyle} /> | |||
</div> | |||
); | |||
} | |||
return ( | |||
<a style={outerStyle} href={card.get('url')} className='status-card'> | |||
{image} | |||
<div style={contentStyle}> | |||
<strong style={titleStyle}>{card.get('title')}</strong> | |||
<p style={descriptionStyle}>{card.get('description')}</p> | |||
<span style={hostStyle}>{getHostname(card.get('url'))}</span> | |||
</div> | |||
</a> | |||
); | |||
} | |||
}); | |||
export default Card; |
@ -0,0 +1,8 @@ | |||
import { connect } from 'react-redux'; | |||
import Card from '../components/card'; | |||
const mapStateToProps = (state, { statusId }) => ({ | |||
card: state.getIn(['cards', statusId], null) | |||
}); | |||
export default connect(mapStateToProps)(Card); |
@ -0,0 +1,14 @@ | |||
import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; | |||
import Immutable from 'immutable'; | |||
const initialState = Immutable.Map(); | |||
export default function cards(state = initialState, action) { | |||
switch(action.type) { | |||
case STATUS_CARD_FETCH_SUCCESS: | |||
return state.set(action.id, Immutable.fromJS(action.card)); | |||
default: | |||
return state; | |||
} | |||
}; |
@ -0,0 +1,20 @@ | |||
# frozen_string_literal: true | |||
class PreviewCard < ApplicationRecord | |||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze | |||
belongs_to :status | |||
has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } | |||
validates :url, presence: true | |||
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES | |||
validates_attachment_size :image, less_than: 1.megabytes | |||
def save_with_optional_image! | |||
save! | |||
rescue ActiveRecord::RecordInvalid | |||
self.image = nil | |||
save! | |||
end | |||
end |
@ -0,0 +1,33 @@ | |||
# frozen_string_literal: true | |||
class FetchLinkCardService < BaseService | |||
def call(status) | |||
# Get first URL | |||
url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first | |||
return if url.nil? | |||
response = http_client.get(url) | |||
return if response.code != 200 | |||
page = Nokogiri::HTML(response.to_s) | |||
card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) | |||
card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content | |||
card.description = meta_property(page, 'og:description') || meta_property(page, 'description') | |||
card.image = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') | |||
card.save_with_optional_image! | |||
end | |||
private | |||
def http_client | |||
HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow | |||
end | |||
def meta_property(html, property) | |||
html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value | |||
end | |||
end |
@ -0,0 +1,5 @@ | |||
object @card | |||
attributes :url, :title, :description | |||
node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil } |
@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class LinkCrawlWorker | |||
include Sidekiq::Worker | |||
sidekiq_options retry: false | |||
def perform(status_id) | |||
FetchLinkCardService.new.call(Status.find(status_id)) | |||
rescue ActiveRecord::RecordNotFound | |||
true | |||
end | |||
end |
@ -0,0 +1,17 @@ | |||
class CreatePreviewCards < ActiveRecord::Migration[5.0] | |||
def change | |||
create_table :preview_cards do |t| | |||
t.integer :status_id | |||
t.string :url, null: false, default: '' | |||
# OpenGraph | |||
t.string :title, null: true | |||
t.string :description, null: true | |||
t.attachment :image | |||
t.timestamps | |||
end | |||
add_index :preview_cards, :status_id, unique: true | |||
end | |||
end |
@ -0,0 +1,5 @@ | |||
Fabricator(:preview_card) do | |||
status_id 1 | |||
url "MyString" | |||
html "MyText" | |||
end |
@ -0,0 +1,5 @@ | |||
require 'rails_helper' | |||
RSpec.describe PreviewCard, type: :model do | |||
end |
@ -1,5 +1,5 @@ | |||
require 'rails_helper' | |||
RSpec.describe Subscription, type: :model do | |||
pending "add some examples to (or delete) #{__FILE__}" | |||
end |