@ -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' | require 'rails_helper' | ||||
RSpec.describe Subscription, type: :model do | RSpec.describe Subscription, type: :model do | ||||
pending "add some examples to (or delete) #{__FILE__}" | |||||
end | end |