diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 19504169..4e59e430 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -56,6 +56,7 @@ const pxStringToNumber = (str) => { const PostStatusForm = { props: [ 'replyTo', + 'quoteId', 'repliedUser', 'attentions', 'copyMessageScope', @@ -99,12 +100,12 @@ const PostStatusForm = { this.updateIdempotencyKey() this.resize(this.$refs.textarea) - if (this.replyTo) { + if (this.replyTo || this.quoteId) { const textLength = this.$refs.textarea.value.length this.$refs.textarea.setSelectionRange(textLength, textLength) } - if (this.replyTo || this.autoFocus) { + if (this.replyTo || this.quoteId || this.autoFocus) { this.$refs.textarea.focus() } }, @@ -112,7 +113,7 @@ const PostStatusForm = { const preset = this.$route.query.message let statusText = preset || '' - if (this.replyTo) { + if (this.replyTo || this.quoteId) { const currentUser = this.$store.state.users.currentUser statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) } @@ -314,6 +315,7 @@ const PostStatusForm = { media: newStatus.files, store: this.$store, inReplyToStatusId: this.replyTo, + quoteId: this.quoteId, contentType: newStatus.contentType, poll, idempotencyKey: this.idempotencyKey @@ -347,6 +349,7 @@ const PostStatusForm = { media: [], store: this.$store, inReplyToStatusId: this.replyTo, + quoteId: this.quoteId, contentType: newStatus.contentType, poll: {}, preview: true diff --git a/src/components/quote_button/quote_button.js b/src/components/quote_button/quote_button.js new file mode 100644 index 00000000..f5bf7e3a --- /dev/null +++ b/src/components/quote_button/quote_button.js @@ -0,0 +1,16 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faQuoteLeft } from '@fortawesome/free-solid-svg-icons' + +library.add(faQuoteLeft) + +const QuoteButton = { + name: 'QuoteButton', + props: ['status', 'quoting', 'visibility'], + computed: { + loggedIn () { + return !!this.$store.state.users.currentUser + } + } +} + +export default QuoteButton diff --git a/src/components/quote_button/quote_button.vue b/src/components/quote_button/quote_button.vue new file mode 100644 index 00000000..7a4c6b4b --- /dev/null +++ b/src/components/quote_button/quote_button.vue @@ -0,0 +1,55 @@ +<template> + <div + v-if="loggedIn" + class="QuoteButton" + > + <button + v-if="visibility === 'public' || visibility === 'unlisted'" + class="button-unstyled interactive" + :class="{'-active': quoting}" + :title="$t('tool_tip.quote')" + @click.prevent="$emit('toggle')" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="quote-left" + /> + </button> + <span v-else-if="loggedIn"> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="lock" + :title="$t('timeline.no_quote_hint')" + /> + </span> + </div> +</template> + +<script src="./quote_button.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.QuoteButton { + display: flex; + + > :first-child { + padding: 10px; + margin: -10px -8px -10px -10px; + } + + .action-counter { + pointer-events: none; + user-select: none; + } + + .interactive { + &:hover .svg-inline--fa, + &.-active .svg-inline--fa { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + } + +} +</style> diff --git a/src/components/quote_card/quote_card.js b/src/components/quote_card/quote_card.js new file mode 100644 index 00000000..8f1a58a1 --- /dev/null +++ b/src/components/quote_card/quote_card.js @@ -0,0 +1,32 @@ +import { mapGetters } from 'vuex' +import QuoteCardContent from '../quote_card_content/quote_card_content.vue' + +const QuoteCard = { + name: 'QuoteCard', + props: [ + 'status' + ], + data () { + return { + imageLoaded: false + } + }, + computed: { + ...mapGetters([ + 'mergedConfig' + ]), + statusLink () { + return { + name: 'conversation', + params: { + id: this.status.id + } + } + } + }, + components: { + QuoteCardContent + } +} + +export default QuoteCard diff --git a/src/components/quote_card/quote_card.vue b/src/components/quote_card/quote_card.vue new file mode 100644 index 00000000..64f8b6a9 --- /dev/null +++ b/src/components/quote_card/quote_card.vue @@ -0,0 +1,76 @@ +<template> + <div> + <a + class="quote-card" + :href="$router.resolve(statusLink).href" + target="_blank" + rel="noopener" + > + <QuoteCardContent + :status="status" + /> + </a> + </div> +</template> + +<script src="./quote_card"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.quote-card { + display: flex; + flex-direction: row; + cursor: pointer; + overflow: hidden; + margin-top: 0.5em; + + .card-image { + flex-shrink: 0; + width: 120px; + max-width: 25%; + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: $fallback--attachmentRadius; + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + } + } + + .card-content { + max-height: 100%; + margin: 0.5em; + display: flex; + flex-direction: column; + } + + .card-host { + font-size: 0.85em; + } + + .card-description { + margin: 0.5em 0 0 0; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + line-height: 1.2em; + // cap description at 3 lines, the 1px is to clean up some stray pixels + // TODO: fancier fade-out at the bottom to show off that it's too long? + max-height: calc(1.2em * 3 - 1px); + } + + .nsfw-alert { + margin: 2em 0; + } + + color: $fallback--text; + color: var(--text, $fallback--text); + border-style: solid; + border-width: 1px; + border-radius: $fallback--attachmentRadius; + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + border-color: $fallback--border; + border-color: var(--border, $fallback--border); +} +</style> diff --git a/src/components/quote_card_content/quote_card_content.vue b/src/components/quote_card_content/quote_card_content.vue new file mode 100644 index 00000000..c5950547 --- /dev/null +++ b/src/components/quote_card_content/quote_card_content.vue @@ -0,0 +1,22 @@ +<template> + <Status + v-if="status" + :is-preview="true" + :statusoid="status" + :compact="true" + /> +</template> + +<script> +import { defineAsyncComponent } from 'vue' + +export default { + name: 'QuoteCardContent', + components: { + Status: defineAsyncComponent(() => import('../status/status.vue')) + }, + props: [ + 'status' + ] +} +</script> diff --git a/src/components/settings_modal/tabs/version_tab.js b/src/components/settings_modal/tabs/version_tab.js index ce0b4d35..d69b131d 100644 --- a/src/components/settings_modal/tabs/version_tab.js +++ b/src/components/settings_modal/tabs/version_tab.js @@ -1,7 +1,7 @@ import { extractCommit } from 'src/services/version/version.service' const pleromaFeCommitUrl = 'https://akkoma.dev/AkkomaGang/pleroma-fe/commit/' -const pleromaBeCommitUrl = 'https://akkoma.dev/AkkomaGang/akkoma/commits/' +const pleromaBeCommitUrl = 'https://akkoma.dev/AkkomaGang/akkoma/commit/' const VersionTab = { data () { diff --git a/src/components/status/status.js b/src/components/status/status.js index d1339652..dcf93688 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,4 +1,5 @@ import ReplyButton from '../reply_button/reply_button.vue' +import QuoteButton from '../quote_button/quote_button.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import ReactButton from '../react_button/react_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' @@ -115,7 +116,8 @@ const Status = { StatusContent, RichContent, MentionLink, - MentionsLine + MentionsLine, + QuoteButton }, props: [ 'statusoid', @@ -145,6 +147,8 @@ const Status = { 'controlledToggleShowingLongSubject', 'controlledReplying', 'controlledToggleReplying', + 'controlledQuoting', + 'controlledToggleQuoting', 'controlledMediaPlaying', 'controlledSetMediaPlaying', 'dive' @@ -152,6 +156,7 @@ const Status = { data () { return { uncontrolledReplying: false, + uncontrolledQuoting: false, unmuted: false, userExpanded: false, uncontrolledMediaPlaying: [], @@ -161,7 +166,7 @@ const Status = { } }, computed: { - ...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']), + ...controlledOrUncontrolledGetters(['replying', 'quoting', 'mediaPlaying']), muteWords () { return this.mergedConfig.muteWords }, @@ -418,6 +423,9 @@ const Status = { toggleReplying () { controlledOrUncontrolledToggle(this, 'replying') }, + toggleQuoting () { + controlledOrUncontrolledToggle(this, 'quoting') + }, gotoOriginal (id) { if (this.inConversation) { this.$emit('goto', id) diff --git a/src/components/status/status.scss b/src/components/status/status.scss index b3ad3818..cc9d4eb7 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -101,6 +101,10 @@ .status-heading { margin-bottom: 0.5em; + + .emoji { + --emoji-size: 16px; + } } .heading-name-row { @@ -355,6 +359,15 @@ flex: 1; } + .quote-form { + padding-top: 0; + padding-bottom: 0; + } + + .quote-body { + flex: 1; + } + .favs-repeated-users { margin-top: var(--status-margin, $status-margin); } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 67ce999a..6c80e293 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -430,6 +430,12 @@ :status="status" @toggle="toggleReplying" /> + <quote-button + :visibility="status.visibility" + :quoting="quoting" + :status="status" + @toggle="toggleQuoting" + /> <retweet-button :visibility="status.visibility" :logged-in="loggedIn" @@ -488,6 +494,20 @@ @posted="toggleReplying" /> </div> + <div + v-if="quoting" + class="status-container quote-form" + > + <PostStatusForm + class="quote-body" + :quote-id="status.id" + :attentions="[status.user]" + :replied-user="status.user" + :copy-message-scope="status.visibility" + :subject="replySubject" + @posted="toggleQuoting" + /> + </div> </template> </div> </template> diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss index 8a5598b0..8a218fb2 100644 --- a/src/components/status_body/status_body.scss +++ b/src/components/status_body/status_body.scss @@ -6,9 +6,8 @@ .emoji { --_still_image-label-scale: 0.5; - - width: 50px; - height: 50px; + --emoji-size: 50px; + --emoji-size: 50px; } ._mfm_x2_ { diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index 89f0aa51..3cd50622 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -3,6 +3,7 @@ import Poll from '../poll/poll.vue' import Gallery from '../gallery/gallery.vue' import StatusBody from 'src/components/status_body/status_body.vue' import LinkPreview from '../link-preview/link-preview.vue' +import QuoteCard from '../quote_card/quote_card.vue' import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -109,7 +110,8 @@ const StatusContent = { Poll, Gallery, LinkPreview, - StatusBody + StatusBody, + QuoteCard }, methods: { toggleShowingTall () { diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 4ed01d3a..4f9c85bb 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -40,7 +40,14 @@ @play="$emit('mediaplay', attachment.id)" @pause="$emit('mediapause', attachment.id)" /> - + <div + v-if="status.quote && !compact" + class="quote" + > + <QuoteCard + :status="status.quote" + /> + </div> <div v-if="status.card && !noHeading && !compact" class="link-preview media-body" diff --git a/src/i18n/en.json b/src/i18n/en.json index 240843dc..0971f77c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -782,6 +782,7 @@ "error": "Error fetching timeline: {0}", "load_older": "Load older statuses", "no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated", + "no_quote_hint": "Post is marked as followers-only or direct and cannot be quoted", "repeated": "repeated", "show_new": "Show new", "reload": "Reload", diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index f00985a8..686689cd 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -763,6 +763,7 @@ const postStatus = ({ poll, mediaIds = [], inReplyToStatusId, + quoteId, contentType, preview, idempotencyKey @@ -795,6 +796,9 @@ const postStatus = ({ if (inReplyToStatusId) { form.append('in_reply_to_id', inReplyToStatusId) } + if (quoteId) { + form.append('quote_id', quoteId) + } if (preview) { form.append('preview', 'true') } diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 074bd0ad..b66191bf 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -347,6 +347,9 @@ export const parseStatus = (data) => { output.visibility = data.visibility output.card = data.card output.created_at = new Date(data.created_at) + if (data.quote) { + output.quote = parseStatus(data.quote) + } // Converting to string, the right way. output.in_reply_to_status_id = output.in_reply_to_status_id diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index f09196aa..d1c5db19 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -10,6 +10,7 @@ const postStatus = ({ poll, media = [], inReplyToStatusId = undefined, + quoteId = undefined, contentType = 'text/plain', preview = false, idempotencyKey = '' @@ -24,6 +25,7 @@ const postStatus = ({ sensitive, mediaIds, inReplyToStatusId, + quoteId, contentType, poll, preview,