You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

359 lines
14 KiB

  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import IconButton from './icon_button';
  5. import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
  6. import { defineMessages, injectIntl } from 'react-intl';
  7. import ImmutablePureComponent from 'react-immutable-pure-component';
  8. import { me } from 'flavours/glitch/initial_state';
  9. import RelativeTimestamp from './relative_timestamp';
  10. import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
  11. import classNames from 'classnames';
  12. import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
  13. const messages = defineMessages({
  14. delete: { id: 'status.delete', defaultMessage: 'Delete' },
  15. redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
  16. edit: { id: 'status.edit', defaultMessage: 'Edit' },
  17. direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
  18. mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
  19. mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
  20. block: { id: 'account.block', defaultMessage: 'Block @{name}' },
  21. reply: { id: 'status.reply', defaultMessage: 'Reply' },
  22. share: { id: 'status.share', defaultMessage: 'Share' },
  23. more: { id: 'status.more', defaultMessage: 'More' },
  24. replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
  25. reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
  26. reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
  27. cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
  28. cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
  29. favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
  30. bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
  31. open: { id: 'status.open', defaultMessage: 'Expand this status' },
  32. report: { id: 'status.report', defaultMessage: 'Report @{name}' },
  33. muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
  34. unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
  35. pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
  36. unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
  37. embed: { id: 'status.embed', defaultMessage: 'Embed' },
  38. admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
  39. admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
  40. admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
  41. copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
  42. hide: { id: 'status.hide', defaultMessage: 'Hide post' },
  43. edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
  44. filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
  45. openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
  46. });
  47. class StatusActionBar extends ImmutablePureComponent {
  48. static contextTypes = {
  49. router: PropTypes.object,
  50. identity: PropTypes.object,
  51. };
  52. static propTypes = {
  53. status: ImmutablePropTypes.map.isRequired,
  54. onReply: PropTypes.func,
  55. onFavourite: PropTypes.func,
  56. onReblog: PropTypes.func,
  57. onDelete: PropTypes.func,
  58. onDirect: PropTypes.func,
  59. onMention: PropTypes.func,
  60. onMute: PropTypes.func,
  61. onBlock: PropTypes.func,
  62. onReport: PropTypes.func,
  63. onEmbed: PropTypes.func,
  64. onMuteConversation: PropTypes.func,
  65. onPin: PropTypes.func,
  66. onBookmark: PropTypes.func,
  67. onFilter: PropTypes.func,
  68. onAddFilter: PropTypes.func,
  69. onInteractionModal: PropTypes.func,
  70. withDismiss: PropTypes.bool,
  71. withCounters: PropTypes.bool,
  72. showReplyCount: PropTypes.bool,
  73. scrollKey: PropTypes.string,
  74. intl: PropTypes.object.isRequired,
  75. };
  76. // Avoid checking props that are functions (and whose equality will always
  77. // evaluate to false. See react-immutable-pure-component for usage.
  78. updateOnProps = [
  79. 'status',
  80. 'showReplyCount',
  81. 'withCounters',
  82. 'withDismiss',
  83. ];
  84. handleReplyClick = () => {
  85. const { signedIn } = this.context.identity;
  86. if (signedIn) {
  87. this.props.onReply(this.props.status, this.context.router.history);
  88. } else {
  89. this.props.onInteractionModal('reply', this.props.status);
  90. }
  91. };
  92. handleShareClick = () => {
  93. navigator.share({
  94. text: this.props.status.get('search_index'),
  95. url: this.props.status.get('url'),
  96. });
  97. };
  98. handleFavouriteClick = (e) => {
  99. const { signedIn } = this.context.identity;
  100. if (signedIn) {
  101. this.props.onFavourite(this.props.status, e);
  102. } else {
  103. this.props.onInteractionModal('favourite', this.props.status);
  104. }
  105. };
  106. handleReblogClick = e => {
  107. const { signedIn } = this.context.identity;
  108. if (signedIn) {
  109. this.props.onReblog(this.props.status, e);
  110. } else {
  111. this.props.onInteractionModal('reblog', this.props.status);
  112. }
  113. };
  114. handleBookmarkClick = (e) => {
  115. this.props.onBookmark(this.props.status, e);
  116. };
  117. handleDeleteClick = () => {
  118. this.props.onDelete(this.props.status, this.context.router.history);
  119. };
  120. handleRedraftClick = () => {
  121. this.props.onDelete(this.props.status, this.context.router.history, true);
  122. };
  123. handleEditClick = () => {
  124. this.props.onEdit(this.props.status, this.context.router.history);
  125. };
  126. handlePinClick = () => {
  127. this.props.onPin(this.props.status);
  128. };
  129. handleMentionClick = () => {
  130. this.props.onMention(this.props.status.get('account'), this.context.router.history);
  131. };
  132. handleDirectClick = () => {
  133. this.props.onDirect(this.props.status.get('account'), this.context.router.history);
  134. };
  135. handleMuteClick = () => {
  136. this.props.onMute(this.props.status.get('account'));
  137. };
  138. handleBlockClick = () => {
  139. this.props.onBlock(this.props.status);
  140. };
  141. handleOpen = () => {
  142. let state = { ...this.context.router.history.location.state };
  143. if (state.mastodonModalKey) {
  144. this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
  145. } else {
  146. state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
  147. this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, state);
  148. }
  149. };
  150. handleEmbed = () => {
  151. this.props.onEmbed(this.props.status);
  152. };
  153. handleReport = () => {
  154. this.props.onReport(this.props.status);
  155. };
  156. handleConversationMuteClick = () => {
  157. this.props.onMuteConversation(this.props.status);
  158. };
  159. handleCopy = () => {
  160. const url = this.props.status.get('url');
  161. navigator.clipboard.writeText(url);
  162. };
  163. handleHideClick = () => {
  164. this.props.onFilter();
  165. };
  166. handleFilterClick = () => {
  167. this.props.onAddFilter(this.props.status);
  168. };
  169. render () {
  170. const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
  171. const { permissions } = this.context.identity;
  172. const anonymousAccess = !me;
  173. const mutingConversation = status.get('muted');
  174. const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
  175. const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
  176. const writtenByMe = status.getIn(['account', 'id']) === me;
  177. const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
  178. let menu = [];
  179. let reblogIcon = 'retweet';
  180. let replyIcon;
  181. let replyTitle;
  182. menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
  183. if (publicStatus && isRemote) {
  184. menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
  185. }
  186. menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
  187. if (publicStatus) {
  188. menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
  189. }
  190. menu.push(null);
  191. if (writtenByMe && pinnableStatus) {
  192. menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
  193. menu.push(null);
  194. }
  195. if (writtenByMe || withDismiss) {
  196. menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
  197. menu.push(null);
  198. }
  199. if (writtenByMe) {
  200. menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
  201. menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
  202. menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
  203. } else {
  204. menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
  205. menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
  206. menu.push(null);
  207. if (!this.props.onFilter) {
  208. menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
  209. menu.push(null);
  210. }
  211. menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
  212. menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
  213. menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
  214. if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
  215. menu.push(null);
  216. if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
  217. if (accountAdminLink !== undefined) {
  218. menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
  219. }
  220. if (statusAdminLink !== undefined) {
  221. menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
  222. }
  223. }
  224. if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
  225. const domain = status.getIn(['account', 'acct']).split('@')[1];
  226. menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
  227. }
  228. }
  229. }
  230. if (status.get('in_reply_to_id', null) === null) {
  231. replyIcon = 'comment';
  232. replyTitle = intl.formatMessage(messages.reply);
  233. } else {
  234. replyIcon = 'comments';
  235. replyTitle = intl.formatMessage(messages.replyAll);
  236. }
  237. const shareButton = ('share' in navigator) && publicStatus && (
  238. <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
  239. );
  240. const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
  241. let reblogTitle = '';
  242. if (status.get('reblogged')) {
  243. reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
  244. } else if (publicStatus) {
  245. reblogTitle = intl.formatMessage(messages.reblog);
  246. } else if (reblogPrivate) {
  247. reblogTitle = intl.formatMessage(messages.reblog_private);
  248. } else {
  249. reblogTitle = intl.formatMessage(messages.cannot_reblog);
  250. }
  251. const filterButton = this.props.onFilter && (
  252. <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
  253. );
  254. return (
  255. <div className='status__action-bar'>
  256. <div className='status__action-bar__buttons'>
  257. <IconButton
  258. className='status__action-bar-button'
  259. title={replyTitle}
  260. icon={replyIcon}
  261. onClick={this.handleReplyClick}
  262. counter={status.get('replies_count')}
  263. obfuscateCount={showReplyCount}
  264. />
  265. <IconButton
  266. className={classNames('status__action-bar-button', { reblogPrivate })}
  267. disabled={!publicStatus && !reblogPrivate}
  268. active={status.get('reblogged')}
  269. title={reblogTitle}
  270. icon={reblogIcon}
  271. onClick={this.handleReblogClick}
  272. counter={status.get('reblogs_count')}
  273. obfuscateCount={showReplyCount}
  274. />
  275. <IconButton
  276. className='status__action-bar-button star-icon'
  277. animate active={status.get('favourited')}
  278. title={intl.formatMessage(messages.favourite)}
  279. icon='heart' onClick={this.handleFavouriteClick}
  280. counter={status.get('favourites_count')}
  281. obfuscateCount={showReplyCount}
  282. />
  283. {shareButton}
  284. <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
  285. {filterButton}
  286. <div className='status__action-bar-dropdown'>
  287. <DropdownMenuContainer
  288. scrollKey={scrollKey}
  289. disabled={anonymousAccess}
  290. status={status}
  291. items={menu}
  292. icon='ellipsis-h'
  293. size={18}
  294. direction='right'
  295. ariaLabel={intl.formatMessage(messages.more)}
  296. />
  297. </div>
  298. </div>
  299. <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
  300. <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
  301. </a>
  302. </div>
  303. );
  304. }
  305. }
  306. export default injectIntl(StatusActionBar);