Compare commits

...

49 Commits

Author SHA1 Message Date
Ariadne Conill 384ac613d8 reducers: add missing nullification of quote_id on reset 2022-12-26 05:54:44 +00:00
Ariadne Conill c6a4f42a37 compose reducer: fix cancelling reply/quote 2022-12-26 05:39:30 +00:00
Ariadne Conill 8b6e2ed562 Merge pull request 'add quote toots' (#36) from feature/quote into main
Reviewed-on: treehouse/mastodon#36
2022-12-26 04:52:20 +00:00
Ariadne Conill c4253c32a0 delete obsolete console.log statements 2022-12-26 04:38:32 +00:00
Ariadne Conill df07456f51 add reply and quote icons to the reply/quote indicators so people know what is going on 2022-12-26 04:38:21 +00:00
Ariadne Conill 7efe4bc5d3 activitypub: fix context extensions for quote_uri 2022-12-26 04:26:52 +00:00
Ariadne Conill f0065720d6 javascript: fix dispatch 2022-12-26 04:17:21 +00:00
Ariadne Conill d23cd8da00 add quote option to detailed statuses 2022-12-26 04:09:05 +00:00
Ariadne Conill 67a7b6067a components: detailed status: suppress cards on quote posts 2022-12-26 03:59:38 +00:00
Ariadne Conill 7d4127065d formatting helper: add the quote-inline hack for incompatible clients 2022-12-26 03:57:02 +00:00
Ariadne Conill e59c40eb68 activitypub: switch to fedibird:quoteUri 2022-12-26 03:18:03 +00:00
Ariadne Conill 5a8d4265ef glitch: fix up quote indicator 2022-12-26 02:53:01 +00:00
Ariadne Conill 214a4c9e6b glitch: reducers: set up correct state for quoting 2022-12-26 01:41:45 +00:00
Ariadne Conill 766a643811 add styles for quote indicator 2022-12-26 01:41:32 +00:00
Ariadne Conill a47d917072 flavors: glitch: add quote handling to status feature 2022-12-26 01:31:57 +00:00
Ariadne Conill c7e00d4c4e flavors: glitch: add quote indicator component 2022-12-26 01:27:58 +00:00
Ariadne Conill 9d4851e3cd glitch: actions: add quoteCompose and cancelQuoteCompose 2022-12-26 01:23:25 +00:00
Ariadne Conill adf1e9fc2e flavors: glitch: action bar: add quote button 2022-12-26 01:15:54 +00:00
Ariadne Conill 08aecd24ba flavors: glitch: show emojified display name in quotes 2022-12-26 00:59:04 +00:00
Ariadne Conill 005256ae8c javascript: glitch: start rendering quotes 2022-12-25 21:11:11 +00:00
Ariadne Conill 5be6a59f80 javascript: glitch: dont render cards if the status has a quote attached 2022-12-25 10:58:25 +00:00
Ariadne Conill 0d3df3e8cf javascript: glitch: pre-process misskey quotes to remove the URL part 2022-12-25 10:46:18 +00:00
Ariadne Conill b36e884cc1 activitypub: note serializer: support _misskey keys 2022-12-25 09:16:44 +00:00
Ariadne Conill 14d001574c activitypub: case transform: support _misskey keys without messing them up 2022-12-25 09:16:44 +00:00
Ariadne Conill 61565488a6 status: support either _misskey_quote or quoteUrl for fetching quotes 2022-12-25 09:16:42 +00:00
Ariadne Conill 8d86c77a58 db: add quote_id index 2022-12-25 04:20:17 +00:00
Ariadne Conill 36955a7a56 status: prevent recursion when serializing 2022-12-25 04:20:17 +00:00
Ariadne Conill 1cef1eb847 status: disallow quoting of non-public posts 2022-12-25 04:20:16 +00:00
Ariadne Conill a697e1da13 db: add quote_id migration 2022-12-25 03:58:18 +00:00
Ariadne Conill 0b48ae2c3c sanitizer config: add quote-inline span to allowlist 2022-12-25 03:58:18 +00:00
Ariadne Conill 1df2577b89 db: add quote_id to statuses table 2022-12-25 03:58:18 +00:00
Ariadne Conill 28fb5c8c52 rest: status serializer: include quote data 2022-12-25 03:58:18 +00:00
Ariadne Conill 56d4b04358 views: add quote status html view 2022-12-25 03:58:16 +00:00
Ariadne Conill ee98c0a6f8 services: post status service: add quote_id to status parameters 2022-12-25 03:25:22 +00:00
Ariadne Conill ba965bec3d statuses controller: accept quote_id parameter 2022-12-25 03:25:22 +00:00
Ariadne Conill 968bd6f0ee activitypub: resolve quoted objects when new create activities are received 2022-12-25 03:25:22 +00:00
Ariadne Conill 6b07407820 context helper: add quoteUrl as as:quoteUrl, even though its wrong 2022-12-25 03:25:22 +00:00
Ariadne Conill 0990d5ac75 activitypub: note serializer: begrudgingly serialize quotes using misskey quoteUrl 2022-12-25 03:25:22 +00:00
Ariadne Conill b1bce9d193 models: status: add support for quoting 2022-12-25 03:25:20 +00:00
Ariadne Conill 78e8693388 services: link fetcher: do not fetch links for quotes 2022-12-25 02:00:29 +00:00
Ariadne Conill 6be7cbb0bc Merge pull request 'docker-compose: emulate production traefik setup' (#35) from feature/docker-compose-traefik into main
Reviewed-on: treehouse/mastodon#35
2022-12-25 01:57:01 +00:00
Ariadne Conill 4bb0e9f9ed docker-compose: emulate production traefik setup 2022-12-25 01:56:20 +00:00
fox 772ba5aac3 update documentation
Reviewed-on: treehouse/mastodon#30
Co-authored-by: fox <fox@neko.business>
Co-committed-by: fox <fox@neko.business>
2022-12-18 03:13:45 +00:00
Rin aa24b2d072
make glitch style consistent 2022-12-08 18:26:32 +11:00
Rin 0cd76aa22a
make default masto style consistent 2022-12-08 18:24:41 +11:00
Rin 06a2259577
fix default masto style too 2022-12-08 16:58:48 +11:00
Rin 7bf26a1094
fix missing link style in admin.scss - actually this time 2022-12-08 16:31:42 +11:00
kouhai dev 41f55e5d8f update upstream glitch README/CONTRIBUTING 2022-11-27 23:49:18 -06:00
kouhai dev c75ac19c8a fix REDIS_URL unix socket path parsing for relative paths 2022-11-27 23:49:18 -06:00
46 changed files with 577 additions and 65 deletions

View File

@ -1,6 +1,7 @@
LOCAL_DOMAIN=localhost
ALTERNATE_DOMAINS=mastodon.internal
DB_HOST=$(pwd)/data/postgres
DB_USER=mastodon
DB_NAME=mastodon_dev
REDIS_URL=redis://$(pwd)/data/redis/redis-dev.sock
REDIS_URL=unix://./data/redis/redis-dev.sock

View File

@ -7,4 +7,4 @@ LOCAL_HTTPS=true
DB_HOST=$(pwd)/data/postgres
DB_USER=mastodon
DB_NAME=mastodon_dev
REDIS_URL=redis://$(pwd)/data/redis/redis-test.sock
REDIS_URL=unix://./data/redis/redis-dev.sock

View File

@ -1,6 +1,6 @@
# Contributing to Mastodon Glitch Edition #
# Contributing to Mastodon Glitch+Treehouse Edition #
Thank you for your interest in contributing to the `glitch-soc` project!
Thank you for your interest in contributing to the **Treehouse Mastodon** project!
Here are some guidelines, and ways you can help.
> (This document is a bit of a work-in-progress, so please bear with us.
@ -8,14 +8,19 @@ Here are some guidelines, and ways you can help.
## Planning ##
Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
Right now a lot of the planning for this project takes place in the `#fediverse`
channel of the Treehouse Discord, or through Gitea Issues.
We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
## Documentation ##
The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
The upstream Glitch documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
## Setup ##
For a some-batteries-required guide to setting up a development environment for this repository, read Rin's excellent
[SETUP.md](https://gitea.treehouse.systems/treehouse/mastodon/src/branch/main/SETUP.md).
## Frontend Development ##

View File

@ -1,14 +1,20 @@
# Mastodon Glitch Edition #
# Mastodon Glitch+Treehouse Edition #
> Now with automated deploys!
> Now with bunny ears!
[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate]
So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. Can you dig it?
[circleci]: https://circleci.com/gh/glitch-soc/mastodon
[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon
Specifically, this fork-of-a-fork is intended for Treehouse use only. Unless
otherwise communicated, we will not put effort into supporting other deployments
or upstreaming our patches.
So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it?
## Links
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
- You can view upstream Glitch's documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- Contributing guidelines are available [here](CONTRIBUTING.md).
## Known Deployments
- Treehouse Social: [social.treehouse.systems](https://social.treehouse.systems)
- VT Social: [vt.social](https://vt.social)
- Unstable Systems: [unstable.systems](https://unstable.systems)

View File

@ -17,31 +17,29 @@ Mastodon development requires the following:
### macOS
First, make sure you have Homebrew installed. Follow the instructions at
[brew.sh](https://brew.sh).
First, make sure you have Homebrew installed. Follow the instructions at [brew.sh](https://brew.sh).
Run the following to install all necessary packages:
```
brew install ruby@3.0 foreman node yarn postgresql redis
```
Ruby 3.0 is **keg-only** by default. Follow the instructions in the **Caveat**
to add it to your path.
Ruby 3.0 is **keg-only** by default. Follow the instructions in the **Caveat** to add it to your path.
### Linux
We will assume that you know how to locate the correct packages for your distro.
That said, some distros package `bundler` and `irb` separately. Make sure that
you also install these.
We will assume that you know how to locate the correct packages for your distro. That said, some distros package `bundler` and `irb` separately. Make sure that you also install these.
On Arch, you will need:
- `ruby`
- `ruby-bundler`
- `ruby-irb`
- `ruby-foreman`
- `redis`
- `postgresql`
- `yarn`
- `gmp`
- `libidn`
### Windows
@ -80,7 +78,7 @@ mkdir -p data/redis
redis-server ./redis-dev.conf
# [Optional] Stop Redis
kill "$(cat ./data/redis/redis-dev.pid)"
# kill "$(cat ./data/redis/redis-dev.pid)"
```
## Ruby
@ -111,11 +109,10 @@ bundle exec rake db:setup
## Running Mastodon
1. Run `export RAILS_ENV=development NODE_ENV=development`.
a. Put these in your shell's .rc, or a script you can source if you want to skip this step in the future.
- Put these in your shell's .rc, or a script you can source if you want to skip this step in the future.
2. Run `bundle exec rake assets:precompile`.
a. If this explodes, complaining about `Hash`, you'll need to `export NODE_OPTIONS=--openssl-legacy-provider`.
b. After doing this, you will need to run `bundle exec rake assets:clobber` and then re-run
`bundle exec rake assets:precompile`.
- If this explodes, complaining about `Hash`, you'll need to `export NODE_OPTIONS=--openssl-legacy-provider`.
- After doing this, you will need to run `bundle exec rake assets:clobber` and then re-run `bundle exec rake assets:precompile`.
3. Run `foreman start`
# Updates/Troubleshooting
@ -123,21 +120,24 @@ bundle exec rake db:setup
## RubyVM/DebugInspector Issues
Still unable to fix. Circumvent by removing `better_errors` and `binding_of_caller` from Gemfile.
Happy to troubleshoot with someone better with Ruby than us >_<'/.
## Webpack Issues
If Webpack compalins about being unable to find some assets or locales:
Try:
If Webpack compalins about being unable to find some assets or locales:
Try:
1. `rm -rf node_modules`
2. `yarn install`
If this doesn't help, try:
1. `yarn add webpack`
2. `git restore package.json yarn.lock`
3. `yarn install`
Then re-run `foreman`. No. We have no idea why this worked.
Then re-run `foreman start`. No. We have no idea why this worked.
# Need Help?
If the above instructions don't work, please contact @Rin here, or @tammy@social.treehouse.systems.

View File

@ -65,7 +65,8 @@ class Api::V1::StatusesController < Api::BaseController
poll: status_params[:poll],
content_type: status_params[:content_type],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true
with_rate_limit: true,
quote_id: status_params[:quote_id].presence
)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
@ -129,6 +130,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:language,
:scheduled_at,
:quote_id,
:content_type,
media_ids: [],
poll: [

View File

@ -24,6 +24,7 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' },
}.freeze
def full_context

View File

@ -15,7 +15,17 @@ module FormattingHelper
module_function :extract_status_plain_text
def status_content_format(status)
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
base = html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
if status.quote? && status.local?
after_html = begin
"<span class=\"quote-inline\"><a href=\"#{status.quote.to_log_permalink}\" class=\"status-link unhandled-link\" target=\"_blank\">#{status.quote.to_log_permalink}</a></span>"
end.html_safe # rubocop:disable Rails/OutputSafety
base + after_html
else
base
end
end
def rss_status_content_format(status)

View File

@ -82,6 +82,9 @@ export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@ -134,6 +137,25 @@ export function cancelReplyCompose() {
};
};
export function quoteCompose(status, router) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_QUOTE,
status: status,
});
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/publish');
}
};
};
export function cancelQuoteCompose() {
return {
type: COMPOSE_QUOTE_CANCEL,
};
};
export function resetCompose() {
return {
type: COMPOSE_RESET,
@ -187,6 +209,7 @@ export function submitCompose(routerHistory) {
status,
content_type: getState().getIn(['compose', 'content_type']),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
quote_id: getState().getIn(['compose', 'quote_id'], null),
media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText,

View File

@ -74,6 +74,8 @@ export function normalizeStatus(status, normalOldStatus, settings) {
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
normalStatus.quote = normalOldStatus.get('quote');
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
@ -83,6 +85,35 @@ export function normalizeStatus(status, normalOldStatus, settings) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
if (status.quote && status.quote.id) {
const quote_spoilerText = status.quote.spoiler_text || '';
const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const quote_emojiMap = makeEmojiMap(normalStatus.quote);
const quote_account_emojiMap = makeEmojiMap(status.quote.account);
const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
Array.from(docElem.querySelectorAll('span.quote-inline'), span => span.remove());
Array.from(docElem.querySelectorAll('p,br'), line => {
let parentNode = line.parentNode;
if (line.nextSibling) {
parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
}
});
let _contentHtml = docElem.textContent;
normalStatus.quote.contentHtml = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
normalStatus.quote_hidden = (quote_spoilerText.length > 0 || normalStatus.quote.sensitive) && autoHideCW(settings, quote_spoilerText);
// delete the quote link!!!!
let parentDocElem = domParser.parseFromString(normalStatus.contentHtml, 'text/html').documentElement;
Array.from(parentDocElem.querySelectorAll('span.quote-inline'), span => span.remove());
normalStatus.contentHtml = parentDocElem.children[1].innerHTML;
}
}
return normalStatus;

View File

@ -69,6 +69,7 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
onReply: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onBookmark: PropTypes.func,
@ -687,7 +688,7 @@ class Status extends ImmutablePureComponent {
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
background = attachments.getIn([0, 'preview_url']);
}
} else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
} else if (!status.get('quote') && status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
media.push(
<Card
onOpenMedia={this.handleOpenMedia}

View File

@ -25,6 +25,7 @@ const messages = defineMessages({
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@ -58,6 +59,7 @@ class StatusActionBar extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@ -124,6 +126,17 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
handleQuoteClick = () => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onQuote(this.props.status, this.context.router.history);
} else {
// TODO(ariadne): Add an interaction modal for quoting specifically.
this.props.onInteractionModal('reply', this.props.status);
}
}
handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e);
}
@ -307,6 +320,8 @@ class StatusActionBar extends ImmutablePureComponent {
obfuscateCount
/>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button' disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />

View File

@ -275,6 +275,34 @@ export default class StatusContent extends React.PureComponent {
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
});
let quote = '';
if (status.get('quote', null) !== null) {
let quoteStatus = status.get('quote');
let quoteStatusContent = { __html: quoteStatus.get('contentHtml') };
let quoteStatusAccount = quoteStatus.get('account');
let quoteStatusDisplayName = { __html: quoteStatusAccount.get('display_name_html') };
quote = (
<div class="status__quote">
<blockquote>
<bdi>
<span class="quote-display-name">
<Icon
fixedWidth
id='quote-right'
aria-hidden='true'
key='icon-quote-right' />
<strong class="display-name__html"
dangerouslySetInnerHTML={quoteStatusDisplayName} />
</span>
</bdi>
<div dangerouslySetInnerHTML={quoteStatusContent} />
</blockquote>
</div>
);
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@ -340,6 +368,7 @@ export default class StatusContent extends React.PureComponent {
{mentionsPlaceholder}
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}`}
@ -365,6 +394,7 @@ export default class StatusContent extends React.PureComponent {
onMouseUp={this.handleMouseUp}
tabIndex='0'
>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}-${rewriteMentions}`}
@ -385,6 +415,7 @@ export default class StatusContent extends React.PureComponent {
className='status__content'
tabIndex='0'
>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}`}

View File

@ -4,6 +4,7 @@ import { List as ImmutableList } from 'immutable';
import { makeGetStatus } from 'flavours/glitch/selectors';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@ -50,6 +51,8 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
@ -111,6 +114,23 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
});
},
onQuote (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
onConfirm: () => dispatch(quoteCompose(status, router)),
}));
} else {
dispatch(quoteCompose(status, router));
}
});
},
onModalReblog (status, privacy) {
if (status.get('reblogged')) {
dispatch(unreblog(status));

View File

@ -2,6 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import { defineMessages, injectIntl } from 'react-intl';
@ -309,6 +310,7 @@ class ComposeForm extends ImmutablePureComponent {
<WarningContainer />
<ReplyIndicatorContainer />
<QuoteIndicatorContainer />
<div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
<AutosuggestInput

View File

@ -0,0 +1,86 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
import Icon from 'flavours/glitch/components/icon';
import IconButton from 'flavours/glitch/components/icon_button';
import AttachmentList from 'flavours/glitch/components/attachment_list';
// Messages.
const messages = defineMessages({
cancel: {
defaultMessage: 'Cancel',
id: 'quote_indicator.cancel',
},
});
export default @injectIntl
class QuoteIndicator extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};
handleClick = () => {
const { onCancel } = this.props;
if (onCancel) {
onCancel();
}
}
// Rendering.
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const account = status.get('account');
const content = status.get('content');
const attachments = status.get('media_attachments');
// The result.
return (
<article className='quote-indicator'>
<header className='quote-indicator__header'>
<IconButton
className='quote-indicator__cancel'
icon='times'
onClick={this.handleClick}
title={intl.formatMessage(messages.cancel)}
inverted
/>
<Icon
className='quote-indicator__cancel icon-button inverted'
id='quote-right' />
{account && (
<AccountContainer
id={account}
small
/>
)}
</header>
<div
className='quote-indicator__content icon-button translate'
dangerouslySetInnerHTML={{ __html: content || '' }}
/>
{attachments.size > 0 && (
<AttachmentList
compact
media={attachments}
/>
)}
</article>
);
}
}

