Commit 4deb41cb authored by Jeffrey Phillips Freeman's avatar Jeffrey Phillips Freeman 💥
Browse files

Added support for posting in markdown format as well as rendering rich text in general.

parent 094e98d5
......@@ -63,6 +63,8 @@ gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.4.3'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'kramdown', '~> 2.3'
gem 'kramdown-parser-gfm', '~> 1.1'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
......
......@@ -324,6 +324,10 @@ GEM
activerecord
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
kramdown (2.3.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.7.0)
......@@ -760,6 +764,8 @@ DEPENDENCIES
json-ld
json-ld-preloaded (~> 3.1)
kaminari (~> 1.2)
kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.1)
letter_opener (~> 1.7)
letter_opener_web (~> 1.4)
link_header (~> 0.0)
......
......@@ -34,6 +34,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
'setting_default_sensitive' => source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
'setting_default_language' => source_params.fetch(:language, @account.user.setting_default_language),
'setting_default_federation' => source_params.fetch(:federation, @account.user.setting_default_federation),
'setting_default_content_type' => source_params.fetch(:content_type, @account.user.setting_default_content_type),
}
end
end
......@@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true,
quote_id: status_params[:quote_id].presence,
content_type: status_params[:content_type], #'text/markdown'
local_only: status_params[:local_only])
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
......@@ -102,6 +103,7 @@ class Api::V1::StatusesController < Api::BaseController
:quote_id,
:expires_at,
:expires_action,
:content_type,
:local_only,
media_ids: [],
poll: [
......
......@@ -37,6 +37,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_default_sensitive,
:setting_default_language,
:setting_default_federation,
:setting_default_content_type,
:setting_unfollow_modal,
:setting_unsubscribe_modal,
:setting_boost_modal,
......
......@@ -51,6 +51,7 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE';
export const COMPOSE_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE';
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
......@@ -171,6 +172,7 @@ export function submitCompose(routerHistory) {
poll: getState().getIn(['compose', 'poll'], null),
quote_id: getState().getIn(['compose', 'quote_from'], null),
local_only: !getState().getIn(['compose', 'federation']),
content_type: getState().getIn(['compose', 'content_type']),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
......@@ -634,6 +636,13 @@ export function changeComposeFederation(value) {
};
};
export function changeComposeContentType(value) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
};
};
export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_EMOJI_INSERT,
......
......@@ -14,6 +14,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import CircleDropdownContainer from '../containers/circle_dropdown_container';
import FederationDropdownContainer from '../containers/federation_dropdown_container';
import ContentTypeDropdownContainer from '../containers/content_type_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
......@@ -47,6 +48,7 @@ class ComposeForm extends ImmutablePureComponent {
spoiler: PropTypes.bool,
privacy: PropTypes.string,
federation: PropTypes.bool,
contentType: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
......@@ -246,6 +248,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
<ContentTypeDropdownContainer/>
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<FederationDropdownContainer />
......
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({
plain: { id: 'content_type.plain.short', defaultMessage: 'Plain Text' },
markdown: { id: 'content_type.markdown.short', defaultMessage: 'Markdown' },
change_content_type: { id: 'content_type.change', defaultMessage: 'Adjust status content type' },
});
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
class ContentTypeDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
state = {
mounted: false,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null;
switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
break;
case 'Home':
element = this.node.firstChild;
break;
case 'End':
element = this.node.lastChild;
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
}
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
this.setState({ mounted: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
setFocusRef = c => {
this.focusedItem = c;
}
render () {
const { mounted } = this.state;
const { style, items, placement, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, zIndex: 2 }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} fixedWidth />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
}
}
export default @injectIntl
class ContentTypeDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
placement: 'bottom',
};
handleToggle = ({ target }) => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
}
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
}
handleKeyDown = e => {
switch(e.key) {
case 'Escape':
this.handleClose();
break;
}
}
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
}
handleChange = value => {
this.props.onChange(value);
}
componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'file-text', value: 'text/plain', text: formatMessage(messages.plain), meta: null },
{ icon: 'arrow-circle-down', value: 'text/markdown', text: formatMessage(messages.markdown), meta: null },
];
}
render () {
const { value, intl } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={(valueOption ? valueOption.icon : 'file')}
title={intl.formatMessage(messages.change_content_type)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement={placement} target={this}>
<ContentTypeDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
placement={placement}
/>
</Overlay>
</div>
);
}
}
......@@ -18,6 +18,7 @@ const mapStateToProps = state => ({
spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
federation: state.getIn(['compose', 'federation']),
contentType: state.getIn(['compose', 'content_type']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),
......
import { connect } from 'react-redux';
import ContentTypeDropdown from '../components/content_type_dropdown';
import { changeComposeContentType } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'content_type']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeContentType(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
});
export default connect(mapStateToProps, mapDispatchToProps)(ContentTypeDropdown);
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "الرد في الحين سوف يُعيد كتابة الرسالة التي أنت بصدد كتابتها. متأكد من أنك تريد المواصلة؟",
"confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "احذف المحادثة",
"conversation.mark_as_read": "اعتبرها كمقروءة",
"conversation.open": "اعرض المحادثة",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Responder agora va sobrescribir el mensaxe que tas componiendo anguaño. ¿De xuru que quies siguir?",
"confirmations.unfollow.confirm": "Dexar de siguir",
"confirmations.unfollow.message": "¿De xuru que quies dexar de siguir a {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Delete conversation",
"conversation.mark_as_read": "Mark as read",
"conversation.open": "View conversation",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Delete conversation",
"conversation.mark_as_read": "Mark as read",
"conversation.open": "View conversation",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "এখন মতামত লিখতে গেলে আপনার এখন যেটা লিখছেন সেটা মুছে যাবে। আপনি নি নিশ্চিত এটা করতে চান ?",
"confirmations.unfollow.confirm": "অনুসরণ করা বাতিল করতে",
"confirmations.unfollow.message": "আপনি কি নিশ্চিত {name} কে আর অনুসরণ করতে চান না ?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "কথোপকথন মুছে ফেলুন",
"conversation.mark_as_read": "পঠিত হিসেবে চিহ্নিত করুন",
"conversation.open": "কথপোকথন দেখান",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Respont bremañ a zilamo ar gemennadenn emaoc'h o skrivañ. Sur e oc'h e fell deoc'h kenderc'hel ganti?",
"confirmations.unfollow.confirm": "Diheuliañ",
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Dilemel ar gaozeadenn",
"conversation.mark_as_read": "Merkañ evel lennet",
"conversation.open": "Gwelout ar gaozeadenn",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Responen ara es sobreescriurà el missatge que estàs editant. Estàs segur que vols continuar?",
"confirmations.unfollow.confirm": "Deixa de seguir",
"confirmations.unfollow.message": "Estàs segur que vols deixar de seguir {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Elimina la conversa",
"conversation.mark_as_read": "Marca com a llegida",
"conversation.open": "Veure conversa",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Risponde avà sguasserà u missaghju chì scrivite. Site sicuru·a chì vulete cuntinuà?",
"confirmations.unfollow.confirm": "Disabbunassi",
"confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Sguassà a cunversazione",
"conversation.mark_as_read": "Marcà cum'è lettu",
"conversation.open": "Vede a cunversazione",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Odpověď přepíše vaši rozepsanou zprávu. Opravdu chcete pokračovat?",
"confirmations.unfollow.confirm": "Přestat sledovat",
"confirmations.unfollow.message": "Opravdu chcete uživatele {name} přestat sledovat?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Smazat konverzaci",
"conversation.mark_as_read": "Označit jako přečtenou",
"conversation.open": "Zobrazit konverzaci",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n sicr yr ydych am barhau?",
"confirmations.unfollow.confirm": "Dad-ddilynwch",
"confirmations.unfollow.message": "Ydych chi'n sicr eich bod am ddad-ddilyn {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Dileu sgwrs",
"conversation.mark_as_read": "Nodi fel wedi'i ddarllen",
"conversation.open": "Gweld sgwrs",
......
......@@ -123,6 +123,9 @@
"confirmations.reply.message": "Hvis du svarer nu vil du overskrive den besked du er ved at skrive. Er du sikker på, du vil fortsætte?",
"confirmations.unfollow.confirm": "Følg ikke længere",
"confirmations.unfollow.message": "Er du sikker på, du ikke længere vil følge {name}?",
"content_type.change": "Adjust status content type",
"content_type.markdown.short": "Markdown",
"content_type.plain.short": "Plain Text",
"conversation.delete": "Slet samtale",
"conversation.mark_as_read": "Marker som læst",
"conversation.open": "Vis samtale",
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment