@@ -5,6 +5,10 @@ export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; | |||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; | |||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; | |||
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; | |||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; | |||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; | |||
export function fetchStatusRequest(id) { | |||
return { | |||
type: STATUS_FETCH_REQUEST, | |||
@@ -41,3 +45,37 @@ export function fetchStatusFail(id, error) { | |||
error: error | |||
}; | |||
}; | |||
export function deleteStatus(id) { | |||
return (dispatch, getState) => { | |||
dispatch(deleteStatusRequest(id)); | |||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => { | |||
dispatch(deleteStatusSuccess(id)); | |||
}).catch(error => { | |||
dispatch(deleteStatusFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function deleteStatusRequest(id) { | |||
return { | |||
type: STATUS_DELETE_REQUEST, | |||
id: id | |||
}; | |||
}; | |||
export function deleteStatusSuccess(id) { | |||
return { | |||
type: STATUS_DELETE_SUCCESS, | |||
id: id | |||
}; | |||
}; | |||
export function deleteStatusFail(id, error) { | |||
return { | |||
type: STATUS_DELETE_FAIL, | |||
id: id, | |||
error: error | |||
}; | |||
}; |
@@ -26,8 +26,16 @@ const IconButton = React.createClass({ | |||
}, | |||
render () { | |||
const style = { | |||
display: 'inline-block', | |||
fontSize: `${this.props.size}px`, | |||
width: `${this.props.size}px`, | |||
height: `${this.props.size}px`, | |||
lineHeight: `${this.props.size}px` | |||
}; | |||
return ( | |||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={{ display: 'inline-block', fontSize: `${this.props.size}px`, width: `${this.props.size}px`, height: `${this.props.size}px`, lineHeight: `${this.props.size}px`}}> | |||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}> | |||
<i className={`fa fa-fw fa-${this.props.icon}`}></i> | |||
</a> | |||
); | |||
@@ -2,11 +2,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import Avatar from './avatar'; | |||
import RelativeTimestamp from './relative_timestamp'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import IconButton from './icon_button'; | |||
import DisplayName from './display_name'; | |||
import MediaGallery from './media_gallery'; | |||
import VideoPlayer from './video_player'; | |||
import StatusContent from './status_content'; | |||
import StatusActionBar from './status_action_bar'; | |||
const Status = React.createClass({ | |||
@@ -19,23 +19,13 @@ const Status = React.createClass({ | |||
wrapped: React.PropTypes.bool, | |||
onReply: React.PropTypes.func, | |||
onFavourite: React.PropTypes.func, | |||
onReblog: React.PropTypes.func | |||
onReblog: React.PropTypes.func, | |||
onDelete: React.PropTypes.func, | |||
me: React.PropTypes.number | |||
}, | |||
mixins: [PureRenderMixin], | |||
handleReplyClick () { | |||
this.props.onReply(this.props.status); | |||
}, | |||
handleFavouriteClick () { | |||
this.props.onFavourite(this.props.status); | |||
}, | |||
handleReblogClick () { | |||
this.props.onReblog(this.props.status); | |||
}, | |||
handleClick () { | |||
const { status } = this.props; | |||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); | |||
@@ -96,11 +86,7 @@ const Status = React.createClass({ | |||
{media} | |||
<div style={{ marginTop: '10px', overflow: 'hidden' }}> | |||
<div style={{ float: 'left', marginRight: '10px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div> | |||
<div style={{ float: 'left', marginRight: '10px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div> | |||
<div style={{ float: 'left'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div> | |||
</div> | |||
<StatusActionBar {...this.props} /> | |||
</div> | |||
); | |||
} | |||
@@ -0,0 +1,67 @@ | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
import IconButton from './icon_button'; | |||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | |||
const StatusActionBar = React.createClass({ | |||
propTypes: { | |||
status: ImmutablePropTypes.map.isRequired, | |||
onReply: React.PropTypes.func, | |||
onFavourite: React.PropTypes.func, | |||
onReblog: React.PropTypes.func, | |||
onDelete: React.PropTypes.func | |||
}, | |||
mixins: [PureRenderMixin], | |||
handleReplyClick () { | |||
this.props.onReply(this.props.status); | |||
}, | |||
handleFavouriteClick () { | |||
this.props.onFavourite(this.props.status); | |||
}, | |||
handleReblogClick () { | |||
this.props.onReblog(this.props.status); | |||
}, | |||
handleDeleteClick(e) { | |||
e.preventDefault(); | |||
this.props.onDelete(this.props.status); | |||
}, | |||
render () { | |||
const { status, me } = this.props; | |||
let menu = ''; | |||
if (status.getIn(['account', 'id']) === me) { | |||
menu = ( | |||
<ul> | |||
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li> | |||
</ul> | |||
); | |||
} | |||
return ( | |||
<div style={{ marginTop: '10px', overflow: 'hidden' }}> | |||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div> | |||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div> | |||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div> | |||
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}> | |||
<Dropdown> | |||
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}> | |||
<i className='fa fa-fw fa-ellipsis-h' /> | |||
</DropdownTrigger> | |||
<DropdownContent>{menu}</DropdownContent> | |||
</Dropdown> | |||
</div> | |||
</div> | |||
); | |||
} | |||
}); | |||
export default StatusActionBar; |
@@ -9,7 +9,9 @@ const StatusList = React.createClass({ | |||
onReply: React.PropTypes.func, | |||
onReblog: React.PropTypes.func, | |||
onFavourite: React.PropTypes.func, | |||
onScrollToBottom: React.PropTypes.func | |||
onDelete: React.PropTypes.func, | |||
onScrollToBottom: React.PropTypes.func, | |||
me: React.PropTypes.number | |||
}, | |||
mixins: [PureRenderMixin], | |||
@@ -23,11 +25,13 @@ const StatusList = React.createClass({ | |||
}, | |||
render () { | |||
const { statuses, onScrollToBottom, ...other } = this.props; | |||
return ( | |||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}> | |||
<div> | |||
{this.props.statuses.map((status) => { | |||
return <Status key={status.get('id')} status={status} onReply={this.props.onReply} onReblog={this.props.onReblog} onFavourite={this.props.onFavourite} />; | |||
{statuses.map((status) => { | |||
return <Status key={status.get('id')} {...other} status={status} />; | |||
})} | |||
</div> | |||
</div> | |||
@@ -8,6 +8,7 @@ import { | |||
fetchAccountTimeline, | |||
expandAccountTimeline | |||
} from '../../actions/accounts'; | |||
import { deleteStatus } from '../../actions/statuses'; | |||
import { replyCompose } from '../../actions/compose'; | |||
import { favourite, reblog } from '../../actions/interactions'; | |||
import Header from './components/header'; | |||
@@ -72,6 +73,10 @@ const Account = React.createClass({ | |||
this.props.dispatch(favourite(status)); | |||
}, | |||
handleDelete (status) { | |||
this.props.dispatch(deleteStatus(status.get('id'))); | |||
}, | |||
handleScrollToBottom () { | |||
this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); | |||
}, | |||
@@ -87,7 +92,7 @@ const Account = React.createClass({ | |||
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}> | |||
<Header account={account} /> | |||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} /> | |||
<StatusList statuses={statuses} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} /> | |||
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} /> | |||
</div> | |||
); | |||
} | |||
@@ -4,29 +4,35 @@ import { replyCompose } from '../../../actions/compose'; | |||
import { reblog, favourite } from '../../../actions/interactions'; | |||
import { expandTimeline } from '../../../actions/timelines'; | |||
import { selectStatus } from '../../../reducers/timelines'; | |||
import { deleteStatus } from '../../../actions/statuses'; | |||
const mapStateToProps = function (state, props) { | |||
return { | |||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)) | |||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)), | |||
me: state.getIn(['timelines', 'me']) | |||
}; | |||
}; | |||
const mapDispatchToProps = function (dispatch, props) { | |||
return { | |||
onReply: function (status) { | |||
onReply (status) { | |||
dispatch(replyCompose(status)); | |||
}, | |||
onFavourite: function (status) { | |||
onFavourite (status) { | |||
dispatch(favourite(status)); | |||
}, | |||
onReblog: function (status) { | |||
onReblog (status) { | |||
dispatch(reblog(status)); | |||
}, | |||
onScrollToBottom: function () { | |||
onScrollToBottom () { | |||
dispatch(expandTimeline(props.type)); | |||
}, | |||
onDelete (status) { | |||
dispatch(deleteStatus(status.get('id'))); | |||
} | |||
}; | |||
}; | |||
@@ -13,7 +13,10 @@ import { | |||
ACCOUNT_TIMELINE_FETCH_FAIL, | |||
ACCOUNT_TIMELINE_EXPAND_FAIL | |||
} from '../actions/accounts'; | |||
import { STATUS_FETCH_FAIL } from '../actions/statuses'; | |||
import { | |||
STATUS_FETCH_FAIL, | |||
STATUS_DELETE_FAIL | |||
} from '../actions/statuses'; | |||
import Immutable from 'immutable'; | |||
const initialState = Immutable.List(); | |||
@@ -51,6 +54,7 @@ export default function notifications(state = initialState, action) { | |||
case ACCOUNT_TIMELINE_FETCH_FAIL: | |||
case ACCOUNT_TIMELINE_EXPAND_FAIL: | |||
case STATUS_FETCH_FAIL: | |||
case STATUS_DELETE_FAIL: | |||
return notificationFromError(state, action.error); | |||
case NOTIFICATION_DISMISS: | |||
return state.filterNot(item => item.get('key') === action.notification.key); | |||
@@ -16,7 +16,10 @@ import { | |||
ACCOUNT_TIMELINE_FETCH_SUCCESS, | |||
ACCOUNT_TIMELINE_EXPAND_SUCCESS | |||
} from '../actions/accounts'; | |||
import { STATUS_FETCH_SUCCESS } from '../actions/statuses'; | |||
import { | |||
STATUS_FETCH_SUCCESS, | |||
STATUS_DELETE_SUCCESS | |||
} from '../actions/statuses'; | |||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; | |||
import Immutable from 'immutable'; | |||
@@ -142,10 +145,28 @@ function updateTimeline(state, timeline, status) { | |||
}; | |||
function deleteStatus(state, id) { | |||
const status = state.getIn(['statuses', id]); | |||
if (!status) { | |||
return state; | |||
} | |||
// Remove references from timelines | |||
['home', 'mentions'].forEach(function (timeline) { | |||
state = state.update(timeline, list => list.filterNot(item => item === id)); | |||
}); | |||
// Remove references from account timelines | |||
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id)); | |||
// Remove reblogs of deleted status | |||
const references = state.get('statuses').filter(item => item.get('reblog') === id); | |||
references.forEach(referencingId => { | |||
state = deleteStatus(state, referencingId); | |||
}); | |||
// Remove normalized status | |||
return state.deleteIn(['statuses', id]); | |||
}; | |||
@@ -153,7 +174,7 @@ function normalizeAccount(state, account, relationship) { | |||
if (relationship) { | |||
state = normalizeRelationship(state, relationship); | |||
} | |||
return state.setIn(['accounts', account.get('id')], account); | |||
}; | |||
@@ -194,6 +215,7 @@ export default function timelines(state = initialState, action) { | |||
case TIMELINE_UPDATE: | |||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status)); | |||
case TIMELINE_DELETE: | |||
case STATUS_DELETE_SUCCESS: | |||
return deleteStatus(state, action.id); | |||
case REBLOG_SUCCESS: | |||
case FAVOURITE_SUCCESS: | |||
@@ -156,3 +156,64 @@ | |||
.transparent-background { | |||
background: image-url('void.png'); | |||
} | |||
.dropdown { | |||
display: inline-block; | |||
} | |||
.dropdown__content { | |||
display: none; | |||
position: absolute; | |||
} | |||
.dropdown--active .dropdown__content { | |||
display: block; | |||
z-index: 9999; | |||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); | |||
&:before { | |||
content: ""; | |||
display: block; | |||
position: absolute; | |||
width: 0; | |||
height: 0; | |||
border-style: solid; | |||
border-width: 0 4.5px 7.8px 4.5px; | |||
border-color: transparent transparent #d9e1e8 transparent; | |||
top: -7px; | |||
left: 8px; | |||
} | |||
ul { | |||
list-style: none; | |||
} | |||
li { | |||
&:first-child a { | |||
border-radius: 4px 4px 0 0; | |||
} | |||
&:last-child a { | |||
border-radius: 0 0 4px 4px; | |||
} | |||
&:first-child:last-child a { | |||
border-radius: 4px; | |||
} | |||
} | |||
a { | |||
font-size: 13px; | |||
display: block; | |||
padding: 6px 16px; | |||
width: 120px; | |||
text-decoration: none; | |||
background: #d9e1e8; | |||
color: #282c37; | |||
&:hover { | |||
background: #2b90d9; | |||
color: #d9e1e8; | |||
} | |||
} | |||
} |
@@ -1,4 +1,4 @@ | |||
class Api::V1::AppsController < ApplicationController | |||
class Api::V1::AppsController < ApiController | |||
respond_to :json | |||
def create | |||
@@ -1,12 +1,19 @@ | |||
!!! 5 | |||
%html | |||
%head | |||
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ | |||
%meta{:content => 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type'}/ | |||
%meta{:charset => 'utf-8'}/ | |||
%meta{:name => 'viewport', :content => 'width=device-width, initial-scale=1'}/ | |||
%meta{'http-equiv' => 'X-UA-Compatible', :content => 'IE=edge'}/ | |||
%title | |||
= "#{yield(:page_title)} - " if content_for?(:page_title) | |||
Mastodon | |||
= stylesheet_link_tag 'application', media: 'all' | |||
= csrf_meta_tags | |||
= yield :header_tags | |||
%body{ class: @body_classes } | |||
= content_for?(:content) ? yield(:content) : yield |