View File

@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
import Icon from 'flavours/glitch/components/icon';
import IconButton from 'flavours/glitch/components/icon_button';
import AttachmentList from 'flavours/glitch/components/attachment_list';
@ -58,6 +59,9 @@ class ReplyIndicator extends ImmutablePureComponent {
title={intl.formatMessage(messages.cancel)}
inverted
/>
<Icon
className='quote-indicator__cancel icon-button inverted'
id='reply' />
{account && (
<AccountContainer
id={account}

View File

@ -0,0 +1,27 @@
import { connect } from 'react-redux';
import { cancelQuoteCompose } from 'flavours/glitch/actions/compose';
import QuoteIndicator from '../components/quote_indicator';
const makeMapStateToProps = () => {
const mapStateToProps = state => {
const statusId = state.getIn(['compose', 'quote_id']);
const editing = false;
return {
status: state.getIn(['statuses', statusId]),
editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelQuoteCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);

View File

@ -18,6 +18,7 @@ const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent {
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onQuote: PropTypes.func.isRequired,
onMute: PropTypes.func,
onMuteConversation: PropTypes.func,
onBlock: PropTypes.func,
@ -81,6 +83,10 @@ class ActionBar extends React.PureComponent {
this.props.onBookmark(this.props.status, e);
}
handleQuoteClick = () => {
this.props.onQuote(this.props.status);
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@ -215,6 +221,7 @@ class ActionBar extends React.PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='quote-right-icon' disabled={!publicStatus} title={intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@ -210,7 +210,7 @@ class DetailedStatus extends ImmutablePureComponent {
);
mediaIcons.push('picture-o');
}
} else if (status.get('card')) {
} else if (!status.get('quote') && status.get('card')) {
media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
mediaIcons.push('link');
}

View File

@ -3,6 +3,7 @@ import DetailedStatus from '../components/detailed_status';
import { makeGetStatus } from 'flavours/glitch/selectors';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@ -33,6 +34,8 @@ import { showAlertForError } from 'flavours/glitch/actions/alerts';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
@ -68,6 +71,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
});
},
onQuote (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: () => dispatch(quoteCompose(status, router)),
}));
} else {
dispatch(quoteCompose(status, router));
}
});
},
onModalReblog (status, privacy) {
dispatch(reblog(status, privacy));
},

View File

@ -23,6 +23,7 @@ import {
} from 'flavours/glitch/actions/interactions';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@ -321,6 +322,21 @@ class Status extends ImmutablePureComponent {
}
}
handleQuoteClick = (status) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
dispatch(quoteCompose(status, this.context.router.history));
} else {
dispatch(openModal('INTERACTION', {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
}
}
handleModalReblog = (status, privacy) => {
const { dispatch } = this.props;
@ -679,6 +695,7 @@ class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onQuote={this.handleQuoteClick}
onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick}

View File

@ -6,6 +6,8 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@ -85,6 +87,7 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
quote_id: null,
is_submitting: false,
is_uploading: false,
is_changing_upload: false,
@ -173,6 +176,7 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
map.set('quote_id', null);
map.update(
'advanced_options',
map => map.mergeWith(overwrite, state.get('default_advanced_options'))
@ -410,6 +414,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('id', null);
map.set('in_reply_to', action.status.get('id'));
map.set('quote_id', null);
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.update(
@ -437,11 +442,29 @@ export default function compose(state = initialState, action) {
map.set('spoiler_text', '');
}
});
case COMPOSE_QUOTE:
return state.withMutations(map => {
map.set('id', null);
map.set('in_reply_to', null);
map.set('quote_id', action.status.get('id'));
map.set('text', '');
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.update(
'advanced_options',
map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') }))
);
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
});
case COMPOSE_REPLY_CANCEL:
state = state.setIn(['advanced_options', 'threaded_mode'], false);
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('quote_id', null);
if (defaultContentType) map.set('content_type', defaultContentType);
map.set('text', '');
map.set('spoiler', false);

View File

@ -10,17 +10,6 @@ $content-width: 840px;
width: 100%;
min-height: 100vh;
a {
color: $highlight-text-color;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
.sidebar-wrapper {
min-height: 100vh;
overflow: hidden;
@ -1692,6 +1681,15 @@ a.sparkline {
box-sizing: border-box;
min-height: 100%;
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
p {
margin-bottom: 20px;
unicode-bidi: plaintext;

View File

@ -123,6 +123,7 @@
}
}
.quote-indicator,
.reply-indicator {
margin: 0 0 10px;
border-radius: 4px;
@ -133,6 +134,7 @@
flex: 0 2 auto;
}
.quote-indicator__header,
.reply-indicator__header {
margin-bottom: 5px;
overflow: hidden;
@ -140,11 +142,13 @@
& > .account.small { color: $inverted-text-color; }
}
.quote-indicator__cancel,
.reply-indicator__cancel {
float: right;
line-height: 24px;
}
.quote-indicator__content,
.reply-indicator__content {
position: relative;
margin: 10px 0;

View File

@ -77,6 +77,11 @@
}
}
.status__quote {
padding-bottom: 0.5em;
}
.status__quote,
.status__content__text,
.e-content {
overflow: hidden;
@ -123,6 +128,11 @@
font-style: italic;
}
i[role=img] {
font-style: normal;
padding-right: 0.25em;
}
sub {
font-size: smaller;
vertical-align: sub;
@ -686,6 +696,7 @@
}
a.status__display-name,
.quote-indicator__display-name,
.reply-indicator__display-name,
.detailed-status__display-name,
.account__display-name {

View File

@ -14,6 +14,7 @@
.status__content a,
.link-footer a,
.quote-indicator__content a,
.reply-indicator__content a,
.status__content__read-more-button {
text-decoration: underline;

View File

@ -257,6 +257,7 @@ html {
}
// Change the background colors of status__content__spoiler-link
.quote-indicator__content .status__content__spoiler-link,
.reply-indicator__content .status__content__spoiler-link,
.status__content .status__content__spoiler-link {
background: $ui-base-color;
@ -662,6 +663,7 @@ html {
}
}
.quote-indicator,
.reply-indicator {
background: transparent;
border: 1px solid lighten($ui-base-color, 8%);
@ -673,6 +675,7 @@ html {
}
.status__content,
.quote-indicator__content,
.reply-indicator__content {
a {
color: $highlight-text-color;

View File

@ -1681,6 +1681,15 @@ a.sparkline {
box-sizing: border-box;
min-height: 100%;
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
p {
margin-bottom: 20px;
unicode-bidi: plaintext;

View File

@ -126,6 +126,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
quote: process_quote,
}
end
end
@ -426,4 +427,24 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll.reload
retry
end
def guess_quote_url
if @object["quoteUrl"] && !@object["quoteUrl"].empty?
@object["quoteUrl"]
elsif @object["_misskey_quote"] && !@object["_misskey_quote"].empty?
@object["_misskey_quote"]
else
nil
end
end
def process_quote
url = guess_quote_url
return nil if url.nil?
quote = ResolveURLService.new.call(url)
status_from_uri(quote.uri) if quote
rescue
nil
end
end

View File

@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
elsif value.start_with?('_')
value
else
value.underscore.camelize(:lower)
end

View File

@ -28,6 +28,7 @@
# edited_at :datetime
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
# quote_id :bigint(8)
#
class Status < ApplicationRecord
@ -61,6 +62,7 @@ class Status < ApplicationRecord
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
@ -70,6 +72,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@ -86,6 +89,7 @@ class Status < ApplicationRecord
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true
validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
accepts_nested_attributes_for :poll
@ -134,6 +138,17 @@ class Status < ApplicationRecord
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
],
quote: [
:application,
:tags,
:preview_cards,
:media_attachments,
:conversation,
:status_stat,
:preloadable_poll,
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
],
thread: { account: :account_stat }
delegate :domain, to: :account, prefix: true
@ -195,6 +210,14 @@ class Status < ApplicationRecord
!reblog_of_id.nil?
end
def quote?
!quote_id.nil? && quote
end
def quote_visibility
quote&.visibility
end
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end
@ -259,7 +282,7 @@ class Status < ApplicationRecord
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
end
def ordered_media_attachments

View File

@ -3,7 +3,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message, :quote_uri
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@ -11,6 +11,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:atom_uri, :in_reply_to_atom_uri,
:conversation
attribute :quote_uri, if: -> { object.quote? }
attribute :content
attribute :content_map, if: :language?
attribute :updated, if: :edited?
@ -149,6 +151,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
def quote_uri
ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
end
def local?
object.account.local?
end

View File

@ -184,3 +184,13 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
end
class REST::QuoteStatusSerializer < REST::StatusSerializer
attribute :quote do
nil
end
end
class REST::StatusSerializer < ActiveModel::Serializer
belongs_to :quote, serializer: REST::QuoteStatusSerializer
end

View File

@ -74,7 +74,7 @@ class FetchLinkCardService < BaseService
@status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
else
document = Nokogiri::HTML(@status.text)
links = document.css('a')
links = document.css(':not(.quote-inline) > a')
links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
end

View File

@ -21,6 +21,7 @@ class PostStatusService < BaseService
# @option [Doorkeeper::Application] :application
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [String] :quote_id
# @return [Status]
def call(account, options = {})
@account = account
@ -179,6 +180,7 @@ class PostStatusService < BaseService
application: @options[:application],
content_type: @options[:content_type] || @account.user&.setting_default_content_type,
rate_limit: @options[:with_rate_limit],
quote_id: @options[:quote_id],
}.compact
end

View File

@ -15,6 +15,9 @@
= account_action_button(status.account)
- if status.quote?
= render partial: "statuses/quote_status", locals: {status: status.quote}
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<

View File

@ -0,0 +1,35 @@
.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do
.status__avatar
%div
= image_tag status.account.avatar_static_url, width: 18, height: 18, alt: '', class: 'u-photo account__avatar'
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true)
&nbsp;
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
.status__content.emojify<
- if status.spoiler_text?
%p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }<
%span.p-summary> #{Formatter.instance.format_spoiler(status)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}" }
= Formatter.instance.format_in_quote(status, custom_emojify: true)
- if !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description, quote: true do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 60, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, maxDescription: 10, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true

View File

@ -27,6 +27,10 @@
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
- if status.quote?
= render partial: "statuses/quote_status", locals: {status: status.quote}
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<

View File

@ -0,0 +1,5 @@
class AddQuoteIdToStatuses < ActiveRecord::Migration[6.1]
def change
add_column :statuses, :quote_id, :bigint, null: true, default: nil
end
end

View File

@ -0,0 +1,7 @@
class AddIndexToStatusesQuoteId < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def change
add_index :statuses, :quote_id, algorithm: :concurrently
end
end

View File

@ -943,6 +943,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do
t.datetime "edited_at"
t.boolean "trendable"
t.bigint "ordered_media_attachment_ids", array: true
t.bigint "quote_id"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
@ -950,6 +951,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id", where: "(in_reply_to_account_id IS NOT NULL)"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)"
t.index ["quote_id"], name: "index_statuses_on_quote_id"
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
t.index ["uri"], name: "index_statuses_on_uri", unique: true, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)"
end

View File

@ -66,8 +66,16 @@ services:
healthcheck:
# prettier-ignore
test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:3000/health || exit 1']
ports:
- '127.0.0.1:3000:3000'
expose:
- 3000
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`social-dev.treehouse.systems`)
- traefik.http.routers.web.tls=true
- traefik.http.routers.web.tls.certresolver=le
- traefik.http.routers.web.tls.domains[0].main=social-dev.treehouse.systems
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.services.web.loadbalancer.server.port=3000
depends_on:
- db
- redis
@ -87,8 +95,16 @@ services:
healthcheck:
# prettier-ignore
test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1']
ports:
- '127.0.0.1:4000:4000'
expose:
- 4000
labels:
- traefik.enable=true
- 'traefik.http.routers.streaming.rule=Host(`social-dev.treehouse.systems`) && PathPrefix(`/api/v1/streaming/`)'
- traefik.http.routers.streaming.tls=true
- traefik.http.routers.streaming.tls.certresolver=le
- traefik.http.routers.streaming.tls.domains[0].main=social-dev.treehouse.systems
- traefik.http.routers.streaming.entrypoints=websecure
- traefik.http.services.streaming.loadbalancer.server.port=4000
depends_on:
- db
- redis

View File

@ -1,17 +1,36 @@
# frozen_string_literal: true
require 'pathname'
def setup_redis_env_url(prefix = nil, defaults = true)
prefix = prefix.to_s.upcase + '_' unless prefix.nil?
prefix = "#{prefix.to_s.upcase}_" unless prefix.nil?
prefix = '' if prefix.nil?
redis_url_key = "#{prefix}REDIS_URL"
return if ENV[prefix + 'REDIS_URL'].present?
if ENV[redis_url_key].present?
conn = +ENV["#{prefix}REDIS_URL"].sub(/redis:\/\//i, '')
password = ENV.fetch(prefix + 'REDIS_PASSWORD') { '' if defaults }
host = ENV.fetch(prefix + 'REDIS_HOST') { 'localhost' if defaults }
port = ENV.fetch(prefix + 'REDIS_PORT') { 6379 if defaults }
db = ENV.fetch(prefix + 'REDIS_DB') { 0 if defaults }
# Strip any prefixing `unix://`
unix = !conn.sub!(/^unix:\/\//i, '').nil?
# Strip any prefixing `./`
unix |= conn.sub!(/^(\.\/)+/, '')
unix |= conn.start_with?('/')
ENV[prefix + 'REDIS_URL'] = begin
if unix
pn = Pathname.new(conn)
pn = Pathname.getwd / pn if pn.relative?
ENV[redis_url_key] = "unix://#{pn}"
end
return
end
password = ENV.fetch("#{prefix}REDIS_PASSWORD") { '' if defaults }
host = ENV.fetch("#{prefix}REDIS_HOST") { 'localhost' if defaults }
port = ENV.fetch("#{prefix}REDIS_PORT") { 6379 if defaults }
db = ENV.fetch("#{prefix}REDIS_DB") { 0 if defaults }
ENV["#{prefix}REDIS_URL"] = begin
if [password, host, port, db].all?(&:nil?)
ENV['REDIS_URL']
else
@ -27,7 +46,7 @@ setup_redis_env_url(:cache, false)
setup_redis_env_url(:sidekiq, false)
namespace = ENV.fetch('REDIS_NAMESPACE', nil)
cache_namespace = namespace ? namespace + '_cache' : 'cache'
cache_namespace = namespace ? "#{namespace}_cache" : 'cache'
sidekiq_namespace = namespace
REDIS_CACHE_PARAMS = {

View File

@ -31,6 +31,7 @@ class Sanitize
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
next true if /^quote-inline$/.match?(e) # quote inline classes
end
node['class'] = class_list.join(' ')

View File

@ -18,7 +18,7 @@ const env = process.env.NODE_ENV || 'development';
const alwaysRequireAuth = process.env.LIMITED_FEDERATION_MODE === 'true' || process.env.WHITELIST_MODE === 'true' || process.env.AUTHORIZED_FETCH === 'true';
dotenv.config({
path: env === 'production' ? '.env.production' : '.env',
path: env === 'production' || env === 'development' ? `.env.${env}` : '.env',
});
log.level = process.env.LOG_LEVEL || 'verbose';