aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorHenry Jameson <me@hjkos.com>2022-03-16 21:00:20 +0200
committerHenry Jameson <me@hjkos.com>2022-03-16 21:00:20 +0200
commitcd4ad2df11369087b1f39bcaac1bbe258e00580d (patch)
treecb2131ccac2501587af7283e6ecd32d3d803e44e /src/components
parent8a9115b58e020f750366e87bb4fd3483d6b62b03 (diff)
parentb632d740c13ff4e5c98a7a101f26ca60cd2629bb (diff)
Merge remote-tracking branch 'origin/develop' into vue3-again
* origin/develop: (475 commits) Apply 1 suggestion(s) to 1 file(s) Update dependency @ungap/event-target to v0.2.3 Update package.json fix broken icons after FA upgrade Update Font Awesome Update dependency webpack-dev-middleware to v3.7.3 Update dependency vuelidate to v0.7.7 Pin dependency @kazvmoe-infra/pinch-zoom-element to 1.2.0 lint Make media modal buttons larger Add English translation for hide tooltip Add hide button to media modal Lint Prevent hiding media viewer if swiped over SwipeClick Fix webkit image blurs Fix video in media modal not displaying properly Add changelog for https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1403 Remove image box-shadow in media modal Clean up debug code for image pinch zoom Bump @kazvmoe-infra/pinch-zoom-element to 1.2.0 on npm ...
Diffstat (limited to 'src/components')
-rw-r--r--src/components/attachment/attachment.js114
-rw-r--r--src/components/attachment/attachment.scss268
-rw-r--r--src/components/attachment/attachment.vue526
-rw-r--r--src/components/basic_user_card/basic_user_card.js4
-rw-r--r--src/components/basic_user_card/basic_user_card.vue12
-rw-r--r--src/components/chat_list_item/chat_list_item.js8
-rw-r--r--src/components/chat_list_item/chat_list_item.scss9
-rw-r--r--src/components/chat_list_item/chat_list_item.vue3
-rw-r--r--src/components/chat_message/chat_message.js5
-rw-r--r--src/components/chat_message/chat_message.scss17
-rw-r--r--src/components/chat_message/chat_message.vue1
-rw-r--r--src/components/chat_title/chat_title.vue18
-rw-r--r--src/components/conversation/conversation.js374
-rw-r--r--src/components/conversation/conversation.vue247
-rw-r--r--src/components/desktop_nav/desktop_nav.vue1
-rw-r--r--src/components/emoji_input/emoji_input.js2
-rw-r--r--src/components/emoji_input/emoji_input.vue2
-rw-r--r--src/components/features_panel/features_panel.js2
-rw-r--r--src/components/features_panel/features_panel.vue4
-rw-r--r--src/components/flash/flash.js53
-rw-r--r--src/components/flash/flash.vue84
-rw-r--r--src/components/follow_button/follow_button.js9
-rw-r--r--src/components/follow_button/follow_button.vue2
-rw-r--r--src/components/follow_card/follow_card.vue1
-rw-r--r--src/components/font_control/font_control.js17
-rw-r--r--src/components/font_control/font_control.vue35
-rw-r--r--src/components/gallery/gallery.js97
-rw-r--r--src/components/gallery/gallery.vue202
-rw-r--r--src/components/hashtag_link/hashtag_link.js36
-rw-r--r--src/components/hashtag_link/hashtag_link.scss6
-rw-r--r--src/components/hashtag_link/hashtag_link.vue19
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue41
-rw-r--r--src/components/media_modal/media_modal.js98
-rw-r--r--src/components/media_modal/media_modal.vue267
-rw-r--r--src/components/mention_link/mention_link.js134
-rw-r--r--src/components/mention_link/mention_link.scss115
-rw-r--r--src/components/mention_link/mention_link.vue75
-rw-r--r--src/components/mentions_line/mentions_line.js37
-rw-r--r--src/components/mentions_line/mentions_line.scss13
-rw-r--r--src/components/mentions_line/mentions_line.vue43
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js3
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.vue4
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.js51
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.scss21
-rw-r--r--src/components/mrf_transparency_panel/mrf_transparency_panel.vue155
-rw-r--r--src/components/notification/notification.js4
-rw-r--r--src/components/notification/notification.scss13
-rw-r--r--src/components/notification/notification.vue18
-rw-r--r--src/components/notifications/notifications.scss12
-rw-r--r--src/components/pinch_zoom/pinch_zoom.js13
-rw-r--r--src/components/pinch_zoom/pinch_zoom.vue11
-rw-r--r--src/components/poll/poll.js10
-rw-r--r--src/components/poll/poll.vue14
-rw-r--r--src/components/poll/poll_form.js6
-rw-r--r--src/components/poll/poll_form.vue70
-rw-r--r--src/components/popover/popover.vue2
-rw-r--r--src/components/post_status_form/post_status_form.js23
-rw-r--r--src/components/post_status_form/post_status_form.vue110
-rw-r--r--src/components/rich_content/rich_content.jsx328
-rw-r--r--src/components/rich_content/rich_content.scss64
-rw-r--r--src/components/select/select.js21
-rw-r--r--src/components/select/select.vue63
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.js47
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.vue42
-rw-r--r--src/components/settings_modal/helpers/choice_setting.js48
-rw-r--r--src/components/settings_modal/helpers/choice_setting.vue31
-rw-r--r--src/components/settings_modal/helpers/integer_setting.js41
-rw-r--r--src/components/settings_modal/helpers/integer_setting.vue23
-rw-r--r--src/components/settings_modal/helpers/server_side_indicator.vue51
-rw-r--r--src/components/settings_modal/helpers/shared_computed_object.js9
-rw-r--r--src/components/settings_modal/settings_modal.js27
-rw-r--r--src/components/settings_modal/settings_modal.scss7
-rw-r--r--src/components/settings_modal/settings_modal.vue40
-rw-r--r--src/components/settings_modal/settings_modal_content.scss13
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js21
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.vue193
-rw-r--r--src/components/settings_modal/tabs/general_tab.js45
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue406
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.js8
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue81
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js25
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue121
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js47
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.scss3
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue41
-rw-r--r--src/components/shadow_control/shadow_control.js4
-rw-r--r--src/components/shadow_control/shadow_control.vue46
-rw-r--r--src/components/shout_panel/shout_panel.js (renamed from src/components/chat_panel/chat_panel.js)8
-rw-r--r--src/components/shout_panel/shout_panel.vue (renamed from src/components/chat_panel/chat_panel.vue)53
-rw-r--r--src/components/side_drawer/side_drawer.js2
-rw-r--r--src/components/side_drawer/side_drawer.vue8
-rw-r--r--src/components/status/status.js200
-rw-r--r--src/components/status/status.scss85
-rw-r--r--src/components/status/status.vue165
-rw-r--r--src/components/status_body/status_body.js131
-rw-r--r--src/components/status_body/status_body.scss174
-rw-r--r--src/components/status_body/status_body.vue100
-rw-r--r--src/components/status_content/status_content.js178
-rw-r--r--src/components/status_content/status_content.vue311
-rw-r--r--src/components/still-image/still-image.js11
-rw-r--r--src/components/still-image/still-image.vue7
-rw-r--r--src/components/swipe_click/swipe_click.js84
-rw-r--r--src/components/swipe_click/swipe_click.vue14
-rw-r--r--src/components/thread_tree/thread_tree.js90
-rw-r--r--src/components/thread_tree/thread_tree.vue127
-rw-r--r--src/components/timeline/timeline.js18
-rw-r--r--src/components/timeline/timeline.vue2
-rw-r--r--src/components/timeline/timeline_quick_settings.js10
-rw-r--r--src/components/timeline/timeline_quick_settings.vue9
-rw-r--r--src/components/timeline_menu/timeline_menu.vue5
-rw-r--r--src/components/user_avatar/user_avatar.js13
-rw-r--r--src/components/user_avatar/user_avatar.vue14
-rw-r--r--src/components/user_card/user_card.js19
-rw-r--r--src/components/user_card/user_card.vue139
-rw-r--r--src/components/user_list_popover/user_list_popover.vue9
-rw-r--r--src/components/user_profile/user_profile.js4
-rw-r--r--src/components/user_profile/user_profile.vue20
117 files changed, 5476 insertions, 1971 deletions
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 5f5779a0..d62a4adc 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,4 +1,5 @@
import StillImage from '../still-image/still-image.vue'
+import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
@@ -10,7 +11,12 @@ import {
faImage,
faVideo,
faPlayCircle,
- faTimes
+ faTimes,
+ faStop,
+ faSearchPlus,
+ faTrashAlt,
+ faPencilAlt,
+ faAlignRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -19,36 +25,64 @@ library.add(
faImage,
faVideo,
faPlayCircle,
- faTimes
+ faTimes,
+ faStop,
+ faSearchPlus,
+ faTrashAlt,
+ faPencilAlt,
+ faAlignRight
)
const Attachment = {
props: [
'attachment',
+ 'description',
+ 'hideDescription',
'nsfw',
'size',
- 'allowPlay',
'setMedia',
- 'naturalSizeLoad'
+ 'remove',
+ 'shiftUp',
+ 'shiftDn',
+ 'edit'
],
data () {
return {
+ localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
- showHidden: false
+ showHidden: false,
+ flashLoaded: false,
+ showDescription: false
}
},
components: {
+ Flash,
StillImage,
VideoAttachment
},
computed: {
+ classNames () {
+ return [
+ {
+ '-loading': this.loading,
+ '-nsfw-placeholder': this.hidden,
+ '-editable': this.edit !== undefined
+ },
+ '-type-' + this.type,
+ this.size && '-size-' + this.size,
+ `-${this.useContainFit ? 'contain' : 'cover'}-fit`
+ ]
+ },
usePlaceholder () {
- return this.size === 'hide' || this.type === 'unknown'
+ return this.size === 'hide'
+ },
+ useContainFit () {
+ return this.$store.getters.mergedConfig.useContainFit
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
@@ -72,24 +106,33 @@ const Attachment = {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
- return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
- },
- isSmall () {
- return this.size === 'small'
- },
- fullwidth () {
- if (this.size === 'hide') return false
- return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
+ return (this.type === 'html' && !this.attachment.oembed)
},
useModal () {
- const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
- : this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
+ let modalTypes = []
+ switch (this.size) {
+ case 'hide':
+ case 'small':
+ modalTypes = ['image', 'video', 'audio', 'flash']
+ break
+ default:
+ modalTypes = this.mergedConfig.playVideosInModal
+ ? ['image', 'video', 'flash']
+ : ['image']
+ break
+ }
return modalTypes.includes(this.type)
},
+ videoTag () {
+ return this.useModal ? 'button' : 'span'
+ },
...mapGetters(['mergedConfig'])
},
+ watch: {
+ localDescription (newVal) {
+ this.onEdit(newVal)
+ }
+ },
methods: {
linkClicked ({ target }) {
if (target.tagName === 'A') {
@@ -98,12 +141,37 @@ const Attachment = {
},
openModal (event) {
if (this.useModal) {
- event.stopPropagation()
- event.preventDefault()
- this.setMedia()
- this.$store.dispatch('setCurrent', this.attachment)
+ this.$emit('setMedia')
+ this.$store.dispatch('setCurrentMedia', this.attachment)
+ } else if (this.type === 'unknown') {
+ window.open(this.attachment.url)
}
},
+ openModalForce (event) {
+ this.$emit('setMedia')
+ this.$store.dispatch('setCurrentMedia', this.attachment)
+ },
+ onEdit (event) {
+ this.edit && this.edit(this.attachment, event)
+ },
+ onRemove () {
+ this.remove && this.remove(this.attachment)
+ },
+ onShiftUp () {
+ this.shiftUp && this.shiftUp(this.attachment)
+ },
+ onShiftDn () {
+ this.shiftDn && this.shiftDn(this.attachment)
+ },
+ stopFlash () {
+ this.$refs.flash.closePlayer()
+ },
+ setFlashLoaded (event) {
+ this.flashLoaded = event
+ },
+ toggleDescription () {
+ this.showDescription = !this.showDescription
+ },
toggleHidden (event) {
if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
@@ -130,7 +198,7 @@ const Attachment = {
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
- this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
+ this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
}
}
}
diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss
new file mode 100644
index 00000000..dfda15bf
--- /dev/null
+++ b/src/components/attachment/attachment.scss
@@ -0,0 +1,268 @@
+@import '../../_variables.scss';
+
+.Attachment {
+ display: inline-flex;
+ flex-direction: column;
+ position: relative;
+ align-self: flex-start;
+ line-height: 0;
+ height: 100%;
+ 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);
+
+ .attachment-wrapper {
+ flex: 1 1 auto;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .description-container {
+ flex: 0 1 0;
+ display: flex;
+ padding-top: 0.5em;
+ z-index: 1;
+
+ p {
+ flex: 1;
+ text-align: center;
+ line-height: 1.5;
+ padding: 0.5em;
+ margin: 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ &.-static {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding-top: 0;
+ background: var(--popover);
+ box-shadow: var(--popupShadow);
+ }
+ }
+
+ .description-field {
+ flex: 1;
+ min-width: 0;
+ }
+
+ & .placeholder-container,
+ & .image-container,
+ & .audio-container,
+ & .video-container,
+ & .flash-container,
+ & .oembed-container {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ .image-container {
+ .image {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ & .flash-container,
+ & .video-container {
+ & .flash,
+ & video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ align-self: center;
+ }
+ }
+
+ .audio-container {
+ display: flex;
+ align-items: flex-end;
+
+ audio {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ .placeholder-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding-top: 0.5em;
+ }
+
+
+ .play-icon {
+ position: absolute;
+ font-size: 64px;
+ top: calc(50% - 32px);
+ left: calc(50% - 32px);
+ color: rgba(255, 255, 255, 0.75);
+ text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
+
+ &::before {
+ margin: 0;
+ }
+ }
+
+ .attachment-buttons {
+ display: flex;
+ position: absolute;
+ right: 0;
+ top: 0;
+ margin-top: 0.5em;
+ margin-right: 0.5em;
+ z-index: 1;
+
+ .attachment-button {
+ padding: 0;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ text-align: center;
+ width: 2em;
+ height: 2em;
+ margin-left: 0.5em;
+ font-size: 1.25em;
+ // TODO: theming? hard to theme with unknown background image color
+ background: rgba(230, 230, 230, 0.7);
+
+ .svg-inline--fa {
+ color: rgba(0, 0, 0, 0.6);
+ }
+
+ &:hover .svg-inline--fa {
+ color: rgba(0, 0, 0, 0.9);
+ }
+ }
+ }
+
+ .oembed-container {
+ line-height: 1.2em;
+ flex: 1 0 100%;
+ width: 100%;
+ margin-right: 15px;
+ display: flex;
+
+ img {
+ width: 100%;
+ }
+
+ .image {
+ flex: 1;
+ img {
+ border: 0px;
+ border-radius: 5px;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .text {
+ flex: 2;
+ margin: 8px;
+ word-break: break-all;
+ h1 {
+ font-size: 14px;
+ margin: 0px;
+ }
+ }
+ }
+
+ &.-size-small {
+ .play-icon {
+ zoom: 0.5;
+ opacity: 0.7;
+ }
+
+ .attachment-buttons {
+ zoom: 0.7;
+ opacity: 0.5;
+ }
+ }
+
+ &.-editable {
+ padding: 0.5em;
+
+ & .description-container,
+ & .attachment-buttons {
+ margin: 0;
+ }
+ }
+
+ &.-placeholder {
+ display: inline-block;
+ color: $fallback--link;
+ color: var(--postLink, $fallback--link);
+ overflow: hidden;
+ white-space: nowrap;
+ height: auto;
+ line-height: 1.5;
+
+ &:not(.-editable) {
+ border: none;
+ }
+
+ &.-editable {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+
+ & .description-container,
+ & .attachment-buttons {
+ margin: 0;
+ padding: 0;
+ position: relative;
+ }
+
+ .description-container {
+ flex: 1;
+ padding-left: 0.5em;
+ }
+
+ .attachment-buttons {
+ order: 99;
+ align-self: center;
+ }
+ }
+
+ a {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ svg {
+ color: inherit;
+ }
+ }
+
+ &.-loading {
+ cursor: progress;
+ }
+
+ &.-contain-fit {
+ img,
+ canvas {
+ object-fit: contain;
+ }
+ }
+
+ &.-cover-fit {
+ img,
+ canvas {
+ object-fit: cover;
+ }
+ }
+}
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 2c1c1682..2a89886d 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,7 +1,8 @@
<template>
- <div
+ <button
v-if="usePlaceholder"
- :class="{ 'fullwidth': fullwidth }"
+ class="Attachment -placeholder button-unstyled"
+ :class="classNames"
@click="openModal"
>
<a
@@ -11,312 +12,257 @@
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
+ @click.prevent
>
<FAIcon :icon="placeholderIconClass" />
- <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
+ <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
</a>
- </div>
+ <div
+ v-if="edit || remove"
+ class="attachment-buttons"
+ >
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ @click.prevent="onRemove"
+ >
+ <FAIcon icon="trash-alt" />
+ </button>
+ </div>
+ <div
+ v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
+ class="description-container"
+ :class="{ '-static': !edit }"
+ >
+ <input
+ v-if="edit"
+ v-model="localDescription"
+ type="text"
+ class="description-field"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
+ >
+ <p v-else>
+ {{ localDescription }}
+ </p>
+ </div>
+ </button>
<div
v-else
- v-show="!isEmpty"
- class="attachment"
- :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
+ class="Attachment"
+ :class="classNames"
>
- <a
- v-if="hidden"
- class="image-attachment"
- :href="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- @click.prevent.stop="toggleHidden"
+ <div
+ v-show="!isEmpty"
+ class="attachment-wrapper"
>
- <img
- :key="nsfwImage"
- class="nsfw"
- :src="nsfwImage"
- :class="{'small': isSmall}"
+ <a
+ v-if="hidden"
+ class="image-container"
+ :href="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
+ @click.prevent.stop="toggleHidden"
>
- <FAIcon
- v-if="type === 'video'"
- class="play-icon"
- icon="play-circle"
- />
- </a>
- <button
- v-if="nsfw && hideNsfwLocal && !hidden"
- class="button-unstyled hider"
- @click.prevent="toggleHidden"
- >
- <FAIcon icon="times" />
- </button>
+ <img
+ :key="nsfwImage"
+ class="nsfw"
+ :src="nsfwImage"
+ >
+ <FAIcon
+ v-if="type === 'video'"
+ class="play-icon"
+ icon="play-circle"
+ />
+ </a>
+ <div
+ v-if="!hidden"
+ class="attachment-buttons"
+ >
+ <button
+ v-if="type === 'flash' && flashLoaded"
+ class="button-unstyled attachment-button"
+ :title="$t('status.attachment_stop_flash')"
+ @click.prevent="stopFlash"
+ >
+ <FAIcon icon="stop" />
+ </button>
+ <button
+ v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ :title="$t('status.show_attachment_description')"
+ @click.prevent="toggleDescription"
+ >
+ <FAIcon icon="align-right" />
+ </button>
+ <button
+ v-if="!useModal && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ :title="$t('status.show_attachment_in_modal')"
+ @click.prevent="openModalForce"
+ >
+ <FAIcon icon="search-plus" />
+ </button>
+ <button
+ v-if="nsfw && hideNsfwLocal"
+ class="button-unstyled attachment-button"
+ :title="$t('status.hide_attachment')"
+ @click.prevent="toggleHidden"
+ >
+ <FAIcon icon="times" />
+ </button>
+ <button
+ v-if="shiftUp"
+ class="button-unstyled attachment-button"
+ :title="$t('status.move_up')"
+ @click.prevent="onShiftUp"
+ >
+ <FAIcon icon="chevron-left" />
+ </button>
+ <button
+ v-if="shiftDn"
+ class="button-unstyled attachment-button"
+ :title="$t('status.move_down')"
+ @click.prevent="onShiftDn"
+ >
+ <FAIcon icon="chevron-right" />
+ </button>
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ :title="$t('status.remove_attachment')"
+ @click.prevent="onRemove"
+ >
+ <FAIcon icon="trash-alt" />
+ </button>
+ </div>
- <a
- v-if="type === 'image' && (!hidden || preloadImage)"
- class="image-attachment"
- :class="{'hidden': hidden && preloadImage }"
- :href="attachment.url"
- target="_blank"
- @click="openModal"
- >
- <StillImage
- class="image"
- :referrerpolicy="referrerpolicy"
- :mimetype="attachment.mimetype"
- :src="attachment.large_thumb_url || attachment.url"
- :image-load-handler="onImageLoad"
- :alt="attachment.description"
- />
- </a>
+ <a
+ v-if="type === 'image' && (!hidden || preloadImage)"
+ class="image-container"
+ :class="{'-hidden': hidden && preloadImage }"
+ :href="attachment.url"
+ target="_blank"
+ @click.stop.prevent="openModal"
+ >
+ <StillImage
+ class="image"
+ :referrerpolicy="referrerpolicy"
+ :mimetype="attachment.mimetype"
+ :src="attachment.large_thumb_url || attachment.url"
+ :image-load-handler="onImageLoad"
+ :alt="attachment.description"
+ />
+ </a>
- <a
- v-if="type === 'video' && !hidden"
- class="video-container"
- :class="{'small': isSmall}"
- :href="allowPlay ? undefined : attachment.url"
- @click="openModal"
- >
- <VideoAttachment
- class="video"
- :attachment="attachment"
- :controls="allowPlay"
- @play="$emit('play')"
- @pause="$emit('pause')"
- />
- <FAIcon
- v-if="!allowPlay"
- class="play-icon"
- icon="play-circle"
- />
- </a>
+ <a
+ v-if="type === 'unknown' && !hidden"
+ class="placeholder-container"
+ :href="attachment.url"
+ target="_blank"
+ >
+ <FAIcon
+ size="5x"
+ :icon="placeholderIconClass"
+ />
+ <p>
+ {{ localDescription }}
+ </p>
+ </a>
- <audio
- v-if="type === 'audio'"
- :src="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- controls
- @play="$emit('play')"
- @pause="$emit('pause')"
- />
+ <component
+ :is="videoTag"
+ v-if="type === 'video' && !hidden"
+ class="video-container"
+ :class="{ 'button-unstyled': 'isModal' }"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <VideoAttachment
+ class="video"
+ :attachment="attachment"
+ :controls="!useModal"
+ @play="$emit('play')"
+ @pause="$emit('pause')"
+ />
+ <FAIcon
+ v-if="useModal"
+ class="play-icon"
+ icon="play-circle"
+ />
+ </component>
+
+ <span
+ v-if="type === 'audio' && !hidden"
+ class="audio-container"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <audio
+ v-if="type === 'audio'"
+ :src="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
+ controls
+ @play="$emit('play')"
+ @pause="$emit('pause')"
+ />
+ </span>
- <div
- v-if="type === 'html' && attachment.oembed"
- class="oembed"
- @click.prevent="linkClicked"
- >
<div
- v-if="attachment.thumb_url"
- class="image"
+ v-if="type === 'html' && attachment.oembed"
+ class="oembed-container"
+ @click.prevent="linkClicked"
>
- <img :src="attachment.thumb_url">
- </div>
- <div class="text">
- <!-- eslint-disable vue/no-v-html -->
- <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
- <div v-html="attachment.oembed.oembedHTML" />
- <!-- eslint-enable vue/no-v-html -->
+ <div
+ v-if="attachment.thumb_url"
+ class="image"
+ >
+ <img :src="attachment.thumb_url">
+ </div>
+ <div class="text">
+ <!-- eslint-disable vue/no-v-html -->
+ <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
+ <div v-html="attachment.oembed.oembedHTML" />
+ <!-- eslint-enable vue/no-v-html -->
+ </div>
</div>
+
+ <span
+ v-if="type === 'flash' && !hidden"
+ class="flash-container"
+ :href="attachment.url"
+ @click.stop.prevent="openModal"
+ >
+ <Flash
+ ref="flash"
+ class="flash"
+ :src="attachment.large_thumb_url || attachment.url"
+ @playerOpened="setFlashLoaded(true)"
+ @playerClosed="setFlashLoaded(false)"
+ />
+ </span>
+ </div>
+ <div
+ v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
+ class="description-container"
+ :class="{ '-static': !edit }"
+ >
+ <input
+ v-if="edit"
+ v-model="localDescription"
+ type="text"
+ class="description-field"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
+ >
+ <p v-else>
+ {{ localDescription }}
+ </p>
</div>
</div>
</template>
<script src="./attachment.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.attachments {
- display: flex;
- flex-wrap: wrap;
-
- .non-gallery {
- max-width: 100%;
- }
-
- .placeholder {
- display: inline-block;
- padding: 0.3em 1em 0.3em 0;
- color: $fallback--link;
- color: var(--postLink, $fallback--link);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 100%;
-
- svg {
- color: inherit;
- }
- }
-
- .nsfw-placeholder {
- cursor: pointer;
-
- &.loading {
- cursor: progress;
- }
- }
-
- .attachment {
- position: relative;
- margin-top: 0.5em;
- align-self: flex-start;
- line-height: 0;
-
- 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);
- overflow: hidden;
- }
-
- .non-gallery.attachment {
- &.video {
- flex: 1 0 40%;
- }
- .nsfw {
- height: 260px;
- }
- .small {
- height: 120px;
- flex-grow: 0;
- }
- .video {
- height: 260px;
- display: flex;
- }
- video {
- max-height: 100%;
- object-fit: contain;
- }
- }
-
- .fullwidth {
- flex-basis: 100%;
- }
- // fixes small gap below video
- &.video {
- line-height: 0;
- }
-
- .video-container {
- display: flex;
- max-height: 100%;
- }
-
- .video {
- width: 100%;
- height: 100%;
- }
-
- .play-icon {
- position: absolute;
- font-size: 64px;
- top: calc(50% - 32px);
- left: calc(50% - 32px);
- color: rgba(255, 255, 255, 0.75);
- text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
- }
-
- .play-icon::before {
- margin: 0;
- }
-
- &.html {
- flex-basis: 90%;
- width: 100%;
- display: flex;
- }
-
- .hider {
- position: absolute;
- right: 0;
- margin: 10px;
- padding: 0;
- z-index: 4;
- border-radius: $fallback--tooltipRadius;
- border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- text-align: center;
- width: 2em;
- height: 2em;
- font-size: 1.25em;
- // TODO: theming? hard to theme with unknown background image color
- background: rgba(230, 230, 230, 0.7);
- .svg-inline--fa {
- color: rgba(0, 0, 0, 0.6);
- }
- &:hover .svg-inline--fa {
- color: rgba(0, 0, 0, 0.9);
- }
- }
-
- video {
- z-index: 0;
- }
-
- audio {
- width: 100%;
- }
-
- img.media-upload {
- line-height: 0;
- max-height: 200px;
- max-width: 100%;
- }
-
- .oembed {
- line-height: 1.2em;
- flex: 1 0 100%;
- width: 100%;
- margin-right: 15px;
- display: flex;
-
- img {
- width: 100%;
- }
-
- .image {
- flex: 1;
- img {
- border: 0px;
- border-radius: 5px;
- height: 100%;
- object-fit: cover;
- }
- }
-
- .text {
- flex: 2;
- margin: 8px;
- word-break: break-all;
- h1 {
- font-size: 14px;
- margin: 0px;
- }
- }
- }
-
- .image-attachment {
- &,
- & .image {
- width: 100%;
- height: 100%;
- }
-
- &.hidden {
- display: none;
- }
-
- .nsfw {
- object-fit: cover;
- width: 100%;
- height: 100%;
- }
-
- img {
- image-orientation: from-image; // NOTE: only FF supports this
- }
- }
-}
-</style>
+<style src="./attachment.scss" lang="scss"></style>
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index 87085a28..8f41e2fb 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,5 +1,6 @@
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
@@ -13,7 +14,8 @@ const BasicUserCard = {
},
components: {
UserCard,
- UserAvatar
+ UserAvatar,
+ RichContent
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index c53f6a9c..53deb1df 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -25,17 +25,11 @@
:title="user.name"
class="basic-user-card-user-name"
>
- <!-- eslint-disable vue/no-v-html -->
- <span
- v-if="user.name_html"
+ <RichContent
class="basic-user-card-user-name-value"
- v-html="user.name_html"
+ :html="user.name"
+ :emoji="user.emoji"
/>
- <!-- eslint-enable vue/no-v-html -->
- <span
- v-else
- class="basic-user-card-user-name-value"
- >{{ user.name }}</span>
</div>
<div>
<router-link
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index bee1ad53..e5032176 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,5 +1,5 @@
import { mapState } from 'vuex'
-import StatusContent from '../status_content/status_content.vue'
+import StatusBody from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
@@ -16,7 +16,7 @@ const ChatListItem = {
AvatarList,
Timeago,
ChatTitle,
- StatusContent
+ StatusBody
},
computed: {
...mapState({
@@ -38,12 +38,14 @@ const ChatListItem = {
},
messageForStatusContent () {
const message = this.chat.lastMessage
+ const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
return {
summary: '',
- statusnet_html: messagePreview,
+ emojis: messageEmojis,
+ raw_html: messagePreview,
text: messagePreview,
attachments: []
}
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index 9e97b28e..57332bed 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -77,18 +77,15 @@
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
- .StatusContent {
- img.emoji {
- width: 1.4em;
- height: 1.4em;
- }
+ .chat-preview-body {
+ --emoji-size: 1.4em;
}
.time-wrapper {
line-height: 1.4em;
}
- .single-line {
+ .chat-preview-body {
padding-right: 1em;
}
}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
index cd3f436e..c7c0e878 100644
--- a/src/components/chat_list_item/chat_list_item.vue
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -29,7 +29,8 @@
</div>
</div>
<div class="chat-preview">
- <StatusContent
+ <StatusBody
+ class="chat-preview-body"
:status="messageForStatusContent"
:single-line="true"
/>
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index bb380f87..eb195bc1 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -57,8 +57,9 @@ const ChatMessage = {
messageForStatusContent () {
return {
summary: '',
- statusnet_html: this.message.content,
- text: this.message.content,
+ emojis: this.message.emojis,
+ raw_html: this.message.content || '',
+ text: this.message.content || '',
attachments: this.message.attachments
}
},
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index e4351d3b..1913479f 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -1,6 +1,7 @@
@import '../../_variables.scss';
.chat-message-wrapper {
+
&.hovered-message-chain {
.animated.Avatar {
canvas {
@@ -40,6 +41,12 @@
.chat-message {
display: flex;
padding-bottom: 0.5em;
+
+ .status-body:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
}
.avatar-wrapper {
@@ -62,10 +69,6 @@
&.with-media {
width: 100%;
- .gallery-row {
- overflow: hidden;
- }
-
.status {
width: 100%;
}
@@ -89,8 +92,9 @@
}
.without-attachment {
- .status-content {
- &::after {
+ .message-content {
+ // TODO figure out how to do it properly
+ .RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
@@ -162,6 +166,7 @@
.visible {
opacity: 1;
}
+
}
.chat-message-date-separator {
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 0f3fc97d..d62b831d 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -71,6 +71,7 @@
</Popover>
</div>
<StatusContent
+ class="message-content"
:status="messageForStatusContent"
:full-content="true"
>
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index b16ed39d..a92028e8 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -1,5 +1,4 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
<div
class="chat-title"
:title="title"
@@ -14,12 +13,13 @@
height="23px"
/>
</router-link>
- <span
+ <RichContent
class="username"
- v-html="htmlTitle"
+ :title="'@'+user.screen_name_ui"
+ :html="htmlTitle"
+ :emoji="user.emoji"
/>
</div>
- <!-- eslint-enable vue/no-v-html -->
</template>
<script src="./chat_title.js"></script>
@@ -34,6 +34,8 @@
white-space: nowrap;
align-items: center;
+ --emoji-size: 14px;
+
.username {
max-width: 100%;
text-overflow: ellipsis;
@@ -41,14 +43,6 @@
display: inline;
word-wrap: break-word;
overflow: hidden;
- text-overflow: ellipsis;
-
- .emoji {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
}
.Avatar {
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 069c0b40..2ef2977a 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,5 +1,19 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
+import ThreadTree from '../thread_tree/thread_tree.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAngleDoubleDown,
+ faAngleDoubleLeft,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAngleDoubleDown,
+ faAngleDoubleLeft,
+ faChevronLeft
+)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@@ -35,7 +49,10 @@ const conversation = {
data () {
return {
highlight: null,
- expanded: false
+ expanded: false,
+ threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
+ statusContentPropertiesObject: {},
+ inlineDivePosition: null
}
},
props: [
@@ -53,13 +70,51 @@ const conversation = {
}
},
computed: {
- hideStatus () {
+ maxDepthToShowByDefault () {
+ // maxDepthInThread = max number of depths that is *visible*
+ // since our depth starts with 0 and "showing" means "showing children"
+ // there is a -2 here
+ const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
+ return maxDepth >= 1 ? maxDepth : 1
+ },
+ displayStyle () {
+ return this.$store.getters.mergedConfig.conversationDisplay
+ },
+ isTreeView () {
+ return !this.isLinearView
+ },
+ treeViewIsSimple () {
+ return !this.$store.getters.mergedConfig.conversationTreeAdvanced
+ },
+ isLinearView () {
+ return this.displayStyle === 'linear'
+ },
+ shouldFadeAncestors () {
+ return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
+ },
+ otherRepliesButtonPosition () {
+ return this.$store.getters.mergedConfig.conversationOtherRepliesButton
+ },
+ showOtherRepliesButtonBelowStatus () {
+ return this.otherRepliesButtonPosition === 'below'
+ },
+ showOtherRepliesButtonInsideStatus () {
+ return this.otherRepliesButtonPosition === 'inside'
+ },
+ suspendable () {
+ if (this.isTreeView) {
+ return Object.entries(this.statusContentProperties)
+ .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
+ }
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
- return this.virtualHidden && this.$refs.statusComponent[0].suspendable
+ return this.$refs.statusComponent.every(s => s.suspendable)
} else {
- return this.virtualHidden
+ return true
}
},
+ hideStatus () {
+ return this.virtualHidden && this.suspendable
+ },
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
@@ -90,6 +145,121 @@ const conversation = {
return sortAndFilterConversation(conversation, this.status)
},
+ statusMap () {
+ return this.conversation.reduce((res, s) => {
+ res[s.id] = s
+ return res
+ }, {})
+ },
+ threadTree () {
+ const reverseLookupTable = this.conversation.reduce((table, status, index) => {
+ table[status.id] = index
+ return table
+ }, {})
+
+ const threads = this.conversation.reduce((a, cur) => {
+ const id = cur.id
+ a.forest[id] = this.getReplies(id)
+ .map(s => s.id)
+
+ return a
+ }, {
+ forest: {}
+ })
+
+ const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
+ if (processed[id]) {
+ return []
+ }
+
+ processed[id] = true
+ return [{
+ status: this.conversation[reverseLookupTable[id]],
+ id,
+ depth
+ }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
+ }).reduce((a, b) => a.concat(b), [])
+
+ const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
+
+ return linearized
+ },
+ replyIds () {
+ return this.conversation.map(k => k.id)
+ .reduce((res, id) => {
+ res[id] = (this.replies[id] || []).map(k => k.id)
+ return res
+ }, {})
+ },
+ totalReplyCount () {
+ const sizes = {}
+ const subTreeSizeFor = (id) => {
+ if (sizes[id]) {
+ return sizes[id]
+ }
+ sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
+ return sizes[id]
+ }
+ this.conversation.map(k => k.id).map(subTreeSizeFor)
+ return Object.keys(sizes).reduce((res, id) => {
+ res[id] = sizes[id] - 1 // exclude itself
+ return res
+ }, {})
+ },
+ totalReplyDepth () {
+ const depths = {}
+ const subTreeDepthFor = (id) => {
+ if (depths[id]) {
+ return depths[id]
+ }
+ depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
+ return depths[id]
+ }
+ this.conversation.map(k => k.id).map(subTreeDepthFor)
+ return Object.keys(depths).reduce((res, id) => {
+ res[id] = depths[id] - 1 // exclude itself
+ return res
+ }, {})
+ },
+ depths () {
+ return this.threadTree.reduce((a, k) => {
+ a[k.id] = k.depth
+ return a
+ }, {})
+ },
+ topLevel () {
+ const topLevel = this.conversation.reduce((tl, cur) =>
+ tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
+ return topLevel
+ },
+ otherTopLevelCount () {
+ return this.topLevel.length - 1
+ },
+ showingTopLevel () {
+ if (this.canDive && this.diveRoot) {
+ return [this.statusMap[this.diveRoot]]
+ }
+ return this.topLevel
+ },
+ diveRoot () {
+ const statusId = this.inlineDivePosition || this.statusId
+ const isTopLevel = !this.parentOf(statusId)
+ return isTopLevel ? null : statusId
+ },
+ diveDepth () {
+ return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
+ },
+ diveMode () {
+ return this.canDive && !!this.diveRoot
+ },
+ shouldShowAllConversationButton () {
+ // The "show all conversation" button tells the user that there exist
+ // other toplevel statuses, so do not show it if there is only a single root
+ return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
+ },
+ shouldShowAncestors () {
+ return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
+ },
replies () {
let i = 1
// eslint-disable-next-line camelcase
@@ -109,15 +279,71 @@ const conversation = {
}, {})
},
isExpanded () {
- return this.expanded || this.isPage
+ return !!(this.expanded || this.isPage)
},
hiddenStyle () {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
+ },
+ threadDisplayStatus () {
+ return this.conversation.reduce((a, k) => {
+ const id = k.id
+ const depth = this.depths[id]
+ const status = (() => {
+ if (this.threadDisplayStatusObject[id]) {
+ return this.threadDisplayStatusObject[id]
+ }
+ if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
+ return 'showing'
+ } else {
+ return 'hidden'
+ }
+ })()
+
+ a[id] = status
+ return a
+ }, {})
+ },
+ statusContentProperties () {
+ return this.conversation.reduce((a, k) => {
+ const id = k.id
+ const props = (() => {
+ const def = {
+ showingTall: false,
+ expandingSubject: false,
+ showingLongSubject: false,
+ isReplying: false,
+ mediaPlaying: []
+ }
+
+ if (this.statusContentPropertiesObject[id]) {
+ return {
+ ...def,
+ ...this.statusContentPropertiesObject[id]
+ }
+ }
+ return def
+ })()
+
+ a[id] = props
+ return a
+ }, {})
+ },
+ canDive () {
+ return this.isTreeView && this.isExpanded
+ },
+ focused () {
+ return (id) => {
+ return (this.isExpanded) && id === this.highlight
+ }
+ },
+ maybeHighlight () {
+ return this.isExpanded ? this.highlight : null
}
},
components: {
- Status
+ Status,
+ ThreadTree
},
watch: {
statusId (newVal, oldVal) {
@@ -132,6 +358,8 @@ const conversation = {
expanded (value) {
if (value) {
this.fetchConversation()
+ } else {
+ this.resetDisplayState()
}
},
virtualHidden (value) {
@@ -161,8 +389,8 @@ const conversation = {
getReplies (id) {
return this.replies[id] || []
},
- focused (id) {
- return (this.isExpanded) && id === this.statusId
+ getHighlight () {
+ return this.isExpanded ? this.highlight : null
},
setHighlight (id) {
if (!id) return
@@ -170,15 +398,139 @@ const conversation = {
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
- getHighlight () {
- return this.isExpanded ? this.highlight : null
- },
toggleExpanded () {
this.expanded = !this.expanded
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
+ },
+ setThreadDisplay (id, nextStatus) {
+ this.threadDisplayStatusObject = {
+ ...this.threadDisplayStatusObject,
+ [id]: nextStatus
+ }
+ },
+ toggleThreadDisplay (id) {
+ const curStatus = this.threadDisplayStatus[id]
+ const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
+ this.setThreadDisplay(id, nextStatus)
+ },
+ setThreadDisplayRecursively (id, nextStatus) {
+ this.setThreadDisplay(id, nextStatus)
+ this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
+ },
+ showThreadRecursively (id) {
+ this.setThreadDisplayRecursively(id, 'showing')
+ },
+ setStatusContentProperty (id, name, value) {
+ this.statusContentPropertiesObject = {
+ ...this.statusContentPropertiesObject,
+ [id]: {
+ ...this.statusContentPropertiesObject[id],
+ [name]: value
+ }
+ }
+ },
+ toggleStatusContentProperty (id, name) {
+ this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
+ },
+ leastVisibleAncestor (id) {
+ let cur = id
+ let parent = this.parentOf(cur)
+ while (cur) {
+ // if the parent is showing it means cur is visible
+ if (this.threadDisplayStatus[parent] === 'showing') {
+ return cur
+ }
+ parent = this.parentOf(parent)
+ cur = this.parentOf(cur)
+ }
+ // nothing found, fall back to toplevel
+ return this.topLevel[0] ? this.topLevel[0].id : undefined
+ },
+ diveIntoStatus (id, preventScroll) {
+ this.tryScrollTo(id)
+ },
+ diveToTopLevel () {
+ this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
+ },
+ // only used when we are not on a page
+ undive () {
+ this.inlineDivePosition = null
+ this.setHighlight(this.statusId)
+ },
+ tryScrollTo (id) {
+ if (!id) {
+ return
+ }
+ if (this.isPage) {
+ // set statusId
+ this.$router.push({ name: 'conversation', params: { id } })
+ } else {
+ this.inlineDivePosition = id
+ }
+ // Because the conversation can be unmounted when out of sight
+ // and mounted again when it comes into sight,
+ // the `mounted` or `created` function in `status` should not
+ // contain scrolling calls, as we do not want the page to jump
+ // when we scroll with an expanded conversation.
+ //
+ // Now the method is to rely solely on the `highlight` watcher
+ // in `status` components.
+ // In linear views, all statuses are rendered at all times, but
+ // in tree views, it is possible that a change in active status
+ // removes and adds status components (e.g. an originally child
+ // status becomes an ancestor status, and thus they will be
+ // different).
+ // Here, let the components be rendered first, in order to trigger
+ // the `highlight` watcher.
+ this.$nextTick(() => {
+ this.setHighlight(id)
+ })
+ },
+ goToCurrent () {
+ this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
+ },
+ statusById (id) {
+ return this.statusMap[id]
+ },
+ parentOf (id) {
+ const status = this.statusById(id)
+ if (!status) {
+ return undefined
+ }
+ const { in_reply_to_status_id: parentId } = status
+ if (!this.statusMap[parentId]) {
+ return undefined
+ }
+ return parentId
+ },
+ parentOrSelf (id) {
+ return this.parentOf(id) || id
+ },
+ // Ancestors of some status, from top to bottom
+ ancestorsOf (id) {
+ const ancestors = []
+ let cur = this.parentOf(id)
+ while (cur) {
+ ancestors.unshift(this.statusMap[cur])
+ cur = this.parentOf(cur)
+ }
+ return ancestors
+ },
+ topLevelAncestorOrSelfId (id) {
+ let cur = id
+ let parent = this.parentOf(id)
+ while (parent) {
+ cur = this.parentOf(cur)
+ parent = this.parentOf(parent)
+ }
+ return cur
+ },
+ resetDisplayState () {
+ this.undive()
+ this.threadDisplayStatusObject = {}
}
}
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 3fb26d92..7628ceaa 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -18,24 +18,168 @@
{{ $t('timeline.collapse') }}
</button>
</div>
- <status
- v-for="status in conversation"
- :key="status.id"
- ref="statusComponent"
- :inline-expanded="collapsable && isExpanded"
- :statusoid="status"
- :expandable="!isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
- :focused="focused(status.id)"
- :in-conversation="isExpanded"
- :highlight="getHighlight()"
- :replies="getReplies(status.id)"
- :in-profile="inProfile"
- :profile-user-id="profileUserId"
- class="conversation-status status-fadein panel-body"
- @goto="setHighlight"
- @toggleExpanded="toggleExpanded"
- />
+ <div class="conversation-body panel-body">
+ <div
+ v-if="isTreeView"
+ class="thread-body"
+ >
+ <div
+ v-if="shouldShowAllConversationButton"
+ class="conversation-dive-to-top-level-box"
+ >
+ <i18n
+ path="status.show_all_conversation_with_icon"
+ tag="button"
+ class="button-unstyled -link"
+ @click.prevent="diveToTopLevel"
+ >
+ <FAIcon
+ place="icon"
+ icon="angle-double-left"
+ />
+ <span place="text">
+ {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
+ </span>
+ </i18n>
+ </div>
+ <div
+ v-if="shouldShowAncestors"
+ class="thread-ancestors"
+ >
+ <div
+ v-for="status in ancestorsOf(diveRoot)"
+ :key="status.id"
+ class="thread-ancestor"
+ :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
+ >
+ <status
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="getHighlight()"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status status-fadein panel-body"
+
+ :simple-tree="treeViewIsSimple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
+ :dive="() => diveIntoStatus(status.id)"
+
+ :controlled-showing-tall="statusContentProperties[status.id].showingTall"
+ :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
+ :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
+ :controlled-replying="statusContentProperties[status.id].replying"
+ :controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
+ :controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
+ :controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
+ :controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
+ :controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
+ :controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ <div
+ v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
+ class="thread-ancestor-dive-box"
+ >
+ <div
+ class="thread-ancestor-dive-box-inner"
+ >
+ <i18n
+ tag="button"
+ path="status.ancestor_follow_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="diveIntoStatus(status.id)"
+ >
+ <FAIcon
+ place="icon"
+ icon="angle-double-right"
+ />
+ <span place="text">
+ {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
+ </span>
+ </i18n>
+ </div>
+ </div>
+ </div>
+ </div>
+ <thread-tree
+ v-for="status in showingTopLevel"
+ :key="status.id"
+ ref="statusComponent"
+ :depth="0"
+
+ :status="status"
+ :in-profile="inProfile"
+ :conversation="conversation"
+ :collapsable="collapsable"
+ :is-expanded="isExpanded"
+ :pinned-status-ids-object="pinnedStatusIdsObject"
+ :profile-user-id="profileUserId"
+
+ :focused="focused"
+ :get-replies="getReplies"
+ :highlight="maybeHighlight"
+ :set-highlight="setHighlight"
+ :toggle-expanded="toggleExpanded"
+
+ :simple="treeViewIsSimple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+ :dive="canDive ? diveIntoStatus : undefined"
+ />
+ </div>
+ <div
+ v-if="isLinearView"
+ class="thread-body"
+ >
+ <status
+ v-for="status in conversation"
+ :key="status.id"
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="getHighlight()"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status status-fadein panel-body"
+
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ </div>
+ </div>
</div>
<div
v-else
@@ -49,19 +193,74 @@
@import '../../_variables.scss';
.Conversation {
- .conversation-status {
+ .conversation-dive-to-top-level-box {
+ padding: var(--status-margin, $status-margin);
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
+ /* Make the button stretch along the whole row */
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .thread-ancestors {
+ margin-left: var(--status-margin, $status-margin);
+ border-left: 2px solid var(--border, $fallback--border);
}
- &.-expanded {
- .conversation-status:last-child {
- border-bottom: none;
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ .thread-ancestor.-faded .StatusContent {
+ --link: var(--faintLink);
+ --text: var(--faint);
+ color: var(--text);
+ }
+ .thread-ancestor-dive-box {
+ padding-left: var(--status-margin, $status-margin);
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ /* Make the button stretch along the whole row */
+ &, &-inner {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
}
}
+ .thread-ancestor-dive-box-inner {
+ padding: var(--status-margin, $status-margin);
+ }
+
+ .conversation-status {
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ }
+
+ .thread-ancestor-has-other-replies .conversation-status,
+ .thread-ancestor:last-child .conversation-status,
+ .thread-ancestor:last-child .thread-ancestor-dive-box,
+ &.-expanded .thread-tree .conversation-status {
+ border-bottom: none;
+ }
+
+ .thread-ancestors + .thread-tree > .conversation-status {
+ border-top-width: 1px;
+ border-top-style: solid;
+ border-top-color: var(--border, $fallback--border);
+ }
+
+ /* expanded conversation in timeline */
+ &.status-fadein.-expanded .thread-body {
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-left-color: $fallback--cRed;
+ border-left-color: var(--cRed, $fallback--cRed);
+ border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+ border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+ border-bottom: 1px solid var(--border, $fallback--border);
+ }
}
</style>
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 762aa610..304baf9d 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -52,6 +52,7 @@
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
target="_blank"
+ @click.stop
>
<FAIcon
fixed-width
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 389b6787..c836317a 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -267,7 +267,7 @@ const EmojiInput = {
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
- // this.input.setSelectionRange(position, position)
+ this.input.setSelectionRange(position, position)
this.caret = position
})
},
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index e6f9a9d3..aa2950ce 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,9 +1,9 @@
<template>
<div
+ ref="root"
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
- ref='root'
>
<slot />
<template v-if="enableEmojiPicker">
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index 8b142d08..d177efeb 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -2,7 +2,7 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const FeaturesPanel = {
computed: {
- chat: function () { return this.$store.state.instance.chatAvailable },
+ shout: function () { return this.$store.state.instance.shoutAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 9605d09d..a58a99af 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -8,8 +8,8 @@
</div>
<div class="panel-body features-panel">
<ul>
- <li v-if="chat">
- {{ $t('features_panel.chat') }}
+ <li v-if="shout">
+ {{ $t('features_panel.shout') }}
</li>
<li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }}
diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js
new file mode 100644
index 00000000..87f940a7
--- /dev/null
+++ b/src/components/flash/flash.js
@@ -0,0 +1,53 @@
+import RuffleService from '../../services/ruffle_service/ruffle_service.js'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faStop,
+ faExclamationTriangle
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faStop,
+ faExclamationTriangle
+)
+
+const Flash = {
+ props: [ 'src' ],
+ data () {
+ return {
+ player: false, // can be true, "hidden", false. hidden = element exists
+ loaded: false,
+ ruffleInstance: null
+ }
+ },
+ methods: {
+ openPlayer () {
+ if (this.player) return // prevent double-loading, or re-loading on failure
+ this.player = 'hidden'
+ RuffleService.getRuffle().then((ruffle) => {
+ const player = ruffle.newest().createPlayer()
+ player.config = {
+ letterbox: 'on'
+ }
+ const container = this.$refs.container
+ container.appendChild(player)
+ player.style.width = '100%'
+ player.style.height = '100%'
+ player.load(this.src).then(() => {
+ this.player = true
+ }).catch((e) => {
+ console.error('Error loading ruffle', e)
+ this.player = 'error'
+ })
+ this.ruffleInstance = player
+ this.$emit('playerOpened')
+ })
+ },
+ closePlayer () {
+ this.ruffleInstance && this.ruffleInstance.remove()
+ this.player = false
+ this.$emit('playerClosed')
+ }
+ }
+}
+
+export default Flash
diff --git a/src/components/flash/flash.vue b/src/components/flash/flash.vue
new file mode 100644
index 00000000..95f71950
--- /dev/null
+++ b/src/components/flash/flash.vue
@@ -0,0 +1,84 @@
+<template>
+ <div class="Flash">
+ <div
+ v-if="player === true || player === 'hidden'"
+ ref="container"
+ class="player"
+ :class="{ hidden: player === 'hidden' }"
+ />
+ <button
+ v-if="player !== true"
+ class="button-unstyled placeholder"
+ @click="openPlayer"
+ >
+ <span
+ v-if="player === 'hidden'"
+ class="label"
+ >
+ {{ $t('general.loading') }}
+ </span>
+ <span
+ v-if="player === 'error'"
+ class="label"
+ >
+ {{ $t('general.flash_fail') }}
+ </span>
+ <span
+ v-else
+ class="label"
+ >
+ <p>
+ {{ $t('general.flash_content') }}
+ </p>
+ <p>
+ <FAIcon icon="exclamation-triangle" />
+ {{ $t('general.flash_security') }}
+ </p>
+ </span>
+ </button>
+ </div>
+</template>
+
+<script src="./flash.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.Flash {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ .player {
+ height: 100%;
+ width: 100%;
+ }
+
+ .placeholder {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg);
+ color: var(--link);
+ }
+
+ .hider {
+ top: 0;
+ }
+
+ .label {
+ text-align: center;
+ flex: 1 1 0;
+ line-height: 1.2;
+ white-space: normal;
+ word-wrap: normal;
+ }
+
+ .hidden {
+ display: none;
+ visibility: 'hidden';
+ }
+}
+</style>
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 95e7cb6b..3edbcb86 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
- props: ['relationship', 'labelFollowing', 'buttonClass'],
+ props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
@@ -14,7 +14,7 @@ export default {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
- return this.$t('user_card.follow_again')
+ return this.$t('user_card.follow_cancel')
} else {
return this.$t('user_card.follow')
}
@@ -29,11 +29,14 @@ export default {
} else {
return this.$t('user_card.follow')
}
+ },
+ disabled () {
+ return this.inProgress || this.user.deactivated
}
},
methods: {
onClick () {
- this.relationship.following ? this.unfollow() : this.follow()
+ this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true
diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue
index 7f85f1d7..965d5256 100644
--- a/src/components/follow_button/follow_button.vue
+++ b/src/components/follow_button/follow_button.vue
@@ -2,7 +2,7 @@
<button
class="btn button-default follow-button"
:class="{ toggled: isPressed }"
- :disabled="inProgress"
+ :disabled="disabled"
:title="title"
@click="onClick"
>
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index b503783f..895a8fa3 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -20,6 +20,7 @@
:relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
class="follow-card-follow-button"
+ :user="user"
/>
</template>
</div>
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
index d3e4680f..137ef9c0 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,13 +1,10 @@
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faChevronDown
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faChevronDown
-)
+import { set } from 'vue'
+import Select from '../select/select.vue'
export default {
+ components: {
+ Select
+ },
props: [
'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
],
@@ -39,8 +36,8 @@ export default {
return this.dValue.family
},
set (v) {
- this.lValue.family = v
- this.$emit('update:modelValue', this.lValue)
+ set(this.lValue, 'family', v)
+ this.$emit('input', this.lValue)
}
},
isCustom () {
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 0cee2edc..ddda218b 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -22,30 +22,20 @@
class="opt-l"
:for="name + '-o'"
/>
- <label
- :for="name + '-font-switcher'"
- class="select"
+ <Select
+ :id="name + '-font-switcher'"
+ v-model="preset"
:disabled="!present"
+ class="font-switcher"
>
- <select
- :id="name + '-font-switcher'"
- v-model="preset"
- :disabled="!present"
- class="font-switcher"
+ <option
+ v-for="option in availableOptions"
+ :key="option"
+ :value="option"
>
- <option
- v-for="option in availableOptions"
- :key="option"
- :value="option"
- >
- {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
+ </option>
+ </Select>
<input
v-if="isCustom"
:id="name"
@@ -65,7 +55,8 @@
min-width: 10em;
}
&.custom {
- .select {
+ /* TODO Should make proper joiners... */
+ .font-switcher {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
index d13d98f9..094b3e57 100644
--- a/src/components/gallery/gallery.js
+++ b/src/components/gallery/gallery.js
@@ -1,15 +1,26 @@
import Attachment from '../attachment/attachment.vue'
-import { chunk, last, dropRight, sumBy } from 'lodash'
+import { sumBy } from 'lodash'
const Gallery = {
props: [
'attachments',
+ 'limitRows',
+ 'descriptions',
+ 'limit',
'nsfw',
- 'setMedia'
+ 'setMedia',
+ 'size',
+ 'editable',
+ 'removeAttachment',
+ 'shiftUpAttachment',
+ 'shiftDnAttachment',
+ 'editAttachment',
+ 'grid'
],
data () {
return {
- sizes: {}
+ sizes: {},
+ hidingLong: true
}
},
components: { Attachment },
@@ -18,26 +29,70 @@ const Gallery = {
if (!this.attachments) {
return []
}
- const rows = chunk(this.attachments, 3)
- if (last(rows).length === 1 && rows.length > 1) {
- // if 1 attachment on last row -> add it to the previous row instead
- const lastAttachment = last(rows)[0]
- const allButLastRow = dropRight(rows)
- last(allButLastRow).push(lastAttachment)
- return allButLastRow
+ const attachments = this.limit > 0
+ ? this.attachments.slice(0, this.limit)
+ : this.attachments
+ if (this.size === 'hide') {
+ return attachments.map(item => ({ minimal: true, items: [item] }))
}
+ const rows = this.grid
+ ? [{ grid: true, items: attachments }]
+ : attachments.reduce((acc, attachment, i) => {
+ if (attachment.mimetype.includes('audio')) {
+ return [...acc, { audio: true, items: [attachment] }, { items: [] }]
+ }
+ if (!(
+ attachment.mimetype.includes('image') ||
+ attachment.mimetype.includes('video') ||
+ attachment.mimetype.includes('flash')
+ )) {
+ return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
+ }
+ const maxPerRow = 3
+ const attachmentsRemaining = this.attachments.length - i + 1
+ const currentRow = acc[acc.length - 1].items
+ currentRow.push(attachment)
+ if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
+ return [...acc, { items: [] }]
+ } else {
+ return acc
+ }
+ }, [{ items: [] }]).filter(_ => _.items.length > 0)
return rows
},
- useContainFit () {
- return this.$store.getters.mergedConfig.useContainFit
+ attachmentsDimensionalScore () {
+ return this.rows.reduce((acc, row) => {
+ let size = 0
+ if (row.minimal) {
+ size += 1 / 8
+ } else if (row.audio) {
+ size += 1 / 4
+ } else {
+ size += 1 / (row.items.length + 0.6)
+ }
+ return acc + size
+ }, 0)
+ },
+ tooManyAttachments () {
+ if (this.editable || this.size === 'small') {
+ return false
+ } else if (this.size === 'hide') {
+ return this.attachments.length > 8
+ } else {
+ return this.attachmentsDimensionalScore > 1
+ }
}
},
methods: {
- onNaturalSizeLoad (id, size) {
- this.sizes[id] = size
+ onNaturalSizeLoad ({ id, width, height }) {
+ this.$set(this.sizes, id, { width, height })
},
- rowStyle (itemsPerRow) {
- return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
+ rowStyle (row) {
+ if (row.audio) {
+ return { 'padding-bottom': '25%' } // fixed reduced height for audio
+ } else if (!row.minimal && !row.grid) {
+ return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
+ }
},
itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id))
@@ -46,6 +101,16 @@ const Gallery = {
getAspectRatio (id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
+ },
+ toggleHidingLong (event) {
+ this.hidingLong = event
+ },
+ openGallery () {
+ this.$store.dispatch('setMedia', this.attachments)
+ this.$store.dispatch('setCurrentMedia', this.attachments[0])
+ },
+ onMedia () {
+ this.$store.dispatch('setMedia', this.attachments)
}
}
}
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index ca91c9c1..f2e1b5ce 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -1,26 +1,84 @@
<template>
<div
ref="galleryContainer"
- style="width: 100%;"
+ class="Gallery"
+ :class="{ '-long': tooManyAttachments && hidingLong }"
>
+ <div class="gallery-rows">
+ <div
+ v-for="(row, rowIndex) in rows"
+ :key="rowIndex"
+ class="gallery-row"
+ :style="rowStyle(row)"
+ :class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }"
+ >
+ <div
+ class="gallery-row-inner"
+ :class="{ '-grid': grid }"
+ >
+ <Attachment
+ v-for="(attachment, attachmentIndex) in row.items"
+ :key="attachment.id"
+ class="gallery-item"
+ :nsfw="nsfw"
+ :attachment="attachment"
+ :allow-play="false"
+ :size="size"
+ :editable="editable"
+ :remove="removeAttachment"
+ :shift-up="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment"
+ :shift-dn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment"
+ :edit="editAttachment"
+ :description="descriptions && descriptions[attachment.id]"
+ :hide-description="size === 'small' || tooManyAttachments && hidingLong"
+ :style="itemStyle(attachment.id, row.items)"
+ @setMedia="onMedia"
+ @naturalSizeLoad="onNaturalSizeLoad"
+ />
+ </div>
+ </div>
+ </div>
<div
- v-for="(row, index) in rows"
- :key="index"
- class="gallery-row"
- :style="rowStyle(row.length)"
- :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
+ v-if="tooManyAttachments"
+ class="many-attachments"
>
- <div class="gallery-row-inner">
- <attachment
- v-for="attachment in row"
- :key="attachment.id"
- :set-media="setMedia"
- :nsfw="nsfw"
- :attachment="attachment"
- :allow-play="false"
- :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
- :style="itemStyle(attachment.id, row)"
- />
+ <div class="many-attachments-text">
+ {{ $t("status.many_attachments", { number: attachments.length }) }}
+ </div>
+ <div class="many-attachments-buttons">
+ <span
+ v-if="!hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="toggleHidingLong(true)"
+ >
+ {{ $t("status.collapse_attachments") }}
+ </button>
+ </span>
+ <span
+ v-if="hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="toggleHidingLong(false)"
+ >
+ {{ $t("status.show_all_attachments") }}
+ </button>
+ </span>
+ <span
+ v-if="hidingLong"
+ class="many-attachments-button"
+ >
+ <button
+ class="button-unstyled -link"
+ @click="openGallery"
+ >
+ {{ $t("status.open_gallery") }}
+ </button>
+ </span>
</div>
</div>
</div>
@@ -31,12 +89,66 @@
<style lang="scss">
@import '../../_variables.scss';
-.gallery-row {
- position: relative;
- height: 0;
- width: 100%;
- flex-grow: 1;
- margin-top: 0.5em;
+.Gallery {
+ .gallery-rows {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .gallery-row {
+ position: relative;
+ height: 0;
+ width: 100%;
+ flex-grow: 1;
+
+ &:not(:first-child) {
+ margin-top: 0.5em;
+ }
+ }
+
+ &.-long {
+ .gallery-rows {
+ max-height: 25em;
+ overflow: hidden;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+ linear-gradient(to top, white, white);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+ }
+
+ .many-attachments-text {
+ text-align: center;
+ line-height: 2;
+ }
+
+ .many-attachments-buttons {
+ display: flex;
+ }
+
+ .many-attachments-button {
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ line-height: 2;
+
+ button {
+ padding: 0 2em;
+ }
+ }
+
+ .gallery-row {
+ &.-grid,
+ &.-minimal {
+ height: auto;
+ .gallery-row-inner {
+ position: relative;
+ }
+ }
+ }
.gallery-row-inner {
position: absolute;
@@ -48,9 +160,24 @@
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
+
+ &.-grid {
+ width: 100%;
+ height: auto;
+ position: relative;
+ display: grid;
+ grid-column-gap: 0.5em;
+ grid-row-gap: 0.5em;
+ grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
+
+ .gallery-item {
+ margin: 0;
+ height: 200px;
+ }
+ }
}
- .gallery-row-inner .attachment {
+ .gallery-item {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
@@ -61,32 +188,5 @@
margin: 0;
}
}
-
- .image-attachment {
- width: 100%;
- height: 100%;
- }
-
- .video-container {
- height: 100%;
- }
-
- &.contain-fit {
- img,
- video,
- canvas {
- object-fit: contain;
- height: 100%;
- }
- }
-
- &.cover-fit {
- img,
- video,
- canvas {
- object-fit: cover;
- }
- }
}
-
</style>
diff --git a/src/components/hashtag_link/hashtag_link.js b/src/components/hashtag_link/hashtag_link.js
new file mode 100644
index 00000000..a2433c2a
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.js
@@ -0,0 +1,36 @@
+import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
+
+const HashtagLink = {
+ name: 'HashtagLink',
+ props: {
+ url: {
+ required: true,
+ type: String
+ },
+ content: {
+ required: true,
+ type: String
+ },
+ tag: {
+ required: false,
+ type: String,
+ default: ''
+ }
+ },
+ methods: {
+ onClick () {
+ const tag = this.tag || extractTagFromUrl(this.url)
+ if (tag) {
+ const link = this.generateTagLink(tag)
+ this.$router.push(link)
+ } else {
+ window.open(this.url, '_blank')
+ }
+ },
+ generateTagLink (tag) {
+ return `/tag/${tag}`
+ }
+ }
+}
+
+export default HashtagLink
diff --git a/src/components/hashtag_link/hashtag_link.scss b/src/components/hashtag_link/hashtag_link.scss
new file mode 100644
index 00000000..78e8fb99
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.scss
@@ -0,0 +1,6 @@
+.HashtagLink {
+ position: relative;
+ white-space: normal;
+ display: inline-block;
+ color: var(--link);
+}
diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue
new file mode 100644
index 00000000..918ed26b
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.vue
@@ -0,0 +1,19 @@
+<template>
+ <span
+ class="HashtagLink"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <a
+ :href="url"
+ class="original"
+ target="_blank"
+ @click.prevent="onClick"
+ v-html="content"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ </span>
+</template>
+
+<script src="./hashtag_link.js"/>
+
+<style lang="scss" src="./hashtag_link.scss"/>
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index dc3bd408..cf307a24 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -3,27 +3,18 @@
<label for="interface-language-switcher">
{{ $t('settings.interfaceLanguage') }}
</label>
- <label
- for="interface-language-switcher"
- class="select"
+ <Select
+ id="interface-language-switcher"
+ v-model="language"
>
- <select
- id="interface-language-switcher"
- v-model="language"
+ <option
+ v-for="lang in languages"
+ :key="lang.code"
+ :value="lang.code"
>
- <option
- v-for="lang in languages"
- :key="lang.code"
- :value="lang.code"
- >
- {{ lang.name }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ {{ lang.name }}
+ </option>
+ </Select>
</div>
</template>
@@ -32,16 +23,12 @@ import languagesObject from '../../i18n/messages'
import localeService from '../../services/locale/locale.service.js'
import ISO6391 from 'iso-639-1'
import _ from 'lodash'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faChevronDown
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faChevronDown
-)
+import Select from '../select/select.vue'
export default {
+ components: {
+ Select
+ },
computed: {
languages () {
return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name))
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 38d66160..ff993664 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,24 +1,46 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
+import SwipeClick from '../swipe_click/swipe_click.vue'
import GestureService from '../../services/gesture_service/gesture_service'
+import Flash from 'src/components/flash/flash.vue'
+import fileTypeService from '../../services/file_type/file_type.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch,
+ faTimes
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch,
+ faTimes
)
const MediaModal = {
components: {
StillImage,
VideoAttachment,
- Modal
+ PinchZoom,
+ SwipeClick,
+ Modal,
+ Flash
+ },
+ data () {
+ return {
+ loading: false,
+ swipeDirection: GestureService.DIRECTION_LEFT,
+ swipeThreshold: () => {
+ const considerableMoveRatio = 1 / 4
+ return window.innerWidth * considerableMoveRatio
+ },
+ pinchZoomMinScale: 1,
+ pinchZoomScaleResetLimit: 1.2
+ }
},
computed: {
showing () {
@@ -27,6 +49,9 @@ const MediaModal = {
media () {
return this.$store.state.mediaViewer.media
},
+ description () {
+ return this.currentMedia.description
+ },
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
@@ -37,43 +62,62 @@ const MediaModal = {
return this.media.length > 1
},
type () {
- return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
+ return this.currentMedia ? this.getType(this.currentMedia) : null
}
},
- created () {
- this.mediaSwipeGestureRight = GestureService.swipeGesture(
- GestureService.DIRECTION_RIGHT,
- this.goPrev,
- 50
- )
- this.mediaSwipeGestureLeft = GestureService.swipeGesture(
- GestureService.DIRECTION_LEFT,
- this.goNext,
- 50
- )
- },
methods: {
- mediaTouchStart (e) {
- GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
- GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
- },
- mediaTouchMove (e) {
- GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
- GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
+ getType (media) {
+ return fileTypeService.fileType(media.mimetype)
},
hide () {
- this.$store.dispatch('closeMediaViewer')
+ // HACK: Closing immediately via a touch will cause the click
+ // to be processed on the content below the overlay
+ const transitionTime = 100 // ms
+ setTimeout(() => {
+ this.$store.dispatch('closeMediaViewer')
+ }, transitionTime)
+ },
+ hideIfNotSwiped (event) {
+ // If we have swiped over SwipeClick, do not trigger hide
+ const comp = this.$refs.swipeClick
+ if (!comp) {
+ this.hide()
+ } else {
+ comp.$gesture.click(event)
+ }
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
- this.$store.dispatch('setCurrent', this.media[prevIndex])
+ const newMedia = this.media[prevIndex]
+ if (this.getType(newMedia) === 'image') {
+ this.loading = true
+ }
+ this.$store.dispatch('setCurrentMedia', newMedia)
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
- this.$store.dispatch('setCurrent', this.media[nextIndex])
+ const newMedia = this.media[nextIndex]
+ if (this.getType(newMedia) === 'image') {
+ this.loading = true
+ }
+ this.$store.dispatch('setCurrentMedia', newMedia)
+ }
+ },
+ onImageLoaded () {
+ this.loading = false
+ },
+ handleSwipePreview (offsets) {
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
+ },
+ handleSwipeEnd (sign) {
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
+ if (sign > 0) {
+ this.goNext()
+ } else if (sign < 0) {
+ this.goPrev()
}
},
handleKeyupEvent (e) {
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 54bc5335..708a43c6 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -2,18 +2,38 @@
<Modal
v-if="showing"
class="media-modal-view"
- @backdropClicked="hide"
+ @backdropClicked="hideIfNotSwiped"
>
- <img
+ <SwipeClick
v-if="type === 'image'"
- class="modal-image"
- :src="currentMedia.url"
- :alt="currentMedia.description"
- :title="currentMedia.description"
- @touchstart.stop="mediaTouchStart"
- @touchmove.stop="mediaTouchMove"
- @click="hide"
+ ref="swipeClick"
+ class="modal-image-container"
+ :direction="swipeDirection"
+ :threshold="swipeThreshold"
+ @preview-requested="handleSwipePreview"
+ @swipe-finished="handleSwipeEnd"
+ @swipeless-clicked="hide"
>
+ <PinchZoom
+ ref="pinchZoom"
+ class="modal-image-container-inner"
+ selector=".modal-image"
+ reach-min-scale-strategy="reset"
+ stop-propagate-handled="stop-propgate-handled"
+ :allow-pan-min-scale="pinchZoomMinScale"
+ :min-scale="pinchZoomMinScale"
+ :reset-to-min-scale-limit="pinchZoomScaleResetLimit"
+ >
+ <img
+ :class="{ loading }"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ @load="onImageLoaded"
+ >
+ </PinchZoom>
+ </SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
@@ -28,38 +48,84 @@
:title="currentMedia.description"
controls
/>
+ <Flash
+ v-if="type === 'flash'"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ />
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
- class="modal-view-button-arrow modal-view-button-arrow--prev"
+ class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
- class="arrow-icon"
+ class="button-icon arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
- class="modal-view-button-arrow modal-view-button-arrow--next"
+ class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
- class="arrow-icon"
+ class="button-icon arrow-icon"
icon="chevron-right"
/>
</button>
+ <button
+ class="modal-view-button modal-view-button-hide"
+ :title="$t('media_modal.hide')"
+ @click.stop.prevent="hide"
+ >
+ <FAIcon
+ class="button-icon"
+ icon="times"
+ />
+ </button>
+
+ <span
+ v-if="description"
+ class="description"
+ >
+ {{ description }}
+ </span>
+ <span
+ class="counter"
+ >
+ {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
+ </span>
+ <span
+ v-if="loading"
+ class="loading-spinner"
+ >
+ <FAIcon
+ spin
+ icon="circle-notch"
+ size="5x"
+ />
+ </span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
+$modal-view-button-icon-height: 3em;
+$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
+$modal-view-button-icon-width: 3em;
+$modal-view-button-icon-margin: 0.5em;
+
.modal-view.media-modal-view {
z-index: 1001;
+ flex-direction: column;
- .modal-view-button-arrow {
+ .modal-view-button-arrow,
+ .modal-view-button-hide {
opacity: 0.75;
&:focus,
@@ -67,69 +133,154 @@
outline: none;
box-shadow: none;
}
+
&:hover {
opacity: 1;
}
}
+ overflow: hidden;
}
-@keyframes media-fadein {
- from {
- opacity: 0;
+.media-modal-view {
+ @keyframes media-fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
- to {
- opacity: 1;
+
+ .modal-image-container {
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ flex-direction: column;
+ max-width: 100%;
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ flex-grow: 1;
+ justify-content: center;
+
+ &-inner {
+ width: 100%;
+ height: 100%;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
}
-}
-.modal-image {
- max-width: 90%;
- max-height: 90%;
- box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
- image-orientation: from-image; // NOTE: only FF supports this
- animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
-}
+ .description,
+ .counter {
+ /* Hardcoded since background is also hardcoded */
+ color: white;
+ margin-top: 1em;
+ text-shadow: 0 0 10px black, 0 0 10px black;
+ padding: 0.2em 2em;
+ }
+
+ .description {
+ flex: 0 0 auto;
+ overflow-y: auto;
+ min-height: 1em;
+ max-width: 500px;
+ max-height: 9.5em;
+ word-break: break-all;
+ }
+
+ .modal-image {
+ max-width: 100%;
+ max-height: 100%;
+ image-orientation: from-image; // NOTE: only FF supports this
+ animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
-.modal-view-button-arrow {
- position: absolute;
- display: block;
- top: 50%;
- margin-top: -50px;
- width: 70px;
- height: 100px;
- border: 0;
- padding: 0;
- opacity: 0;
- box-shadow: none;
- background: none;
- appearance: none;
- overflow: visible;
- cursor: pointer;
- transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+ &.loading {
+ opacity: 0.5;
+ }
+ }
- .arrow-icon {
+ .loading-spinner {
+ width: 100%;
+ height: 100%;
position: absolute;
- top: 35px;
- height: 30px;
- width: 32px;
- font-size: 14px;
- line-height: 30px;
- color: #FFF;
- text-align: center;
- background-color: rgba(0,0,0,.3);
+ pointer-events: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ svg {
+ color: white;
+ }
}
- &--prev {
- left: 0;
+ .modal-view-button {
+ border: 0;
+ padding: 0;
+ opacity: 0;
+ box-shadow: none;
+ background: none;
+ appearance: none;
+ overflow: visible;
+ cursor: pointer;
+ transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+ height: $modal-view-button-icon-height;
+ width: $modal-view-button-icon-width;
+
+ .button-icon {
+ position: absolute;
+ height: $modal-view-button-icon-height;
+ width: $modal-view-button-icon-width;
+ font-size: 14px;
+ line-height: $modal-view-button-icon-height;
+ color: #FFF;
+ text-align: center;
+ background-color: rgba(0,0,0,.3);
+ }
+ }
+
+ .modal-view-button-arrow {
+ position: absolute;
+ display: block;
+ top: 50%;
+ margin-top: $modal-view-button-icon-half-height;
+ width: $modal-view-button-icon-width;
+ height: $modal-view-button-icon-height;
+
.arrow-icon {
- left: 6px;
+ position: absolute;
+ top: 0;
+ line-height: $modal-view-button-icon-height;
+ color: #FFF;
+ text-align: center;
+ background-color: rgba(0,0,0,.3);
+ }
+
+ &--prev {
+ left: 0;
+ .arrow-icon {
+ left: $modal-view-button-icon-margin;
+ }
+ }
+
+ &--next {
+ right: 0;
+ .arrow-icon {
+ right: $modal-view-button-icon-margin;
+ }
}
}
- &--next {
+ .modal-view-button-hide {
+ position: absolute;
+ top: 0;
right: 0;
- .arrow-icon {
- right: 6px;
+ .button-icon {
+ top: $modal-view-button-icon-margin;
+ right: $modal-view-button-icon-margin;
}
}
}
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
new file mode 100644
index 00000000..55eea195
--- /dev/null
+++ b/src/components/mention_link/mention_link.js
@@ -0,0 +1,134 @@
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { mapGetters, mapState } from 'vuex'
+import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAt
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAt
+)
+
+const MentionLink = {
+ name: 'MentionLink',
+ components: {
+ UserAvatar
+ },
+ props: {
+ url: {
+ required: true,
+ type: String
+ },
+ content: {
+ required: true,
+ type: String
+ },
+ userId: {
+ required: false,
+ type: String
+ },
+ userScreenName: {
+ required: false,
+ type: String
+ }
+ },
+ methods: {
+ onClick () {
+ const link = generateProfileLink(
+ this.userId || this.user.id,
+ this.userScreenName || this.user.screen_name
+ )
+ this.$router.push(link)
+ }
+ },
+ computed: {
+ user () {
+ return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
+ },
+ isYou () {
+ // FIXME why user !== currentUser???
+ return this.user && this.user.id === this.currentUser.id
+ },
+ userName () {
+ return this.user && this.userNameFullUi.split('@')[0]
+ },
+ serverName () {
+ // XXX assumed that domain does not contain @
+ return this.user && (this.userNameFullUi.split('@')[1] || this.$store.getters.instanceDomain)
+ },
+ userNameFull () {
+ return this.user && this.user.screen_name
+ },
+ userNameFullUi () {
+ return this.user && this.user.screen_name_ui
+ },
+ highlight () {
+ return this.user && this.mergedConfig.highlight[this.user.screen_name]
+ },
+ highlightType () {
+ return this.highlight && ('-' + this.highlight.type)
+ },
+ highlightClass () {
+ if (this.highlight) return highlightClass(this.user)
+ },
+ style () {
+ if (this.highlight) {
+ const {
+ backgroundColor,
+ backgroundPosition,
+ backgroundImage,
+ ...rest
+ } = highlightStyle(this.highlight)
+ return rest
+ }
+ },
+ classnames () {
+ return [
+ {
+ '-you': this.isYou && this.shouldBoldenYou,
+ '-highlighted': this.highlight
+ },
+ this.highlightType
+ ]
+ },
+ useAtIcon () {
+ return this.mergedConfig.useAtIcon
+ },
+ isRemote () {
+ return this.userName !== this.userNameFull
+ },
+ shouldShowFullUserName () {
+ const conf = this.mergedConfig.mentionLinkDisplay
+ if (conf === 'short') {
+ return false
+ } else if (conf === 'full') {
+ return true
+ } else { // full_for_remote
+ return this.isRemote
+ }
+ },
+ shouldShowTooltip () {
+ return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote
+ },
+ shouldShowAvatar () {
+ return this.mergedConfig.mentionLinkShowAvatar
+ },
+ shouldShowYous () {
+ return this.mergedConfig.mentionLinkShowYous
+ },
+ shouldBoldenYou () {
+ return this.mergedConfig.mentionLinkBoldenYou
+ },
+ shouldFadeDomain () {
+ return this.mergedConfig.mentionLinkFadeDomain
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ })
+ }
+}
+
+export default MentionLink
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
new file mode 100644
index 00000000..1d856ff9
--- /dev/null
+++ b/src/components/mention_link/mention_link.scss
@@ -0,0 +1,115 @@
+@import '../../_variables.scss';
+
+.MentionLink {
+ position: relative;
+ white-space: normal;
+ display: inline;
+ color: var(--link);
+ word-break: normal;
+
+ & .new,
+ & .original {
+ display: inline;
+ border-radius: 2px;
+ }
+
+ .mention-avatar {
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ user-select: none;
+ margin-right: 0.2em;
+ }
+
+ .full {
+ position: absolute;
+ display: inline-block;
+ pointer-events: none;
+ opacity: 0;
+ top: 100%;
+ left: 0;
+ height: 100%;
+ word-wrap: normal;
+ white-space: nowrap;
+ transition: opacity 0.2s ease;
+ z-index: 1;
+ margin-top: 0.25em;
+ padding: 0.5em;
+ user-select: all;
+ }
+
+ & .short.-with-tooltip,
+ & .you {
+ user-select: none;
+ }
+
+ & .short,
+ & .full {
+ white-space: nowrap;
+ }
+
+ .shortName {
+ white-space: normal;
+ }
+
+ .new {
+ &.-you {
+ & .shortName,
+ & .full {
+ font-weight: 600;
+ }
+ }
+
+ .at {
+ color: var(--link);
+ opacity: 0.8;
+ display: inline-block;
+ line-height: 1;
+ padding: 0 0.1em;
+ vertical-align: -25%;
+ margin: 0;
+ }
+
+ &.-striped {
+ & .shortName,
+ & .full {
+ background-image:
+ repeating-linear-gradient(
+ 135deg,
+ var(--____highlight-tintColor),
+ var(--____highlight-tintColor) 5px,
+ var(--____highlight-tintColor2) 5px,
+ var(--____highlight-tintColor2) 10px
+ );
+ }
+ }
+
+ &.-solid {
+ & .shortName,
+ & .full {
+ background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
+ }
+ }
+
+ &.-side {
+ & .shortName,
+ & .userNameFull {
+ box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
+ }
+ }
+ }
+
+ &:hover .new .full {
+ opacity: 1;
+ pointer-events: initial;
+ }
+
+ .serverName.-faded {
+ color: var(--faintLink, $fallback--link);
+ }
+
+ .full .-faded {
+ color: var(--faint, $fallback--faint);
+ }
+}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
new file mode 100644
index 00000000..3562f511
--- /dev/null
+++ b/src/components/mention_link/mention_link.vue
@@ -0,0 +1,75 @@
+<template>
+ <span
+ class="MentionLink"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <a
+ v-if="!user"
+ :href="url"
+ class="original"
+ target="_blank"
+ v-html="content"
+ /><!-- eslint-enable vue/no-v-html --><span
+ v-if="user"
+ class="new"
+ :style="style"
+ :class="classnames"
+ >
+ <a
+ class="short button-unstyled"
+ :class="{ '-with-tooltip': shouldShowTooltip }"
+ :href="url"
+ @click.prevent="onClick"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <UserAvatar
+ v-if="shouldShowAvatar"
+ class="mention-avatar"
+ :user="user"
+ /><span
+ class="shortName"
+ ><FAIcon
+ v-if="useAtIcon"
+ size="sm"
+ icon="at"
+ class="at"
+ />{{ !useAtIcon ? '@' : '' }}<span
+ class="userName"
+ v-html="userName"
+ /><span
+ v-if="shouldShowFullUserName"
+ class="serverName"
+ :class="{ '-faded': shouldFadeDomain }"
+ v-html="'@' + serverName"
+ /></span><span
+ v-if="isYou && shouldShowYous"
+ :class="{ '-you': shouldBoldenYou }"
+ > {{ $t('status.you') }}</span>
+ <!-- eslint-enable vue/no-v-html -->
+ </a><span
+ v-if="shouldShowTooltip"
+ class="full popover-default"
+ :class="[highlightType]"
+ >
+ <span
+ class="userNameFull"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ @<span
+ class="userName"
+ v-html="userName"
+ /><span
+ class="serverName"
+ :class="{ '-faded': shouldFadeDomain }"
+ v-html="'@' + serverName"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ </span>
+ </span>
+ </span>
+ </span>
+</template>
+
+<script src="./mention_link.js"/>
+
+<style lang="scss" src="./mention_link.scss"/>
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
new file mode 100644
index 00000000..a4a0c724
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.js
@@ -0,0 +1,37 @@
+import MentionLink from 'src/components/mention_link/mention_link.vue'
+import { mapGetters } from 'vuex'
+
+export const MENTIONS_LIMIT = 5
+
+const MentionsLine = {
+ name: 'MentionsLine',
+ props: {
+ mentions: {
+ required: true,
+ type: Array
+ }
+ },
+ data: () => ({ expanded: false }),
+ components: {
+ MentionLink
+ },
+ computed: {
+ mentionsComputed () {
+ return this.mentions.slice(0, MENTIONS_LIMIT)
+ },
+ extraMentions () {
+ return this.mentions.slice(MENTIONS_LIMIT)
+ },
+ manyMentions () {
+ return this.extraMentions.length > 0
+ },
+ ...mapGetters(['mergedConfig'])
+ },
+ methods: {
+ toggleShowMore () {
+ this.expanded = !this.expanded
+ }
+ }
+}
+
+export default MentionsLine
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
new file mode 100644
index 00000000..9a622e75
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.scss
@@ -0,0 +1,13 @@
+.MentionsLine {
+ word-break: break-all;
+
+ .mention-link:not(:first-child)::before {
+ content: ' ';
+ }
+
+ .showMoreLess {
+ margin-left: 0.5em;
+ white-space: normal;
+ color: var(--link);
+ }
+}
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
new file mode 100644
index 00000000..f375e3b0
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.vue
@@ -0,0 +1,43 @@
+<template>
+ <span class="MentionsLine">
+ <MentionLink
+ v-for="mention in mentionsComputed"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ :first-mention="false"
+ /><span
+ v-if="manyMentions"
+ class="extraMentions"
+ >
+ <span
+ v-if="expanded"
+ class="fullExtraMentions"
+ >
+ <MentionLink
+ v-for="mention in extraMentions"
+ :key="mention.index"
+ class="mention-link"
+ :content="mention.content"
+ :url="mention.url"
+ :first-mention="false"
+ />
+ </span><button
+ v-if="!expanded"
+ class="button-unstyled showMoreLess"
+ @click="toggleShowMore"
+ >
+ {{ $t('status.plus_more', { number: extraMentions.length }) }}
+ </button><button
+ v-if="expanded"
+ class="button-unstyled showMoreLess"
+ @click="toggleShowMore"
+ >
+ {{ $t('general.show_less') }}
+ </button>
+ </span>
+ </span>
+</template>
+<script src="./mentions_line.js" ></script>
+<style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 293b85fd..4866ac57 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -44,6 +44,9 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
+ isPersistent () {
+ return !!this.$store.getters.mergedConfig.showNewPostButton
+ },
autohideFloatingPostButton () {
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
}
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
index 767f8244..37becf4c 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -2,7 +2,7 @@
<div v-if="isLoggedIn">
<button
class="button-default new-status-button"
- :class="{ 'hidden': isHidden }"
+ :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
@click="openPostForm"
>
<FAIcon icon="pen" />
@@ -47,7 +47,7 @@
}
@media all and (min-width: 801px) {
- .new-status-button {
+ .new-status-button:not(.always-show) {
display: none;
}
}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index a0b600d2..3fde8106 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -1,17 +1,56 @@
import { mapState } from 'vuex'
import { get } from 'lodash'
+/**
+ * This is for backwards compatibility. We originally didn't recieve
+ * extra info like a reason why an instance was rejected/quarantined/etc.
+ * Because we didn't want to break backwards compatibility it was decided
+ * to add an extra "info" key.
+ */
+const toInstanceReasonObject = (instances, info, key) => {
+ return instances.map(instance => {
+ if (info[key] && info[key][instance] && info[key][instance]['reason']) {
+ return { instance: instance, reason: info[key][instance]['reason'] }
+ }
+ return { instance: instance, reason: '' }
+ })
+}
+
const MRFTransparencyPanel = {
computed: {
...mapState({
federationPolicy: state => get(state, 'instance.federationPolicy'),
mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []),
- quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []),
- acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []),
- rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
- ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
- mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
- mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+ quarantineInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.quarantined_instances', []),
+ get(state, 'instance.federationPolicy.quarantined_instances_info', []),
+ 'quarantined_instances'
+ ),
+ acceptInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.accept', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'accept'
+ ),
+ rejectInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.reject', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'reject'
+ ),
+ ftlRemovalInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'federated_timeline_removal'
+ ),
+ mediaNsfwInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'media_nsfw'
+ ),
+ mediaRemovalInstances: state => toInstanceReasonObject(
+ get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+ get(state, 'instance.federationPolicy.mrf_simple_info', []),
+ 'media_removal'
+ ),
keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
new file mode 100644
index 00000000..80ea01d4
--- /dev/null
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
@@ -0,0 +1,21 @@
+.mrf-section {
+ margin: 1em;
+
+ table {
+ width:100%;
+ text-align: left;
+ padding-left:10px;
+ padding-bottom:20px;
+
+ th, td {
+ width: 180px;
+ max-width: 360px;
+ overflow: hidden;
+ vertical-align: text-top;
+ }
+
+ th+th, td+td {
+ width: auto;
+ }
+ }
+}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
index acdf822e..1787fa07 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
@@ -31,13 +31,24 @@
<p>{{ $t("about.mrf.simple.accept_desc") }}</p>
- <ul>
- <li
- v-for="instance in acceptInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in acceptInstances"
+ :key="entry.instance + '_accept'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="rejectInstances.length">
@@ -45,13 +56,24 @@
<p>{{ $t("about.mrf.simple.reject_desc") }}</p>
- <ul>
- <li
- v-for="instance in rejectInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in rejectInstances"
+ :key="entry.instance + '_reject'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="quarantineInstances.length">
@@ -59,13 +81,24 @@
<p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
- <ul>
- <li
- v-for="instance in quarantineInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in quarantineInstances"
+ :key="entry.instance + '_quarantine'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="ftlRemovalInstances.length">
@@ -73,13 +106,24 @@
<p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
- <ul>
- <li
- v-for="instance in ftlRemovalInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in ftlRemovalInstances"
+ :key="entry.instance + '_ftl_removal'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="mediaNsfwInstances.length">
@@ -87,13 +131,24 @@
<p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
- <ul>
- <li
- v-for="instance in mediaNsfwInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in mediaNsfwInstances"
+ :key="entry.instance + '_media_nsfw'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<div v-if="mediaRemovalInstances.length">
@@ -101,13 +156,24 @@
<p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
- <ul>
- <li
- v-for="instance in mediaRemovalInstances"
- :key="instance"
- v-text="instance"
- />
- </ul>
+ <table>
+ <tr>
+ <th>{{ $t("about.mrf.simple.instance") }}</th>
+ <th>{{ $t("about.mrf.simple.reason") }}</th>
+ </tr>
+ <tr
+ v-for="entry in mediaRemovalInstances"
+ :key="entry.instance + '_media_removal'"
+ >
+ <td>{{ entry.instance }}</td>
+ <td v-if="entry.reason === ''">
+ {{ $t("about.mrf.simple.not_applicable") }}
+ </td>
+ <td v-else>
+ {{ entry.reason }}
+ </td>
+ </tr>
+ </table>
</div>
<h2 v-if="hasKeywordPolicies">
@@ -161,7 +227,6 @@
<script src="./mrf_transparency_panel.js"></script>
<style lang="scss">
-.mrf-section {
- margin: 1em;
-}
+@import '../../_variables.scss';
+@import './mrf_transparency_panel.scss';
</style>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 4aa9affd..398bb7a9 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -4,6 +4,7 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -44,7 +45,8 @@ const Notification = {
UserAvatar,
UserCard,
Timeago,
- Status
+ Status,
+ RichContent
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
index f5905560..38978137 100644
--- a/src/components/notification/notification.scss
+++ b/src/components/notification/notification.scss
@@ -2,6 +2,19 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ word-wrap: break-word;
+ word-break: break-word;
+ --emoji-size: 14px;
+
+ &:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
+
&.-muted {
padding: 0.25em 0.6em;
height: 1.2em;
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index cafa1e79..c03ebc89 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,6 +1,7 @@
<template>
<Status
v-if="notification.type === 'mention'"
+ class="Notification"
:compact="true"
:statusoid="notification.status"
/>
@@ -51,12 +52,14 @@
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
- <bdi
- v-if="!!notification.from_profile.name_html"
- class="username"
- :title="'@'+notification.from_profile.screen_name_ui"
- v-html="notification.from_profile.name_html"
- />
+ <bdi v-if="!!notification.from_profile.name_html">
+ <RichContent
+ class="username"
+ :title="'@'+notification.from_profile.screen_name_ui"
+ :html="notification.from_profile.name_html"
+ :emoji="notification.from_profile.emoji"
+ />
+ </bdi>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
@@ -181,8 +184,9 @@
</router-link>
</div>
<template v-else>
- <status-content
+ <StatusContent
class="faint"
+ :compact="true"
:status="notification.action"
/>
</template>
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 2bb627a8..9e5663ed 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -37,11 +37,6 @@
.notification {
box-sizing: border-box;
- border-bottom: 1px solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- word-wrap: break-word;
- word-break: break-word;
&:hover .animated.Avatar {
canvas {
@@ -148,13 +143,6 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
-
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
}
.timeago {
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
new file mode 100644
index 00000000..82670ddf
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -0,0 +1,13 @@
+import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
+
+export default {
+ methods: {
+ setTransform ({ scale, x, y }) {
+ this.$el.setTransform({ scale, x, y })
+ }
+ },
+ created () {
+ // Make lint happy
+ (() => PinchZoom)()
+ }
+}
diff --git a/src/components/pinch_zoom/pinch_zoom.vue b/src/components/pinch_zoom/pinch_zoom.vue
new file mode 100644
index 00000000..18d69719
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.vue
@@ -0,0 +1,11 @@
+<template>
+ <pinch-zoom
+ class="pinch-zoom-parent"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ <slot />
+ </pinch-zoom>
+</template>
+
+<script src="./pinch_zoom.js"></script>
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index 4611a95c..eda1733a 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -1,10 +1,14 @@
-import Timeago from '../timeago/timeago.vue'
+import Timeago from 'components/timeago/timeago.vue'
+import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash'
export default {
name: 'Poll',
- props: ['basePoll'],
- components: { Timeago },
+ props: ['basePoll', 'emoji'],
+ components: {
+ Timeago,
+ RichContent
+ },
data () {
return {
loading: false,
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 2215d675..e949c653 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,8 +17,11 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span v-html="option.title_html" />
+ <RichContent
+ :html="option.title_html"
+ :handle-links="false"
+ :emoji="emoji"
+ />
</div>
<div
class="result-fill"
@@ -42,8 +45,11 @@
:value="index"
>
<label class="option-vote">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="option.title_html" />
+ <RichContent
+ :html="option.title_html"
+ :handle-links="false"
+ :emoji="emoji"
+ />
</label>
</div>
</div>
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index 1f8df3f9..e30645c3 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -1,19 +1,21 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
+import Select from '../select/select.vue'
import {
faTimes,
- faChevronDown,
faPlus
} from '@fortawesome/free-solid-svg-icons'
library.add(
faTimes,
- faChevronDown,
faPlus
)
export default {
+ components: {
+ Select
+ },
name: 'PollForm',
props: ['visible'],
data: () => ({
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
index c4403210..3620075a 100644
--- a/src/components/poll/poll_form.vue
+++ b/src/components/poll/poll_form.vue
@@ -46,23 +46,19 @@
class="poll-type"
:title="$t('polls.type')"
>
- <label
- for="poll-type-selector"
- class="select"
+ <Select
+ v-model="pollType"
+ class="poll-type-select"
+ unstyled="true"
+ @change="updatePollToParent"
>
- <select
- v-model="pollType"
- class="select"
- @change="updatePollToParent"
- >
- <option value="single">{{ $t('polls.single_choice') }}</option>
- <option value="multiple">{{ $t('polls.multiple_choices') }}</option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ <option value="single">
+ {{ $t('polls.single_choice') }}
+ </option>
+ <option value="multiple">
+ {{ $t('polls.multiple_choices') }}
+ </option>
+ </Select>
</div>
<div
class="poll-expiry"
@@ -76,24 +72,20 @@
:max="maxExpirationInCurrentUnit"
@change="expiryAmountChange"
>
- <label class="expiry-unit select">
- <select
- v-model="expiryUnit"
- @change="expiryAmountChange"
+ <Select
+ v-model="expiryUnit"
+ unstyled="true"
+ class="expiry-unit"
+ @change="expiryAmountChange"
+ >
+ <option
+ v-for="unit in expiryUnits"
+ :key="unit"
+ :value="unit"
>
- <option
- v-for="unit in expiryUnits"
- :key="unit"
- :value="unit"
- >
- {{ $t(`time.${unit}_short`, ['']) }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ {{ $t(`time.${unit}_short`, ['']) }}
+ </option>
+ </Select>
</div>
</div>
</div>
@@ -147,10 +139,8 @@
.poll-type {
margin-right: 0.75em;
flex: 1 1 60%;
- .select {
- border: none;
- box-shadow: none;
- background-color: transparent;
+
+ .poll-type-select {
padding-right: 0.75em;
}
}
@@ -162,12 +152,6 @@
width: 3em;
text-align: right;
}
-
- .expiry-unit {
- border: none;
- box-shadow: none;
- background-color: transparent;
- }
}
}
</style>
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index 2e78a09e..8588b351 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -33,7 +33,7 @@
@import '../../_variables.scss';
.popover-trigger-button {
- display: block;
+ display: inline-block;
}
.popover {
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index e540654b..fe07309f 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
+import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@@ -11,10 +12,10 @@ import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
+import Select from '../select/select.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
- faChevronDown,
faSmileBeam,
faPollH,
faUpload,
@@ -24,7 +25,6 @@ import {
} from '@fortawesome/free-solid-svg-icons'
library.add(
- faChevronDown,
faSmileBeam,
faPollH,
faUpload,
@@ -84,8 +84,10 @@ const PostStatusForm = {
PollForm,
ScopeSelector,
Checkbox,
+ Select,
Attachment,
- StatusContent
+ StatusContent,
+ Gallery
},
mounted () {
this.updateIdempotencyKey()
@@ -388,6 +390,21 @@ const PostStatusForm = {
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
+ editAttachment (fileInfo, newText) {
+ this.newStatus.mediaDescriptions[fileInfo.id] = newText
+ },
+ shiftUpMediaFile (fileInfo) {
+ const { files } = this.newStatus
+ const index = this.newStatus.files.indexOf(fileInfo)
+ files.splice(index, 1)
+ files.splice(index - 1, 0, fileInfo)
+ },
+ shiftDnMediaFile (fileInfo) {
+ const { files } = this.newStatus
+ const index = this.newStatus.files.indexOf(fileInfo)
+ files.splice(index, 1)
+ files.splice(index + 1, 0, fileInfo)
+ },
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index c0a85727..45ec9656 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -189,28 +189,19 @@
v-if="postFormats.length > 1"
class="text-format"
>
- <label
- for="post-content-type"
- class="select"
+ <Select
+ id="post-content-type"
+ v-model="newStatus.contentType"
+ class="form-control"
>
- <select
- id="post-content-type"
- v-model="newStatus.contentType"
- class="form-control"
+ <option
+ v-for="postFormat in postFormats"
+ :key="postFormat"
+ :value="postFormat"
>
- <option
- v-for="postFormat in postFormats"
- :key="postFormat"
- :value="postFormat"
- >
- {{ $t(`post_status.content_type["${postFormat}"]`) }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ {{ $t(`post_status.content_type["${postFormat}"]`) }}
+ </option>
+ </Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
@@ -296,32 +287,22 @@
@click="clearError"
/>
</div>
- <div class="attachments">
- <div
- v-for="file in newStatus.files"
- :key="file.url"
- class="media-upload-wrapper"
- >
- <button
- class="button-unstyled hider"
- @click="removeMediaFile(file)"
- >
- <FAIcon icon="times" />
- </button>
- <attachment
- :attachment="file"
- :set-media="() => $store.dispatch('setMedia', newStatus.files)"
- size="small"
- allow-play="false"
- />
- <input
- v-model="newStatus.mediaDescriptions[file.id]"
- type="text"
- :placeholder="$t('post_status.media_description')"
- @keydown.enter.prevent=""
- >
- </div>
- </div>
+ <gallery
+ v-if="newStatus.files && newStatus.files.length > 0"
+ class="attachments"
+ :grid="true"
+ :nsfw="false"
+ :attachments="newStatus.files"
+ :descriptions="newStatus.mediaDescriptions"
+ :set-media="() => $store.dispatch('setMedia', newStatus.files)"
+ :editable="true"
+ :edit-attachment="editAttachment"
+ :remove-attachment="removeMediaFile"
+ :shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
+ :shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
+ @play="$emit('mediaplay', attachment.id)"
+ @pause="$emit('mediapause', attachment.id)"
+ />
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
@@ -339,26 +320,13 @@
<style lang="scss">
@import '../../_variables.scss';
-.tribute-container {
- ul {
- padding: 0px;
- li {
- display: flex;
- align-items: center;
- }
- }
- img {
- padding: 3px;
- width: 16px;
- height: 16px;
- border-radius: $fallback--avatarAltRadius;
- border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
- }
-}
-
.post-status-form {
position: relative;
+ .attachments {
+ margin-bottom: 0.5em;
+ }
+
.form-bottom {
display: flex;
justify-content: space-between;
@@ -516,15 +484,6 @@
flex-direction: column;
}
- .attachments .media-upload-wrapper {
- position: relative;
-
- .attachment {
- margin: 0;
- padding: 0;
- }
- }
-
.btn {
cursor: pointer;
}
@@ -625,11 +584,4 @@
border: 2px dashed var(--text, $fallback--text);
}
}
-
-// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
-img.media-upload, .media-upload-container > video {
- line-height: 0;
- max-height: 200px;
- max-width: 100%;
-}
</style>
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
new file mode 100644
index 00000000..46bc661a
--- /dev/null
+++ b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,328 @@
+import Vue from 'vue'
+import { unescape, flattenDeep } from 'lodash'
+import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
+import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+import StillImage from 'src/components/still-image/still-image.vue'
+import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
+import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
+
+import './rich_content.scss'
+
+/**
+ * RichContent, The Über-powered component for rendering Post HTML.
+ *
+ * This takes post HTML and does multiple things to it:
+ * - Groups all mentions into <MentionsLine>, this affects all mentions regardles
+ * of where they are (beginning/middle/end), even single mentions are converted
+ * to a <MentionsLine> containing single <MentionLink>.
+ * - Replaces emoji shortcodes with <StillImage>'d images.
+ *
+ * There are two problems with this component's architecture:
+ * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
+ * proven to be a massive overcomplication due to amount of things done here.
+ * 2. We need to output both render and some extra data, which seems to be imp-
+ * possible in vue. Current solution is to emit 'parseReady' event when parsing
+ * is done within render() function.
+ *
+ * Apart from that one small hiccup with emit in render this _should_ be vue3-ready
+ */
+export default Vue.component('RichContent', {
+ name: 'RichContent',
+ props: {
+ // Original html content
+ html: {
+ required: true,
+ type: String
+ },
+ attentions: {
+ required: false,
+ default: () => []
+ },
+ // Emoji object, as in status.emojis, note the "s" at the end...
+ emoji: {
+ required: true,
+ type: Array
+ },
+ // Whether to handle links or not (posts: yes, everything else: no)
+ handleLinks: {
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ // Meme arrows
+ greentext: {
+ required: false,
+ type: Boolean,
+ default: false
+ }
+ },
+ // NEVER EVER TOUCH DATA INSIDE RENDER
+ render (h) {
+ // Pre-process HTML
+ const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
+ let currentMentions = null // Current chain of mentions, we group all mentions together
+ // This is used to recover spacing removed when parsing mentions
+ let lastSpacing = ''
+
+ const lastTags = [] // Tags that appear at the end of post body
+ const writtenMentions = [] // All mentions that appear in post body
+ const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
+ // to collapse too many mentions in a row
+ const writtenTags = [] // All tags that appear in post body
+ // unique index for vue "tag" property
+ let mentionIndex = 0
+ let tagsIndex = 0
+
+ const renderImage = (tag) => {
+ return <StillImage
+ {...{ attrs: getAttrs(tag) }}
+ class="img"
+ />
+ }
+
+ const renderHashtag = (attrs, children, encounteredTextReverse) => {
+ const linkData = getLinkData(attrs, children, tagsIndex++)
+ writtenTags.push(linkData)
+ if (!encounteredTextReverse) {
+ lastTags.push(linkData)
+ }
+ return <HashtagLink {...{ props: linkData }}/>
+ }
+
+ const renderMention = (attrs, children) => {
+ const linkData = getLinkData(attrs, children, mentionIndex++)
+ linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
+ writtenMentions.push(linkData)
+ if (currentMentions === null) {
+ currentMentions = []
+ }
+ currentMentions.push(linkData)
+ if (currentMentions.length > MENTIONS_LIMIT) {
+ invisibleMentions.push(linkData)
+ }
+ if (currentMentions.length === 1) {
+ return <MentionsLine mentions={ currentMentions } />
+ } else {
+ return ''
+ }
+ }
+
+ // Processor to use with html_tree_converter
+ const processItem = (item, index, array, what) => {
+ // Handle text nodes - just add emoji
+ if (typeof item === 'string') {
+ const emptyText = item.trim() === ''
+ if (item.includes('\n')) {
+ currentMentions = null
+ }
+ if (emptyText) {
+ // don't include spaces when processing mentions - we'll include them
+ // in MentionsLine
+ lastSpacing = item
+ // Don't remove last space in a container (fixes poast mentions)
+ return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
+ }
+
+ currentMentions = null
+ if (item.includes(':')) {
+ item = ['', processTextForEmoji(
+ item,
+ this.emoji,
+ ({ shortcode, url }) => {
+ return <StillImage
+ class="emoji img"
+ src={url}
+ title={`:${shortcode}:`}
+ alt={`:${shortcode}:`}
+ />
+ }
+ )]
+ }
+ return item
+ }
+
+ // Handle tag nodes
+ if (Array.isArray(item)) {
+ const [opener, children, closer] = item
+ const Tag = getTagName(opener)
+ const attrs = getAttrs(opener)
+ const previouslyMentions = currentMentions !== null
+ /* During grouping of mentions we trim all the empty text elements
+ * This padding is added to recover last space removed in case
+ * we have a tag right next to mentions
+ */
+ const mentionsLinePadding =
+ // Padding is only needed if we just finished parsing mentions
+ previouslyMentions &&
+ // Don't add padding if content is string and has padding already
+ !(children && typeof children[0] === 'string' && children[0].match(/^\s/))
+ ? lastSpacing
+ : ''
+ switch (Tag) {
+ case 'br':
+ currentMentions = null
+ break
+ case 'img': // replace images with StillImage
+ return ['', [mentionsLinePadding, renderImage(opener)], '']
+ case 'a': // replace mentions with MentionLink
+ if (!this.handleLinks) break
+ if (attrs['class'] && attrs['class'].includes('mention')) {
+ // Handling mentions here
+ return renderMention(attrs, children)
+ } else {
+ currentMentions = null
+ break
+ }
+ case 'span':
+ if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
+ return ['', children.map(processItem), '']
+ }
+ }
+
+ if (children !== undefined) {
+ return [
+ '',
+ [
+ mentionsLinePadding,
+ [opener, children.map(processItem), closer]
+ ],
+ ''
+ ]
+ } else {
+ return ['', [mentionsLinePadding, item], '']
+ }
+ }
+ }
+
+ // Processor for back direction (for finding "last" stuff, just easier this way)
+ let encounteredTextReverse = false
+ const processItemReverse = (item, index, array, what) => {
+ // Handle text nodes - just add emoji
+ if (typeof item === 'string') {
+ const emptyText = item.trim() === ''
+ if (emptyText) return item
+ if (!encounteredTextReverse) encounteredTextReverse = true
+ return unescape(item)
+ } else if (Array.isArray(item)) {
+ // Handle tag nodes
+ const [opener, children] = item
+ const Tag = opener === '' ? '' : getTagName(opener)
+ switch (Tag) {
+ case 'a': // replace mentions with MentionLink
+ if (!this.handleLinks) break
+ const attrs = getAttrs(opener)
+ // should only be this
+ if (
+ (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
+ (attrs['rel'] === 'tag') // Mastodon style
+ ) {
+ return renderHashtag(attrs, children, encounteredTextReverse)
+ } else {
+ attrs.target = '_blank'
+ const newChildren = [...children].reverse().map(processItemReverse).reverse()
+
+ return <a {...{ attrs }}>
+ { newChildren }
+ </a>
+ }
+ case '':
+ return [...children].reverse().map(processItemReverse).reverse()
+ }
+
+ // Render tag as is
+ if (children !== undefined) {
+ const newChildren = Array.isArray(children)
+ ? [...children].reverse().map(processItemReverse).reverse()
+ : children
+ return <Tag {...{ attrs: getAttrs(opener) }}>
+ { newChildren }
+ </Tag>
+ } else {
+ return <Tag/>
+ }
+ }
+ return item
+ }
+
+ const pass1 = convertHtmlToTree(html).map(processItem)
+ const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
+ // DO NOT USE SLOTS they cause a re-render feedback loop here.
+ // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
+ // at least until vue3?
+ const result = <span class="RichContent">
+ { pass2 }
+ </span>
+
+ const event = {
+ lastTags,
+ writtenMentions,
+ writtenTags,
+ invisibleMentions
+ }
+
+ // DO NOT MOVE TO UPDATE. BAD IDEA.
+ this.$emit('parseReady', event)
+
+ return result
+ }
+})
+
+const getLinkData = (attrs, children, index) => {
+ const stripTags = (item) => {
+ if (typeof item === 'string') {
+ return item
+ } else {
+ return item[1].map(stripTags).join('')
+ }
+ }
+ const textContent = children.map(stripTags).join('')
+ return {
+ index,
+ url: attrs.href,
+ tag: attrs['data-tag'],
+ content: flattenDeep(children).join(''),
+ textContent
+ }
+}
+
+/** Pre-processing HTML
+ *
+ * Currently this does one thing:
+ * - add green/cyantexting
+ *
+ * @param {String} html - raw HTML to process
+ * @param {Boolean} greentext - whether to enable greentexting or not
+ */
+export const preProcessPerLine = (html, greentext) => {
+ const greentextHandle = new Set(['p', 'div'])
+
+ const lines = convertHtmlToLines(html)
+ const newHtml = lines.reverse().map((item, index, array) => {
+ if (!item.text) return item
+ const string = item.text
+
+ // Greentext stuff
+ if (
+ // Only if greentext is engaged
+ greentext &&
+ // Only handle p's and divs. Don't want to affect blockquotes, code etc
+ item.level.every(l => greentextHandle.has(l)) &&
+ // Only if line begins with '>' or '<'
+ (string.includes('&gt;') || string.includes('&lt;'))
+ ) {
+ const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
+ .replace(/@\w+/gi, '') // remove mentions (even failed ones)
+ .trim()
+ if (cleanedString.startsWith('&gt;')) {
+ return `<span class='greentext'>${string}</span>`
+ } else if (cleanedString.startsWith('&lt;')) {
+ return `<span class='cyantext'>${string}</span>`
+ }
+ }
+
+ return string
+ }).reverse().join('')
+
+ return { newHtml }
+}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
new file mode 100644
index 00000000..db08ef1e
--- /dev/null
+++ b/src/components/rich_content/rich_content.scss
@@ -0,0 +1,64 @@
+.RichContent {
+ blockquote {
+ margin: 0.2em 0 0.2em 2em;
+ font-style: italic;
+ }
+
+ pre {
+ overflow: auto;
+ }
+
+ code,
+ samp,
+ kbd,
+ var,
+ pre {
+ font-family: var(--postCodeFont, monospace);
+ }
+
+ p {
+ margin: 0 0 1em 0;
+ }
+
+ p:last-child {
+ margin: 0 0 0 0;
+ }
+
+ h1 {
+ font-size: 1.1em;
+ line-height: 1.2em;
+ margin: 1.4em 0;
+ }
+
+ h2 {
+ font-size: 1.1em;
+ margin: 1em 0;
+ }
+
+ h3 {
+ font-size: 1em;
+ margin: 1.2em 0;
+ }
+
+ h4 {
+ margin: 1.1em 0;
+ }
+
+ .img {
+ display: inline-block;
+ }
+
+ .emoji {
+ display: inline-block;
+ width: var(--emoji-size, 32px);
+ height: var(--emoji-size, 32px);
+ }
+
+ .img,
+ video {
+ max-width: 100%;
+ max-height: 400px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+}
diff --git a/src/components/select/select.js b/src/components/select/select.js
new file mode 100644
index 00000000..49535d07
--- /dev/null
+++ b/src/components/select/select.js
@@ -0,0 +1,21 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faChevronDown
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faChevronDown
+)
+
+export default {
+ model: {
+ prop: 'value',
+ event: 'change'
+ },
+ props: [
+ 'value',
+ 'disabled',
+ 'unstyled',
+ 'kind'
+ ]
+}
diff --git a/src/components/select/select.vue b/src/components/select/select.vue
new file mode 100644
index 00000000..8d6528ff
--- /dev/null
+++ b/src/components/select/select.vue
@@ -0,0 +1,63 @@
+
+<template>
+ <label
+ class="Select input"
+ :class="{ disabled, unstyled }"
+ >
+ <select
+ :disabled="disabled"
+ :value="value"
+ @change="$emit('change', $event.target.value)"
+ >
+ <slot />
+ </select>
+ <FAIcon
+ class="select-down-icon"
+ icon="chevron-down"
+ />
+ </label>
+</template>
+
+<script src="./select.js"> </script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.Select {
+ padding: 0;
+
+ select {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: transparent;
+ border: none;
+ color: $fallback--text;
+ color: var(--inputText, --text, $fallback--text);
+ margin: 0;
+ padding: 0 2em 0 .2em;
+ font-family: sans-serif;
+ font-family: var(--inputFont, sans-serif);
+ font-size: 14px;
+ width: 100%;
+ z-index: 1;
+ height: 28px;
+ line-height: 16px;
+ }
+
+ .select-down-icon {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 5px;
+ height: 100%;
+ width: 0.875em;
+ color: $fallback--text;
+ color: var(--inputText, $fallback--text);
+ line-height: 28px;
+ z-index: 0;
+ pointer-events: none;
+ }
+
+}
+</style>
diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
new file mode 100644
index 00000000..353e551c
--- /dev/null
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -0,0 +1,47 @@
+import { get, set } from 'lodash'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
+export default {
+ components: {
+ Checkbox,
+ ModifiedIndicator,
+ ServerSideIndicator
+ },
+ props: [
+ 'path',
+ 'disabled',
+ 'expert'
+ ],
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isServerSide () {
+ return this.path.startsWith('serverSide_')
+ },
+ isChanged () {
+ return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, e)
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue
index 146ad6c1..e0d825f2 100644
--- a/src/components/settings_modal/helpers/boolean_setting.vue
+++ b/src/components/settings_modal/helpers/boolean_setting.vue
@@ -1,5 +1,6 @@
<template>
<label
+ v-if="matchesExpertLevel"
class="BooleanSetting"
>
<Checkbox
@@ -13,45 +14,8 @@
>
<slot />
</span>
- <ModifiedIndicator :changed="isChanged" />
- </Checkbox>
+ <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
</label>
</template>
-<script>
-import { get, set } from 'lodash'
-import Checkbox from 'src/components/checkbox/checkbox.vue'
-import ModifiedIndicator from './modified_indicator.vue'
-export default {
- components: {
- Checkbox,
- ModifiedIndicator
- },
- props: [
- 'path',
- 'disabled'
- ],
- computed: {
- pathDefault () {
- const [firstSegment, ...rest] = this.path.split('.')
- return [firstSegment + 'DefaultValue', ...rest].join('.')
- },
- state () {
- return get(this.$parent, this.path)
- },
- isChanged () {
- return get(this.$parent, this.path) !== get(this.$parent, this.pathDefault)
- }
- },
- methods: {
- update (e) {
- set(this.$parent, this.path, e)
- }
- }
-}
-</script>
-
-<style lang="scss">
-.BooleanSetting {
-}
-</style>
+<script src="./boolean_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js
new file mode 100644
index 00000000..4677d4c1
--- /dev/null
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -0,0 +1,48 @@
+import { get, set } from 'lodash'
+import Select from 'src/components/select/select.vue'
+import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
+export default {
+ components: {
+ Select,
+ ModifiedIndicator,
+ ServerSideIndicator
+ },
+ props: [
+ 'path',
+ 'disabled',
+ 'options',
+ 'expert'
+ ],
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isServerSide () {
+ return this.path.startsWith('serverSide_')
+ },
+ isChanged () {
+ return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, e)
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue
new file mode 100644
index 00000000..54f5d0a7
--- /dev/null
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -0,0 +1,31 @@
+<template>
+ <label
+ v-if="matchesExpertLevel"
+ class="ChoiceSetting"
+ >
+ <slot />
+ <Select
+ :value="state"
+ :disabled="disabled"
+ @change="update"
+ >
+ <option
+ v-for="option in options"
+ :key="option.key"
+ :value="option.value"
+ >
+ {{ option.label }}
+ {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
+ </option>
+ </Select>
+ <ModifiedIndicator :changed="isChanged" />
+ <ServerSideIndicator :server-side="isServerSide" />
+ </label>
+</template>
+
+<script src="./choice_setting.js"></script>
+
+<style lang="scss">
+.ChoiceSetting {
+}
+</style>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js
new file mode 100644
index 00000000..4a19bd7c
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.js
@@ -0,0 +1,41 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+export default {
+ components: {
+ ModifiedIndicator
+ },
+ props: {
+ path: String,
+ disabled: Boolean,
+ min: Number,
+ expert: Number
+ },
+ computed: {
+ pathDefault () {
+ const [firstSegment, ...rest] = this.path.split('.')
+ return [firstSegment + 'DefaultValue', ...rest].join('.')
+ },
+ state () {
+ const value = get(this.$parent, this.path)
+ if (value === undefined) {
+ return this.defaultState
+ } else {
+ return value
+ }
+ },
+ defaultState () {
+ return get(this.$parent, this.pathDefault)
+ },
+ isChanged () {
+ return this.state !== this.defaultState
+ },
+ matchesExpertLevel () {
+ return (this.expert || 0) <= this.$parent.expertLevel
+ }
+ },
+ methods: {
+ update (e) {
+ set(this.$parent, this.path, parseInt(e.target.value))
+ }
+ }
+}
diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue
new file mode 100644
index 00000000..408b0925
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.vue
@@ -0,0 +1,23 @@
+<template>
+ <span
+ v-if="matchesExpertLevel"
+ class="IntegerSetting"
+ >
+ <label :for="path">
+ <slot />
+ </label>
+ <input
+ :id="path"
+ class="number-input"
+ type="number"
+ step="1"
+ :disabled="disabled"
+ :min="min || 0"
+ :value="state"
+ @change="update"
+ >
+ <ModifiedIndicator :changed="isChanged" />
+ </span>
+</template>
+
+<script src="./integer_setting.js"></script>
diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/server_side_indicator.vue
new file mode 100644
index 00000000..143a86a1
--- /dev/null
+++ b/src/components/settings_modal/helpers/server_side_indicator.vue
@@ -0,0 +1,51 @@
+<template>
+ <span
+ v-if="serverSide"
+ class="ServerSideIndicator"
+ >
+ <Popover
+ trigger="hover"
+ >
+ <template v-slot:trigger>
+ &nbsp;
+ <FAIcon
+ icon="server"
+ :aria-label="$t('settings.setting_server_side')"
+ />
+ </template>
+ <template v-slot:content>
+ <div class="serverside-tooltip">
+ {{ $t('settings.setting_server_side') }}
+ </div>
+ </template>
+ </Popover>
+ </span>
+</template>
+
+<script>
+import Popover from 'src/components/popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faServer } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faServer
+)
+
+export default {
+ components: { Popover },
+ props: ['serverSide']
+}
+</script>
+
+<style lang="scss">
+.ServerSideIndicator {
+ display: inline-block;
+ position: relative;
+
+ .serverside-tooltip {
+ margin: 0.5em 1em;
+ min-width: 10em;
+ text-align: center;
+ }
+}
+</style>
diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js
index 2c833c0c..12431dca 100644
--- a/src/components/settings_modal/helpers/shared_computed_object.js
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -1,4 +1,5 @@
import { defaultState as configDefaultState } from 'src/modules/config.js'
+import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js'
const SharedComputedObject = () => ({
user () {
@@ -22,6 +23,14 @@ const SharedComputedObject = () => ({
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+ ...Object.keys(serverSideConfigDefaultState)
+ .map(key => ['serverSide_' + key, {
+ get () { return this.$store.state.serverSideConfig[key] },
+ set (value) {
+ this.$store.dispatch('setServerSideOption', { name: key, value })
+ }
+ }])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
index 7f97fa08..82ea410e 100644
--- a/src/components/settings_modal/settings_modal.js
+++ b/src/components/settings_modal/settings_modal.js
@@ -1,8 +1,9 @@
-import { defineAsyncComponent } from 'vue'
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
+import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import Popover from '../popover/popover.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { cloneDeep } from 'lodash'
import {
@@ -51,12 +52,15 @@ const SettingsModal = {
components: {
Modal,
Popover,
- SettingsModalContent: defineAsyncComponent({
- loader: () => import('./settings_modal_content.vue'),
- loadingComponent: PanelLoading,
- errorComponent: AsyncComponentError,
- delay: 0
- })
+ Checkbox,
+ SettingsModalContent: getResettableAsyncComponent(
+ () => import('./settings_modal_content.vue'),
+ {
+ loading: PanelLoading,
+ error: AsyncComponentError,
+ delay: 0
+ }
+ )
},
methods: {
closeModal () {
@@ -157,6 +161,15 @@ const SettingsModal = {
},
modalPeeked () {
return this.$store.state.interface.settingsModalState === 'minimized'
+ },
+ expertLevel: {
+ get () {
+ return this.$store.state.config.expertLevel > 0
+ },
+ set (value) {
+ console.log(value)
+ this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 })
+ }
}
}
}
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 90446b36..fb466f2f 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -48,4 +48,11 @@
}
}
}
+
+ .settings-footer {
+ display: flex;
+ >* {
+ margin-right: 0.5em;
+ }
+ }
}
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
index 0c261474..1805c77f 100644
--- a/src/components/settings_modal/settings_modal.vue
+++ b/src/components/settings_modal/settings_modal.vue
@@ -11,24 +11,22 @@
{{ $t('settings.settings') }}
</span>
<transition name="fade">
- <template>
- <template v-if="currentSaveStateNotice">
- <div
- v-if="currentSaveStateNotice.error"
- class="alert error"
- @click.prevent
- >
- {{ $t('settings.saving_err') }}
- </div>
+ <template v-if="currentSaveStateNotice">
+ <div
+ v-if="currentSaveStateNotice.error"
+ class="alert error"
+ @click.prevent
+ >
+ {{ $t('settings.saving_err') }}
+ </div>
- <div
- v-if="!currentSaveStateNotice.error"
- class="alert transparent"
- @click.prevent
- >
- {{ $t('settings.saving_ok') }}
- </div>
- </template>
+ <div
+ v-if="!currentSaveStateNotice.error"
+ class="alert transparent"
+ @click.prevent
+ >
+ {{ $t('settings.saving_ok') }}
+ </div>
</template>
</transition>
<button
@@ -55,7 +53,7 @@
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
- <div class="panel-footer">
+ <div class="panel-footer settings-footer">
<Popover
class="export"
trigger="click"
@@ -75,7 +73,7 @@
/>
</button>
</template>
- <template v-slot:content ="{close}">
+ <template v-slot:content="{close}">
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@@ -110,6 +108,10 @@
</div>
</template>
</Popover>
+
+ <Checkbox v-model="expertLevel">
+ {{ $t("settings.expert_mode") }}
+ </Checkbox>
</div>
</div>
</Modal>
diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss
index f066234c..81ab434b 100644
--- a/src/components/settings_modal/settings_modal_content.scss
+++ b/src/components/settings_modal/settings_modal_content.scss
@@ -7,13 +7,24 @@
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
- > div {
+ > div,
+ > label {
+ display: block;
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
+ .select-multiple {
+ display: flex;
+
+ .option-list {
+ margin: 0;
+ padding-left: .5em;
+ }
+ }
+
&:last-child {
border-bottom: none;
padding-bottom: 0;
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 6e95f7af..73413b48 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,24 +1,25 @@
import { filter, trim } from 'lodash'
import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_setting.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faChevronDown
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faChevronDown
-)
const FilteringTab = {
data () {
return {
- muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
+ muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n'),
+ replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.reply_visibility_${mode}`)
+ }))
}
},
components: {
- BooleanSetting
+ BooleanSetting,
+ ChoiceSetting,
+ IntegerSetting
},
computed: {
...SharedComputedObject(),
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index 402c2a4a..dc48902f 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -1,89 +1,122 @@
<template>
<div :label="$t('settings.filtering')">
<div class="setting-item">
- <div class="select-multiple">
- <span class="label">{{ $t('settings.notification_visibility') }}</span>
- <ul class="option-list">
- <li>
- <BooleanSetting path="notificationVisibility.likes">
- {{ $t('settings.notification_visibility_likes') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.repeats">
- {{ $t('settings.notification_visibility_repeats') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.follows">
- {{ $t('settings.notification_visibility_follows') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.mentions">
- {{ $t('settings.notification_visibility_mentions') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.moves">
- {{ $t('settings.notification_visibility_moves') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="notificationVisibility.emojiReactions">
- {{ $t('settings.notification_visibility_emoji_reactions') }}
- </BooleanSetting>
- </li>
- </ul>
- </div>
- <div>
- {{ $t('settings.replies_in_timeline') }}
- <label
- for="replyVisibility"
- class="select"
- >
- <select
- id="replyVisibility"
- v-model="replyVisibility"
+ <h2>{{ $t('settings.posts') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="hideFilteredStatuses">
+ {{ $t('settings.hide_filtered_statuses') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !streaming}]"
>
- <option
- value="all"
- selected
- >{{ $t('settings.reply_visibility_all') }}</option>
- <option value="following">{{ $t('settings.reply_visibility_following') }}</option>
- <option value="self">{{ $t('settings.reply_visibility_self') }}</option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
+ <li>
+ <BooleanSetting
+ :disabled="hideFilteredStatuses"
+ path="hideWordFilteredPosts"
+ >
+ {{ $t('settings.hide_wordfiltered_statuses') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ v-if="user"
+ :disabled="hideFilteredStatuses"
+ path="hideMutedThreads"
+ >
+ {{ $t('settings.hide_muted_threads') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ v-if="user"
+ :disabled="hideFilteredStatuses"
+ path="hideMutedPosts"
+ >
+ {{ $t('settings.hide_muted_posts') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting path="muteBotStatuses">
+ {{ $t('settings.mute_bot_posts') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hidePostStats">
+ {{ $t('settings.hide_post_stats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideBotIndication">
+ {{ $t('settings.hide_bot_indication') }}
+ </BooleanSetting>
+ </li>
+ <ChoiceSetting
+ v-if="user"
+ id="replyVisibility"
+ path="replyVisibility"
+ :options="replyVisibilityOptions"
+ >
+ {{ $t('settings.replies_in_timeline') }}
+ </ChoiceSetting>
+ <li>
+ <h3>{{ $t('settings.wordfilter') }}</h3>
+ <textarea
+ id="muteWords"
+ v-model="muteWordsString"
+ class="resize-height"
/>
- </label>
- </div>
- <div>
- <BooleanSetting path="hidePostStats">
- {{ $t('settings.hide_post_stats') }}
- </BooleanSetting>
- </div>
- <div>
- <BooleanSetting path="hideUserStats">
- {{ $t('settings.hide_user_stats') }}
- </BooleanSetting>
- </div>
+ <div>{{ $t('settings.filtering_explanation') }}</div>
+ </li>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <li v-if="expertLevel > 0">
+ <label for="maxThumbnails">
+ {{ $t('settings.max_thumbnails') }}
+ </label>
+ <input
+ id="maxThumbnails"
+ path.number="maxThumbnails"
+ class="number-input"
+ type="number"
+ min="0"
+ step="1"
+ >
+ </li>
+ <li>
+ <IntegerSetting
+ path="maxThumbnails"
+ :min="0"
+ >
+ {{ $t('settings.max_thumbnails') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachments">
+ {{ $t('settings.hide_attachments_in_tl') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="hideAttachmentsInConv">
+ {{ $t('settings.hide_attachments_in_convo') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
- <div class="setting-item">
- <div>
- <p>{{ $t('settings.filtering_explanation') }}</p>
- <textarea
- id="muteWords"
- v-model="muteWordsString"
- class="resize-height"
- />
- </div>
- <div>
- <BooleanSetting path="hideFilteredStatuses">
- {{ $t('settings.hide_filtered_statuses') }}
- </BooleanSetting>
- </div>
+ <div
+ v-if="expertLevel > 0"
+ class="setting-item"
+ >
+ <h2>{{ $t('settings.user_profiles') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="hideUserStats">
+ {{ $t('settings.hide_user_stats') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index 2db523be..62d86176 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,21 +1,43 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_setting.vue'
+import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
+import ServerSideIndicator from '../helpers/server_side_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
- faChevronDown,
faGlobe
} from '@fortawesome/free-solid-svg-icons'
library.add(
- faChevronDown,
faGlobe
)
const GeneralTab = {
data () {
return {
+ subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
+ })),
+ conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_display_${mode}`)
+ })),
+ conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.conversation_other_replies_button_${mode}`)
+ })),
+ mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
+ key: mode,
+ value: mode,
+ label: this.$t(`settings.mention_link_display_${mode}`)
+ })),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@@ -27,18 +49,35 @@ const GeneralTab = {
},
components: {
BooleanSetting,
- InterfaceLanguageSwitcher
+ ChoiceSetting,
+ IntegerSetting,
+ InterfaceLanguageSwitcher,
+ ScopeSelector,
+ ServerSideIndicator
},
computed: {
postFormats () {
return this.$store.state.instance.postFormats || []
},
+ postContentOptions () {
+ return this.postFormats.map(format => ({
+ key: format,
+ value: format,
+ label: this.$t(`post_status.content_type["${format}"]`)
+ }))
+ },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
+ instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
...SharedComputedObject()
+ },
+ methods: {
+ changeDefaultScope (value) {
+ this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value })
+ }
}
}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 9228c78e..a2c6bffa 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -11,24 +11,19 @@
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
+ <li>
+ <BooleanSetting path="sidebarRight">
+ {{ $t('settings.right_sidebar') }}
+ </BooleanSetting>
+ </li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('nav.timeline') }}</h2>
- <ul class="setting-list">
<li>
- <BooleanSetting path="hideMutedPosts">
- {{ $t('settings.hide_muted_posts') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="collapseMessageWithSubject">
- {{ $t('settings.collapse_subject') }}
+ <BooleanSetting path="stopGifs">
+ {{ $t('settings.stop_gifs') }}
</BooleanSetting>
</li>
<li>
@@ -50,146 +45,126 @@
</ul>
</li>
<li>
- <BooleanSetting path="useStreamingApi">
+ <BooleanSetting
+ path="useStreamingApi"
+ expert="1"
+ >
{{ $t('settings.useStreamingApi') }}
- <br>
- <small>
- {{ $t('settings.useStreamingApiWarning') }}
- </small>
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="emojiReactionsOnTimeline">
- {{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="virtualScrolling">
+ <BooleanSetting
+ path="virtualScrolling"
+ expert="1"
+ >
{{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.composing') }}</h2>
- <ul class="setting-list">
- <li>
- <BooleanSetting path="scopeCopy">
- {{ $t('settings.scope_copy') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="alwaysShowSubjectInput">
- {{ $t('settings.subject_input_always_show') }}
- </BooleanSetting>
- </li>
- <li>
- <div>
- {{ $t('settings.subject_line_behavior') }}
- <label
- for="subjectLineBehavior"
- class="select"
- >
- <select
- id="subjectLineBehavior"
- v-model="subjectLineBehavior"
- >
- <option value="email">
- {{ $t('settings.subject_line_email') }}
- {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
- </option>
- <option value="masto">
- {{ $t('settings.subject_line_mastodon') }}
- {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
- </option>
- <option value="noop">
- {{ $t('settings.subject_line_noop') }}
- {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
- </div>
- </li>
- <li v-if="postFormats.length > 0">
- <div>
- {{ $t('settings.post_status_content_type') }}
- <label
- for="postContentType"
- class="select"
- >
- <select
- id="postContentType"
- v-model="postContentType"
- >
- <option
- v-for="postFormat in postFormats"
- :key="postFormat"
- :value="postFormat"
- >
- {{ $t(`post_status.content_type["${postFormat}"]`) }}
- {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
- </div>
- </li>
- <li>
- <BooleanSetting path="minimalScopesMode">
- {{ $t('settings.minimal_scopes_mode') }}
- </BooleanSetting>
- </li>
<li>
- <BooleanSetting path="sensitiveByDefault">
- {{ $t('settings.sensitive_by_default') }}
+ <BooleanSetting
+ path="alwaysShowNewPostButton"
+ expert="1"
+ >
+ {{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="autohideFloatingPostButton">
+ <BooleanSetting
+ path="autohideFloatingPostButton"
+ expert="1"
+ >
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
- <li>
- <BooleanSetting path="padEmoji">
- {{ $t('settings.pad_emoji') }}
+ <li v-if="instanceShoutboxPresent">
+ <BooleanSetting
+ path="hideShoutbox"
+ expert="1"
+ >
+ {{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
</ul>
</div>
-
<div class="setting-item">
- <h2>{{ $t('settings.attachments') }}</h2>
+ <h2>{{ $t('settings.post_look_feel') }}</h2>
<ul class="setting-list">
<li>
- <BooleanSetting path="hideAttachments">
- {{ $t('settings.hide_attachments_in_tl') }}
+ <ChoiceSetting
+ id="conversationDisplay"
+ path="conversationDisplay"
+ :options="conversationDisplayOptions"
+ >
+ {{ $t('settings.conversation_display') }}
+ </ChoiceSetting>
+ </li>
+ <ul
+ v-if="conversationDisplay !== 'linear'"
+ class="setting-list suboptions"
+ >
+ <li>
+ <BooleanSetting path="conversationTreeAdvanced">
+ {{ $t('settings.tree_advanced') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="conversationTreeFadeAncestors"
+ :expert="1"
+ >
+ {{ $t('settings.tree_fade_ancestors') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <IntegerSetting
+ path="maxDepthInThread"
+ :min="3"
+ :expert="1"
+ >
+ {{ $t('settings.max_depth_in_thread') }}
+ </IntegerSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="conversationOtherRepliesButton"
+ path="conversationOtherRepliesButton"
+ :options="conversationOtherRepliesButtonOptions"
+ :expert="1"
+ >
+ {{ $t('settings.conversation_other_replies_button') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
+ <li>
+ <BooleanSetting path="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }}
</BooleanSetting>
</li>
<li>
- <BooleanSetting path="hideAttachmentsInConv">
- {{ $t('settings.hide_attachments_in_convo') }}
+ <BooleanSetting
+ path="emojiReactionsOnTimeline"
+ expert="1"
+ >
+ {{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
<li>
- <label for="maxThumbnails">
- {{ $t('settings.max_thumbnails') }}
- </label>
- <input
- id="maxThumbnails"
- path.number="maxThumbnails"
- class="number-input"
- type="number"
- min="0"
- step="1"
+ <BooleanSetting
+ v-if="user"
+ path="serverSide_stripRichContent"
+ expert="1"
>
+ {{ $t('settings.no_rich_text_description') }}
+ </BooleanSetting>
+ </li>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <li>
+ <BooleanSetting
+ path="useContainFit"
+ expert="1"
+ >
+ {{ $t('settings.use_contain_fit') }}
+ </BooleanSetting>
</li>
<li>
<BooleanSetting path="hideNsfw">
@@ -200,6 +175,7 @@
<li>
<BooleanSetting
path="preloadImage"
+ expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
@@ -208,6 +184,7 @@
<li>
<BooleanSetting
path="useOneClickNsfw"
+ expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
@@ -215,12 +192,10 @@
</li>
</ul>
<li>
- <BooleanSetting path="stopGifs">
- {{ $t('settings.stop_gifs') }}
- </BooleanSetting>
- </li>
- <li>
- <BooleanSetting path="loopVideo">
+ <BooleanSetting
+ path="loopVideo"
+ expert="1"
+ >
{{ $t('settings.loop_video') }}
</BooleanSetting>
<ul
@@ -230,6 +205,7 @@
<li>
<BooleanSetting
path="loopVideoSilentOnly"
+ expert="1"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
@@ -244,35 +220,175 @@
</ul>
</li>
<li>
- <BooleanSetting path="playVideosInModal">
+ <BooleanSetting
+ path="playVideosInModal"
+ expert="1"
+ >
{{ $t('settings.play_videos_in_modal') }}
</BooleanSetting>
</li>
+ <h3>{{ $t('settings.mention_links') }}</h3>
<li>
- <BooleanSetting path="useContainFit">
- {{ $t('settings.use_contain_fit') }}
+ <ChoiceSetting
+ id="mentionLinkDisplay"
+ path="mentionLinkDisplay"
+ :options="mentionLinkDisplayOptions"
+ >
+ {{ $t('settings.mention_link_display') }}
+ </ChoiceSetting>
+ </li>
+ <ul
+ class="setting-list suboptions"
+ >
+ <li v-if="mentionLinkDisplay === 'short'">
+ <BooleanSetting
+ path="mentionLinkShowTooltip"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_show_tooltip') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ <li>
+ <BooleanSetting
+ path="useAtIcon"
+ expert="1"
+ >
+ {{ $t('settings.use_at_icon') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="mentionLinkShowAvatar">
+ {{ $t('settings.mention_link_show_avatar') }}
</BooleanSetting>
</li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.notifications') }}</h2>
- <ul class="setting-list">
<li>
- <BooleanSetting path="webPushNotifications">
- {{ $t('settings.enable_web_push_notifications') }}
+ <BooleanSetting
+ path="mentionLinkFadeDomain"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_fade_domain') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="user">
+ <BooleanSetting
+ path="mentionLinkBoldenYou"
+ expert="1"
+ >
+ {{ $t('settings.mention_link_bolden_you') }}
+ </BooleanSetting>
+ </li>
+ <h3 v-if="expertLevel > 0">
+ {{ $t('settings.fun') }}
+ </h3>
+ <li>
+ <BooleanSetting
+ path="greentext"
+ expert="1"
+ >
+ {{ $t('settings.greentext') }}
+ </BooleanSetting>
+ </li>
+ <li v-if="user">
+ <BooleanSetting
+ path="mentionLinkShowYous"
+ expert="1"
+ >
+ {{ $t('settings.show_yous') }}
</BooleanSetting>
</li>
</ul>
</div>
- <div class="setting-item">
- <h2>{{ $t('settings.fun') }}</h2>
+ <div
+ v-if="user"
+ class="setting-item"
+ >
+ <h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
- <BooleanSetting path="greentext">
- {{ $t('settings.greentext') }}
+ <label for="default-vis">
+ {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" />
+ <ScopeSelector
+ class="scope-selector"
+ :show-all="true"
+ :user-default="serverSide_defaultScope"
+ :initial-scope="serverSide_defaultScope"
+ :on-scope-change="changeDefaultScope"
+ />
+ </label>
+ </li>
+ <li>
+ <!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
+ <BooleanSetting path="sensitiveByDefault">
+ {{ $t('settings.sensitive_by_default') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="scopeCopy"
+ expert="1"
+ >
+ {{ $t('settings.scope_copy') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="alwaysShowSubjectInput"
+ expert="1"
+ >
+ {{ $t('settings.subject_input_always_show') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="subjectLineBehavior"
+ path="subjectLineBehavior"
+ :options="subjectLineOptions"
+ expert="1"
+ >
+ {{ $t('settings.subject_line_behavior') }}
+ </ChoiceSetting>
+ </li>
+ <li v-if="postFormats.length > 0">
+ <ChoiceSetting
+ id="postContentType"
+ path="postContentType"
+ :options="postContentOptions"
+ >
+ {{ $t('settings.post_status_content_type') }}
+ </ChoiceSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="minimalScopesMode"
+ expert="1"
+ >
+ {{ $t('settings.minimal_scopes_mode') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="alwaysShowNewPostButton"
+ expert="1"
+ >
+ {{ $t('settings.always_show_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="autohideFloatingPostButton"
+ expert="1"
+ >
+ {{ $t('settings.autohide_floating_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="padEmoji"
+ expert="1"
+ >
+ {{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
</ul>
diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js
index 3e44c95d..3c6ab87f 100644
--- a/src/components/settings_modal/tabs/notifications_tab.js
+++ b/src/components/settings_modal/tabs/notifications_tab.js
@@ -1,4 +1,5 @@
-import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
const NotificationsTab = {
data () {
@@ -9,12 +10,13 @@ const NotificationsTab = {
}
},
components: {
- Checkbox
+ BooleanSetting
},
computed: {
user () {
return this.$store.state.users.currentUser
- }
+ },
+ ...SharedComputedObject()
},
methods: {
updateNotificationSettings () {
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index 7e0568ea..86be6095 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -2,30 +2,77 @@
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
- <p>
- <Checkbox v-model="notificationSettings.block_from_strangers">
- {{ $t('settings.notification_setting_block_from_strangers') }}
- </Checkbox>
- </p>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="serverSide_blockNotificationsFromStrangers">
+ {{ $t('settings.notification_setting_block_from_strangers') }}
+ </BooleanSetting>
+ </li>
+ <li class="select-multiple">
+ <span class="label">{{ $t('settings.notification_visibility') }}</span>
+ <ul class="option-list">
+ <li>
+ <BooleanSetting path="notificationVisibility.likes">
+ {{ $t('settings.notification_visibility_likes') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.repeats">
+ {{ $t('settings.notification_visibility_repeats') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.follows">
+ {{ $t('settings.notification_visibility_follows') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.mentions">
+ {{ $t('settings.notification_visibility_mentions') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.moves">
+ {{ $t('settings.notification_visibility_moves') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="notificationVisibility.emojiReactions">
+ {{ $t('settings.notification_visibility_emoji_reactions') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
</div>
- <div class="setting-item">
+ <div
+ v-if="expertLevel > 0"
+ class="setting-item"
+ >
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
- <p>
- <Checkbox v-model="notificationSettings.hide_notification_contents">
- {{ $t('settings.notification_setting_hide_notification_contents') }}
- </Checkbox>
- </p>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting
+ path="webPushNotifications"
+ expert="1"
+ >
+ {{ $t('settings.enable_web_push_notifications') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ path="serverSide_webPushHideContents"
+ expert="1"
+ >
+ {{ $t('settings.notification_setting_hide_notification_contents') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
- <button
- class="btn button-default"
- @click="updateNotificationSettings"
- >
- {{ $t('settings.save') }}
- </button>
</div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 9709424c..bee8a7bb 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -8,6 +8,9 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@@ -24,21 +27,13 @@ library.add(
const ProfileTab = {
data () {
return {
- newName: this.$store.state.users.currentUser.name,
+ newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
- newNoRichText: this.$store.state.users.currentUser.no_rich_text,
- newDefaultScope: this.$store.state.users.currentUser.default_scope,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
- hideFollows: this.$store.state.users.currentUser.hide_follows,
- hideFollowers: this.$store.state.users.currentUser.hide_followers,
- hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
- hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
- discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
- allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
@@ -54,12 +49,14 @@ const ProfileTab = {
EmojiInput,
Autosuggest,
ProgressButton,
- Checkbox
+ Checkbox,
+ BooleanSetting
},
computed: {
user () {
return this.$store.state.users.currentUser
},
+ ...SharedComputedObject(),
emojiUserSuggestor () {
return suggestor({
emoji: [
@@ -123,15 +120,7 @@ const ProfileTab = {
/* eslint-disable camelcase */
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
- default_scope: this.newDefaultScope,
- no_rich_text: this.newNoRichText,
- hide_follows: this.hideFollows,
- hide_followers: this.hideFollowers,
- discoverable: this.discoverable,
bot: this.bot,
- allow_following_move: this.allowFollowingMove,
- hide_follows_count: this.hideFollowsCount,
- hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index bb3c301d..e00f6e5b 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -25,61 +25,6 @@
class="bio resize-height"
/>
</EmojiInput>
- <p>
- <Checkbox v-model="newLocked">
- {{ $t('settings.lock_account_description') }}
- </Checkbox>
- </p>
- <div>
- <label for="default-vis">{{ $t('settings.default_vis') }}</label>
- <div
- id="default-vis"
- class="visibility-tray"
- >
- <scope-selector
- :show-all="true"
- :user-default="newDefaultScope"
- :initial-scope="newDefaultScope"
- :on-scope-change="changeVis"
- />
- </div>
- </div>
- <p>
- <Checkbox v-model="newNoRichText">
- {{ $t('settings.no_rich_text_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollows">
- {{ $t('settings.hide_follows_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowsCount"
- :disabled="!hideFollows"
- >
- {{ $t('settings.hide_follows_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="hideFollowers">
- {{ $t('settings.hide_followers_description') }}
- </Checkbox>
- </p>
- <p class="setting-subitem">
- <Checkbox
- v-model="hideFollowersCount"
- :disabled="!hideFollowers"
- >
- {{ $t('settings.hide_followers_count_description') }}
- </Checkbox>
- </p>
- <p>
- <Checkbox v-model="allowFollowingMove">
- {{ $t('settings.allow_following_move') }}
- </Checkbox>
- </p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
@@ -90,11 +35,6 @@
</template>
</Checkbox>
</p>
- <p>
- <Checkbox v-model="discoverable">
- {{ $t('settings.discoverable') }}
- </Checkbox>
- </p>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
@@ -269,6 +209,67 @@
{{ $t('settings.save') }}
</button>
</div>
+ <div class="setting-item">
+ <h2>{{ $t('settings.account_privacy') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="serverSide_locked">
+ {{ $t('settings.lock_account_description') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_discoverable">
+ {{ $t('settings.discoverable') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_allowFollowingMove">
+ {{ $t('settings.allow_following_move') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFavorites">
+ {{ $t('settings.hide_favorites_description') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFollowers">
+ {{ $t('settings.hide_followers_description') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !serverSide_hideFollowers}]"
+ >
+ <li>
+ <BooleanSetting
+ path="serverSide_hideFollowersCount"
+ :disabled="!serverSide_hideFollowers"
+ >
+ {{ $t('settings.hide_followers_count_description') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting path="serverSide_hideFollows">
+ {{ $t('settings.hide_follows_description') }}
+ </BooleanSetting>
+ <ul
+ class="setting-list suboptions"
+ :class="[{disabled: !serverSide_hideFollows}]"
+ >
+ <li>
+ <BooleanSetting
+ path="serverSide_hideFollowsCount"
+ :disabled="!serverSide_hideFollows"
+ >
+ {{ $t('settings.hide_follows_count_description') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
</div>
</template>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 9f4dd1a9..251f63fd 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -35,16 +35,9 @@ import FontControl from 'src/components/font_control/font_control.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Checkbox from 'src/components/checkbox/checkbox.vue'
+import Select from 'src/components/select/select.vue'
import Preview from './preview.vue'
-import { library } from '@fortawesome/fontawesome-svg-core'
-import {
- faChevronDown
-} from '@fortawesome/free-solid-svg-icons'
-
-library.add(
- faChevronDown
-)
// List of color values used in v1
const v1OnlyNames = [
@@ -79,7 +72,8 @@ export default {
getExportedObject: () => this.exportedTheme
}),
availableStyles: [],
- selected: this.$store.getters.mergedConfig.theme,
+ selected: '',
+ selectedTheme: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
@@ -213,7 +207,7 @@ export default {
}
},
selectedVersion () {
- return Array.isArray(this.selected) ? 1 : 2
+ return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors () {
return Object.keys(SLOT_INHERITANCE)
@@ -394,7 +388,8 @@ export default {
FontControl,
TabSwitcher,
Preview,
- Checkbox
+ Checkbox,
+ Select
},
methods: {
loadTheme (
@@ -479,7 +474,7 @@ export default {
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
- console.err('Forcing snapshout from file is not supported yet')
+ console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
@@ -750,6 +745,16 @@ export default {
}
},
selected () {
+ this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => {
+ if (Array.isArray(s)) {
+ console.log(s[0] === this.selected, this.selected)
+ return s[0] === this.selected
+ } else {
+ return s.name === this.selected
+ }
+ })[1]
+ },
+ selectedTheme () {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
@@ -767,17 +772,17 @@ export default {
if (!this.keepColor) {
this.clearV1()
- this.bgColorLocal = this.selected[1]
- this.fgColorLocal = this.selected[2]
- this.textColorLocal = this.selected[3]
- this.linkColorLocal = this.selected[4]
- this.cRedColorLocal = this.selected[5]
- this.cGreenColorLocal = this.selected[6]
- this.cBlueColorLocal = this.selected[7]
- this.cOrangeColorLocal = this.selected[8]
+ this.bgColorLocal = this.selectedTheme[1]
+ this.fgColorLocal = this.selectedTheme[2]
+ this.textColorLocal = this.selectedTheme[3]
+ this.linkColorLocal = this.selectedTheme[4]
+ this.cRedColorLocal = this.selectedTheme[5]
+ this.cGreenColorLocal = this.selectedTheme[6]
+ this.cBlueColorLocal = this.selectedTheme[7]
+ this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
- this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
+ this.normalizeLocalState(this.selectedTheme.theme, 2, this.selectedTheme.source)
}
}
}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
index 1b7d9f31..0db21537 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -270,6 +270,9 @@
.apply-container {
justify-content: center;
+ position: absolute;
+ bottom: 8px;
+ right: 5px;
}
.radius-item,
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 62378867..c02986ed 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -55,7 +55,7 @@
for="preset-switcher"
class="select"
>
- <select
+ <Select
id="preset-switcher"
v-model="selected"
class="preset-switcher"
@@ -63,7 +63,7 @@
<option
v-for="style in availableStyles"
:key="style.name"
- :value="style"
+ :value="style.name || style[0]"
:style="{
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text
@@ -71,11 +71,7 @@
>
{{ style[0] || style.name }}
</option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
+ </Select>
</label>
</div>
<div class="export-import">
@@ -907,28 +903,19 @@
<div class="tab-header shadow-selector">
<div class="select-container">
{{ $t('settings.style.shadows.component') }}
- <label
- for="shadow-switcher"
- class="select"
+ <Select
+ id="shadow-switcher"
+ v-model="shadowSelected"
+ class="shadow-switcher"
>
- <select
- id="shadow-switcher"
- v-model="shadowSelected"
- class="shadow-switcher"
+ <option
+ v-for="shadow in shadowsAvailable"
+ :key="shadow"
+ :value="shadow"
>
- <option
- v-for="shadow in shadowsAvailable"
- :key="shadow"
- :value="shadow"
- >
- {{ $t('settings.style.shadows.components.' + shadow) }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ {{ $t('settings.style.shadows.components.' + shadow) }}
+ </option>
+ </Select>
</div>
<div class="override">
<label
diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js
index 800c39d5..2d5d6eb1 100644
--- a/src/components/shadow_control/shadow_control.js
+++ b/src/components/shadow_control/shadow_control.js
@@ -1,5 +1,6 @@
import ColorInput from '../color_input/color_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue'
+import Select from '../select/select.vue'
import { getCssShadow } from '../../services/style_setter/style_setter.js'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -45,7 +46,8 @@ export default {
},
components: {
ColorInput,
- OpacityInput
+ OpacityInput,
+ Select
},
methods: {
add () {
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index 37d491f0..511e07f3 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -59,30 +59,20 @@
:disabled="usingFallback"
class="id-control style-control"
>
- <label
- for="shadow-switcher"
- class="select"
+ <Select
+ id="shadow-switcher"
+ v-model="selectedId"
+ class="shadow-switcher"
:disabled="!ready || usingFallback"
>
- <select
- id="shadow-switcher"
- v-model="selectedId"
- class="shadow-switcher"
- :disabled="!ready || usingFallback"
+ <option
+ v-for="(shadow, index) in cValue"
+ :key="index"
+ :value="index"
>
- <option
- v-for="(shadow, index) in cValue"
- :key="index"
- :value="index"
- >
- {{ $t('settings.style.shadows.shadow_id', { value: index }) }}
- </option>
- </select>
- <FAIcon
- icon="chevron-down"
- class="select-down-icon"
- />
- </label>
+ {{ $t('settings.style.shadows.shadow_id', { value: index }) }}
+ </option>
+ </Select>
<button
class="btn button-default"
:disabled="!ready || !present"
@@ -316,20 +306,20 @@
.id-control {
align-items: stretch;
- .select, .btn {
+
+ .shadow-switcher {
+ flex: 1;
+ }
+
+ .shadow-switcher, .btn {
min-width: 1px;
margin-right: 5px;
}
+
.btn {
padding: 0 .4em;
margin: 0 .1em;
}
- .select {
- flex: 1;
- select {
- align-self: initial;
- }
- }
}
}
}
diff --git a/src/components/chat_panel/chat_panel.js b/src/components/shout_panel/shout_panel.js
index 556694ae..a6168971 100644
--- a/src/components/chat_panel/chat_panel.js
+++ b/src/components/shout_panel/shout_panel.js
@@ -10,7 +10,7 @@ library.add(
faTimes
)
-const chatPanel = {
+const shoutPanel = {
props: [ 'floating' ],
data () {
return {
@@ -21,12 +21,12 @@ const chatPanel = {
},
computed: {
messages () {
- return this.$store.state.chat.messages
+ return this.$store.state.shout.messages
}
},
methods: {
submit (message) {
- this.$store.state.chat.channel.push('new_msg', { text: message }, 10000)
+ this.$store.state.shout.channel.push('new_msg', { text: message }, 10000)
this.currentMessage = ''
},
togglePanel () {
@@ -50,4 +50,4 @@ const chatPanel = {
}
}
-export default chatPanel
+export default shoutPanel
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/shout_panel/shout_panel.vue
index 8a829115..c88797d1 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -1,12 +1,12 @@
<template>
<div
v-if="!collapsed || !floating"
- class="chat-panel"
+ class="shout-panel"
>
<div class="panel panel-default">
<div
class="panel-heading timeline-heading"
- :class="{ 'chat-heading': floating }"
+ :class="{ 'shout-heading': floating }"
@click.stop.prevent="togglePanel"
>
<div class="title">
@@ -18,33 +18,33 @@
/>
</div>
</div>
- <div class="chat-window">
+ <div class="shout-window">
<div
v-for="message in messages"
:key="message.id"
- class="chat-message"
+ class="shout-message"
>
- <span class="chat-avatar">
+ <span class="shout-avatar">
<img :src="message.author.avatar">
</span>
- <div class="chat-content">
+ <div class="shout-content">
<router-link
- class="chat-name"
+ class="shout-name"
:to="userProfileLink(message.author)"
>
{{ message.author.username }}
</router-link>
<br>
- <span class="chat-text">
+ <span class="shout-text">
{{ message.text }}
</span>
</div>
</div>
</div>
- <div class="chat-input">
+ <div class="shout-input">
<textarea
v-model="currentMessage"
- class="chat-input-textarea"
+ class="shout-input-textarea"
rows="1"
@keyup.enter="submit(currentMessage)"
/>
@@ -53,11 +53,11 @@
</div>
<div
v-else
- class="chat-panel"
+ class="shout-panel"
>
<div class="panel panel-default">
<div
- class="panel-heading stub timeline-heading chat-heading"
+ class="panel-heading stub timeline-heading shout-heading"
@click.stop.prevent="togglePanel"
>
<div class="title">
@@ -72,21 +72,28 @@
</div>
</template>
-<script src="./chat_panel.js"></script>
+<script src="./shout_panel.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.floating-chat {
+.floating-shout {
position: fixed;
- right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
-.chat-panel {
- .chat-heading {
+.floating-shout.left {
+ left: 0px;
+}
+
+.floating-shout:not(.left) {
+ right: 0px;
+}
+
+.shout-panel {
+ .shout-heading {
cursor: pointer;
.icon {
@@ -102,22 +109,22 @@
}
}
- .chat-window {
+ .shout-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
- .chat-window-container {
+ .shout-window-container {
height: 100%;
}
- .chat-message {
+ .shout-message {
display: flex;
padding: 0.2em 0.5em
}
- .chat-avatar {
+ .shout-avatar {
img {
height: 24px;
width: 24px;
@@ -128,7 +135,7 @@
}
}
- .chat-input {
+ .shout-input {
display: flex;
textarea {
flex: 1;
@@ -138,7 +145,7 @@
}
}
- .chat-panel {
+ .shout-panel {
.title {
display: flex;
justify-content: space-between;
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index fe736168..89719df3 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -49,7 +49,7 @@ const SideDrawer = {
currentUser () {
return this.$store.state.users.currentUser
},
- chat () { return this.$store.state.chat.channel.state === 'joined' },
+ shout () { return this.$store.state.shout.channel.state === 'joined' },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 223b1632..dd88de7d 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -106,10 +106,10 @@
</router-link>
</li>
<li
- v-if="chat"
+ v-if="shout"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'chat-panel' }">
+ <router-link :to="{ name: 'shout-panel' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
@@ -273,9 +273,7 @@
--icon: var(--popoverIcon, $fallback--icon);
.badge {
- position: absolute;
- right: 0.7rem;
- top: 1em;
+ margin-left: 10px;
}
}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 906413ae..4c0ef3e0 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -9,9 +9,12 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
+import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
@@ -32,7 +35,10 @@ import {
faStar,
faEyeSlash,
faEye,
- faThumbtack
+ faThumbtack,
+ faChevronUp,
+ faChevronDown,
+ faAngleDoubleRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -49,9 +55,47 @@ library.add(
faEllipsisH,
faEyeSlash,
faEye,
- faThumbtack
+ faThumbtack,
+ faChevronUp,
+ faChevronDown,
+ faAngleDoubleRight
)
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const controlledName = `controlled${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ res[name] = function () {
+ return this[toggle] ? this[controlledName] : this[uncontrolledName]
+ }
+ return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[toggle]) {
+ obj[toggle]()
+ } else {
+ obj[uncontrolledName] = !obj[uncontrolledName]
+ }
+}
+
+const controlledOrUncontrolledSet = (obj, name, val) => {
+ const camelized = camelCase(name)
+ const set = `controlledSet${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[set]) {
+ obj[set](val)
+ } else {
+ obj[uncontrolledName] = val
+ }
+}
+
const Status = {
name: 'Status',
components: {
@@ -68,7 +112,10 @@ const Status = {
StatusPopover,
UserListPopover,
EmojiReactions,
- StatusContent
+ StatusContent,
+ RichContent,
+ MentionLink,
+ MentionsLine
},
props: [
'statusoid',
@@ -84,19 +131,37 @@ const Status = {
'showPinned',
'inProfile',
'profileUserId',
- 'virtualHidden'
+
+ 'simpleTree',
+ 'controlledThreadDisplayStatus',
+ 'controlledToggleThreadDisplay',
+ 'showOtherRepliesAsButton',
+
+ 'controlledShowingTall',
+ 'controlledToggleShowingTall',
+ 'controlledExpandingSubject',
+ 'controlledToggleExpandingSubject',
+ 'controlledShowingLongSubject',
+ 'controlledToggleShowingLongSubject',
+ 'controlledReplying',
+ 'controlledToggleReplying',
+ 'controlledMediaPlaying',
+ 'controlledSetMediaPlaying',
+ 'dive'
],
data () {
return {
- replying: false,
+ uncontrolledReplying: false,
unmuted: false,
userExpanded: false,
- mediaPlaying: [],
+ uncontrolledMediaPlaying: [],
suspendable: true,
- error: null
+ error: null,
+ headTailLinks: null
}
},
computed: {
+ ...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
},
@@ -133,12 +198,15 @@ const Status = {
},
replyProfileLink () {
if (this.isReply) {
- return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
+ const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
+ // FIXME Why user not found sometimes???
+ return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
+ retweeterUser () { return this.statusoid.user },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
- retweeterHtml () { return this.statusoid.user.name_html },
+ retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
@@ -157,27 +225,60 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
+ botStatus () {
+ return this.status.user.bot
+ },
+ botIndicator () {
+ return this.botStatus && !this.hideBotIndication
+ },
+ mentionsLine () {
+ if (!this.headTailLinks) return []
+ const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
+ return this.status.attentions.filter(attn => {
+ // no reply user
+ return attn.id !== this.status.in_reply_to_user_id &&
+ // no self-replies
+ attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
+ // don't include if mentions is written
+ !writtenSet.has(attn.statusnet_profile_url)
+ }).map(attn => ({
+ url: attn.statusnet_profile_url,
+ content: attn.screen_name,
+ userId: attn.id
+ }))
+ },
+ hasMentionsLine () {
+ return this.mentionsLine.length > 0
+ },
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
+ const reasonsToMute = this.userIsMuted ||
+ // Thread is muted
+ status.thread_muted ||
+ // Wordfiltered
+ this.muteWordHits.length > 0 ||
+ // bot status
+ (this.muteBotStatuses && this.botStatus && !this.compact)
+ return !this.unmuted && !this.shouldNotMute && reasonsToMute
+ },
+ userIsMuted () {
+ if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
- const reasonsToMute = (
- // Post is muted according to BE
- status.muted ||
+ return status.muted ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
- (relationshipReblog && relationshipReblog.muting) ||
- // Thread is muted
- status.thread_muted ||
- // Wordfiltered
- this.muteWordHits.length > 0
- )
- const excusesNotToMute = (
+ (relationshipReblog && relationshipReblog.muting)
+ },
+ shouldNotMute () {
+ const { status } = this
+ const { reblog } = status
+ return (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
@@ -190,14 +291,26 @@ const Status = {
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
-
- return !this.unmuted && !excusesNotToMute && reasonsToMute
+ },
+ hideMutedUsers () {
+ return this.mergedConfig.hideMutedPosts
+ },
+ hideMutedThreads () {
+ return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
+ hideWordFilteredPosts () {
+ return this.mergedConfig.hideWordFilteredPosts
+ },
hideStatus () {
- return (this.muted && this.hideFilteredStatuses) || this.virtualHidden
+ return (this.virtualHidden || !this.shouldNotMute) && (
+ (this.muted && this.hideFilteredStatuses) ||
+ (this.userIsMuted && this.hideMutedUsers) ||
+ (this.status.thread_muted && this.hideMutedThreads) ||
+ (this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
+ )
},
isFocused () {
// retweet or root of an expanded conversation
@@ -247,6 +360,12 @@ const Status = {
hidePostStats () {
return this.mergedConfig.hidePostStats
},
+ muteBotStatuses () {
+ return this.mergedConfig.muteBotStatuses
+ },
+ hideBotIndication () {
+ return this.mergedConfig.hideBotIndication
+ },
currentUser () {
return this.$store.state.users.currentUser
},
@@ -258,6 +377,12 @@ const Status = {
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
+ },
+ inThreadForest () {
+ return !!this.controlledThreadDisplayStatus
+ },
+ threadShowing () {
+ return this.controlledThreadDisplayStatus === 'showing'
}
},
methods: {
@@ -280,7 +405,7 @@ const Status = {
this.error = undefined
},
toggleReplying () {
- this.replying = !this.replying
+ controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
if (this.inConversation) {
@@ -300,16 +425,21 @@ const Status = {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
addMediaPlaying (id) {
- this.mediaPlaying.push(id)
+ controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
},
removeMediaPlaying (id) {
- this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
- }
- },
- watch: {
- 'highlight': function (id) {
+ controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
+ },
+ setHeadTailLinks (headTailLinks) {
+ this.headTailLinks = headTailLinks
+ },
+ toggleThreadDisplay () {
+ this.controlledToggleThreadDisplay()
+ },
+ scrollIfHighlighted (highlightId) {
+ const id = highlightId
if (this.status.id === id) {
- let rect = this.$refs.root.getBoundingClientRect()
+ let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
@@ -321,6 +451,11 @@ const Status = {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
+ }
+ },
+ watch: {
+ 'highlight': function (id) {
+ this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
@@ -337,6 +472,11 @@ const Status = {
'isSuspendable': function (val) {
this.suspendable = val
}
+ },
+ filters: {
+ capitalize: function (str) {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ }
}
}
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 58b55bc8..3f647b25 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,10 +1,10 @@
-
@import '../../_variables.scss';
-$status-margin: 0.75em;
-
.Status {
min-width: 0;
+ white-space: normal;
+ word-wrap: break-word;
+ word-break: break-word;
&:hover {
--_still-image-img-visibility: visible;
@@ -26,15 +26,8 @@ $status-margin: 0.75em;
--icon: var(--selectedPostIcon, $fallback--icon);
}
- &.-conversation {
- border-left-width: 4px;
- border-left-style: solid;
- border-left-color: $fallback--cRed;
- border-left-color: var(--cRed, $fallback--cRed);
- }
-
.gravestone {
- padding: $status-margin;
+ padding: var(--status-margin, $status-margin);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
display: flex;
@@ -47,7 +40,7 @@ $status-margin: 0.75em;
.status-container {
display: flex;
- padding: $status-margin;
+ padding: var(--status-margin, $status-margin);
&.-repeat {
padding-top: 0;
@@ -55,7 +48,7 @@ $status-margin: 0.75em;
}
.pin {
- padding: $status-margin $status-margin 0;
+ padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -71,7 +64,7 @@ $status-margin: 0.75em;
}
.left-side {
- margin-right: $status-margin;
+ margin-right: var(--status-margin, $status-margin);
}
.right-side {
@@ -80,7 +73,7 @@ $status-margin: 0.75em;
}
.usercard {
- margin-bottom: $status-margin;
+ margin-bottom: var(--status-margin, $status-margin);
}
.status-username {
@@ -93,12 +86,8 @@ $status-margin: 0.75em;
margin-right: 0.4em;
text-overflow: ellipsis;
- .emoji {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain;
- }
+ --_still_image-label-scale: 0.25;
+ --emoji-size: 14px;
}
.status-favicon {
@@ -155,42 +144,37 @@ $status-margin: 0.75em;
}
}
+ .glued-label {
+ display: inline-flex;
+ white-space: nowrap;
+ }
+
.timeago {
margin-right: 0.2em;
}
- .heading-reply-row {
+ & .heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
- line-height: 18px;
+ margin-top: 0.2em;
+ line-height: 130%;
max-width: 100%;
- display: flex;
- flex-wrap: wrap;
align-items: stretch;
}
- .reply-to-and-accountname {
- display: flex;
- height: 18px;
- margin-right: 0.5em;
- max-width: 100%;
-
- .reply-to-link {
- white-space: nowrap;
- word-break: break-word;
- text-overflow: ellipsis;
- overflow-x: hidden;
- }
- }
-
& .reply-to-popover,
- & .reply-to-no-popover {
+ & .reply-to-no-popover,
+ & .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
+ .reply-glued-label {
+ margin-right: 0.5em;
+ }
+
.reply-to-popover {
.reply-to:hover::before {
content: '';
@@ -220,21 +204,26 @@ $status-margin: 0.75em;
}
}
- .reply-to {
+ & .mentions,
+ & .reply-to {
+ white-space: nowrap;
position: relative;
}
- .reply-to-text {
+ & .mentions-text,
+ & .reply-to-text {
+ color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
- .replies-separator {
- margin-left: 0.4em;
+ .mentions-line {
+ display: inline;
}
.replies {
+ margin-top: 0.25em;
line-height: 18px;
font-size: 12px;
display: flex;
@@ -250,7 +239,7 @@ $status-margin: 0.75em;
}
.repeat-info {
- padding: 0.4em $status-margin;
+ padding: 0.4em var(--status-margin, $status-margin);
.repeat-icon {
color: $fallback--cGreen;
@@ -296,7 +285,7 @@ $status-margin: 0.75em;
position: relative;
width: 100%;
display: flex;
- margin-top: $status-margin;
+ margin-top: var(--status-margin, $status-margin);
> * {
max-width: 4em;
@@ -364,7 +353,7 @@ $status-margin: 0.75em;
}
.favs-repeated-users {
- margin-top: $status-margin;
+ margin-top: var(--status-margin, $status-margin);
}
.stats {
@@ -391,7 +380,7 @@ $status-margin: 0.75em;
}
.stat-count {
- margin-right: $status-margin;
+ margin-right: var(--status-margin, $status-margin);
user-select: none;
.stat-title {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 6e3ce0ff..f5b71f54 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,5 +1,4 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
ref="root"
@@ -79,6 +78,7 @@
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
+ :bot="botIndicator"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
@@ -90,8 +90,12 @@
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
- v-html="retweeterHtml"
- />
+ >
+ <RichContent
+ :html="retweeterHtml"
+ :emoji="retweeterUser.emoji"
+ />
+ </router-link>
<router-link
v-else
:to="retweeterProfileLink"
@@ -122,6 +126,7 @@
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
+ :bot="botIndicator"
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
@@ -146,8 +151,12 @@
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
- v-html="status.user.name_html"
- />
+ >
+ <RichContent
+ :html="status.user.name"
+ :emoji="status.user.emoji"
+ />
+ </h4>
<h4
v-else
class="status-username"
@@ -213,13 +222,40 @@
class="fa-scale-110"
/>
</button>
+ <button
+ v-if="inThreadForest && replies && replies.length && !simpleTree"
+ class="button-unstyled"
+ :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
+ :aria-expanded="threadShowing ? 'true' : 'false'"
+ @click.prevent="toggleThreadDisplay"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="threadShowing ? 'chevron-up' : 'chevron-down'"
+ />
+ </button>
+ <button
+ v-if="dive && !simpleTree"
+ class="button-unstyled"
+ :title="$t('status.show_only_conversation_under_this')"
+ @click.prevent="dive"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110"
+ :icon="'angle-double-right'"
+ />
+ </button>
</span>
</div>
-
- <div class="heading-reply-row">
- <div
+ <div
+ v-if="isReply || hasMentionsLine"
+ class="heading-reply-row"
+ >
+ <span
v-if="isReply"
- class="reply-to-and-accountname"
+ class="glued-label reply-glued-label"
>
<StatusPopover
v-if="!isPreview"
@@ -239,7 +275,7 @@
flip="horizontal"
/>
<span
- class="faint-link reply-to-text"
+ class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
@@ -252,50 +288,95 @@
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
- <router-link
- class="reply-to-link"
- :title="replyToName"
- :to="replyProfileLink"
- >
- {{ replyToName }}
- </router-link>
- <span
- v-if="replies && replies.length"
- class="faint replies-separator"
- >
- -
- </span>
- </div>
- <div
- v-if="inConversation && !isPreview && replies && replies.length"
- class="replies"
+ <MentionLink
+ :content="replyToName"
+ :url="replyProfileLink"
+ :user-id="status.in_reply_to_user_id"
+ :user-screen-name="status.in_reply_to_screen_name"
+ :first-mention="false"
+ />
+ </span>
+
+ <!-- This little wrapper is made for sole purpose of "gluing" -->
+ <!-- "Mentions" label to the first mention -->
+ <span
+ v-if="hasMentionsLine"
+ class="glued-label"
>
- <span class="faint">{{ $t('status.replies_list') }}</span>
- <StatusPopover
- v-for="reply in replies"
- :key="reply.id"
- :status-id="reply.id"
+ <span
+ class="mentions"
+ :aria-label="$t('tool_tip.mentions')"
+ @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
- <button
- class="button-unstyled -link reply-link"
- @click.prevent="gotoOriginal(reply.id)"
+ <span
+ class="mentions-text"
>
- {{ reply.name }}
- </button>
- </StatusPopover>
- </div>
+ {{ $t('status.mentions') }}
+ </span>
+ </span>
+ <MentionsLine
+ v-if="hasMentionsLine"
+ :mentions="mentionsLine.slice(0, 1)"
+ class="mentions-line-first"
+ />
+ </span>
+ <MentionsLine
+ v-if="hasMentionsLine"
+ :mentions="mentionsLine.slice(1)"
+ class="mentions-line"
+ />
</div>
</div>
<StatusContent
+ ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
+ :controlled-showing-tall="controlledShowingTall"
+ :controlled-expanding-subject="controlledExpandingSubject"
+ :controlled-showing-long-subject="controlledShowingLongSubject"
+ :controlled-toggle-showing-tall="controlledToggleShowingTall"
+ :controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
+ :controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
+ @parseReady="setHeadTailLinks"
/>
+ <div
+ v-if="inConversation && !isPreview && replies && replies.length"
+ class="replies"
+ >
+ <button
+ v-if="showOtherRepliesAsButton && replies.length > 1"
+ class="button-unstyled -link faint"
+ :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
+ @click.prevent="dive"
+ >
+ {{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
+ </button>
+ <span
+ v-else
+ class="faint"
+ >
+ {{ $t('status.replies_list') }}
+ </span>
+ <StatusPopover
+ v-for="reply in replies"
+ :key="reply.id"
+ :status-id="reply.id"
+ >
+ <button
+ class="button-unstyled -link reply-link"
+ @click.prevent="gotoOriginal(reply.id)"
+ >
+ {{ reply.name }}
+ </button>
+ </StatusPopover>
+ </div>
+
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
@@ -373,7 +454,10 @@
class="gravestone"
>
<div class="left-side">
- <UserAvatar :compact="compact" />
+ <UserAvatar
+ :compact="compact"
+ :bot="botIndicator"
+ />
</div>
<div class="right-side">
<div class="deleted-text">
@@ -403,7 +487,6 @@
</div>
</template>
</div>
-<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
new file mode 100644
index 00000000..b8f6f9a0
--- /dev/null
+++ b/src/components/status_body/status_body.js
@@ -0,0 +1,131 @@
+import fileType from 'src/services/file_type/file_type.service'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { mapGetters } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faFile,
+ faMusic,
+ faImage,
+ faLink,
+ faPollH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faFile,
+ faMusic,
+ faImage,
+ faLink,
+ faPollH
+)
+
+const StatusContent = {
+ name: 'StatusContent',
+ props: [
+ 'compact',
+ 'status',
+ 'focused',
+ 'noHeading',
+ 'fullContent',
+ 'singleLine',
+ 'showingTall',
+ 'expandingSubject',
+ 'showingLongSubject',
+ 'toggleShowingTall',
+ 'toggleExpandingSubject',
+ 'toggleShowingLongSubject'
+ ],
+ data () {
+ return {
+ postLength: this.status.text.length,
+ parseReadyDone: false
+ }
+ },
+ computed: {
+ localCollapseSubjectDefault () {
+ return this.mergedConfig.collapseMessageWithSubject
+ },
+ // This is a bit hacky, but we want to approximate post height before rendering
+ // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
+ // as well as approximate line count by counting characters and approximating ~80
+ // per line.
+ //
+ // Using max-height + overflow: auto for status components resulted in false positives
+ // very often with japanese characters, and it was very annoying.
+ tallStatus () {
+ if (this.singleLine || this.compact) return false
+ const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
+ return lengthScore > 20
+ },
+ longSubject () {
+ return this.status.summary.length > 240
+ },
+ // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
+ mightHideBecauseSubject () {
+ return !!this.status.summary && this.localCollapseSubjectDefault
+ },
+ mightHideBecauseTall () {
+ return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
+ },
+ hideSubjectStatus () {
+ return this.mightHideBecauseSubject && !this.expandingSubject
+ },
+ hideTallStatus () {
+ return this.mightHideBecauseTall && !this.showingTall
+ },
+ showingMore () {
+ return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
+ },
+ attachmentTypes () {
+ return this.status.attachments.map(file => fileType.fileType(file.mimetype))
+ },
+ ...mapGetters(['mergedConfig'])
+ },
+ components: {
+ RichContent
+ },
+ mounted () {
+ this.status.attentions && this.status.attentions.forEach(attn => {
+ const { id } = attn
+ this.$store.dispatch('fetchUserIfMissing', id)
+ })
+ },
+ methods: {
+ onParseReady (event) {
+ if (this.parseReadyDone) return
+ this.parseReadyDone = true
+ this.$emit('parseReady', event)
+ const { writtenMentions, invisibleMentions } = event
+ writtenMentions
+ .filter(mention => !mention.notifying)
+ .forEach(mention => {
+ const { content, url } = mention
+ const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
+ if (!cleanedString.startsWith('@')) return
+ const handle = cleanedString.slice(1)
+ const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
+ this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
+ })
+ /* This is a bit of a hack to make current tall status detector work
+ * with rich mentions. Invisible mentions are detected at RichContent level
+ * and also we generate plaintext version of mentions by stripping tags
+ * so here we subtract from post length by each mention that became invisible
+ * via MentionsLine
+ */
+ this.postLength = invisibleMentions.reduce((acc, mention) => {
+ return acc - mention.textContent.length - 1
+ }, this.postLength)
+ },
+ toggleShowMore () {
+ if (this.mightHideBecauseTall) {
+ this.toggleShowingTall()
+ } else if (this.mightHideBecauseSubject) {
+ this.toggleExpandingSubject()
+ }
+ },
+ generateTagLink (tag) {
+ return `/tag/${tag}`
+ }
+ }
+}
+
+export default StatusContent
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
new file mode 100644
index 00000000..f261108e
--- /dev/null
+++ b/src/components/status_body/status_body.scss
@@ -0,0 +1,174 @@
+@import '../../_variables.scss';
+
+.StatusBody {
+ display: flex;
+ flex-direction: column;
+
+ .emoji {
+ --_still_image-label-scale: 0.5;
+ }
+
+ .attachments {
+ margin-top: 0.5em;
+ }
+
+ & .text,
+ & .summary {
+ font-family: var(--postFont, sans-serif);
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ line-height: 1.4em;
+ }
+
+ .summary {
+ display: block;
+ font-style: italic;
+ padding-bottom: 0.5em;
+ }
+
+ .text {
+ &.-single-line {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ height: 1.4em;
+ }
+ }
+
+ .summary-wrapper {
+ margin-bottom: 0.5em;
+ border-style: solid;
+ border-width: 0 0 1px 0;
+ border-color: var(--border, $fallback--border);
+ flex-grow: 0;
+
+ &.-tall {
+ position: relative;
+
+ .summary {
+ max-height: 2em;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ .text-wrapper {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+
+ &.-tall-status {
+ position: relative;
+ height: 220px;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ z-index: 1;
+
+ .media-body {
+ min-height: 0;
+ mask:
+ linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+ linear-gradient(to top, white, white);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+ }
+ }
+
+ & .tall-status-hider,
+ & .tall-subject-hider,
+ & .status-unhider,
+ & .cw-status-hider {
+ display: inline-block;
+ word-break: break-all;
+ width: 100%;
+ text-align: center;
+ }
+
+ .tall-status-hider {
+ position: absolute;
+ height: 70px;
+ margin-top: 150px;
+ line-height: 110px;
+ z-index: 2;
+ }
+
+ .tall-subject-hider {
+ // position: absolute;
+ padding-bottom: 0.5em;
+ }
+
+ & .status-unhider,
+ & .cw-status-hider {
+ word-break: break-all;
+
+ svg {
+ color: inherit;
+ }
+ }
+
+ .greentext {
+ color: $fallback--cGreen;
+ color: var(--postGreentext, $fallback--cGreen);
+ }
+
+ .cyantext {
+ color: var(--postCyantext, $fallback--cBlue);
+ }
+
+ &.-compact {
+ align-items: top;
+ flex-direction: row;
+
+ --emoji-size: 16px;
+
+ & .body,
+ & .attachments {
+ max-height: 3.25em;
+ }
+
+ .body {
+ overflow: hidden;
+ white-space: normal;
+ min-width: 5em;
+ flex: 5 1 auto;
+ mask-size: auto 3.5em, auto auto;
+ mask-position: 0 0, 0 0;
+ mask-repeat: repeat-x, repeat;
+ mask-image: linear-gradient(to bottom, white 2em, transparent 3em);
+
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
+
+ .attachments {
+ margin-top: 0;
+ flex: 1 1 0;
+ min-width: 5em;
+ height: 100%;
+ margin-left: 0.5em;
+ }
+
+ .summary-wrapper {
+ .summary::after {
+ content: ': ';
+ }
+
+ line-height: inherit;
+ margin: 0;
+ border: none;
+ display: inline-block;
+ }
+
+ .text-wrapper {
+ display: inline-block;
+ }
+ }
+}
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
new file mode 100644
index 00000000..24d842c2
--- /dev/null
+++ b/src/components/status_body/status_body.vue
@@ -0,0 +1,100 @@
+<template>
+ <div
+ class="StatusBody"
+ :class="{ '-compact': compact }"
+ >
+ <div class="body">
+ <div
+ v-if="status.summary_raw_html"
+ class="summary-wrapper"
+ :class="{ '-tall': (longSubject && !showingLongSubject) }"
+ >
+ <RichContent
+ class="media-body summary"
+ :html="status.summary_raw_html"
+ :emoji="status.emojis"
+ />
+ <button
+ v-if="longSubject && showingLongSubject"
+ class="button-unstyled -link tall-subject-hider"
+ @click.prevent="toggleShowingLongSubject"
+ >
+ {{ $t("status.hide_full_subject") }}
+ </button>
+ <button
+ v-else-if="longSubject"
+ class="button-unstyled -link tall-subject-hider"
+ @click.prevent="toggleShowingLongSubject"
+ >
+ {{ $t("status.show_full_subject") }}
+ </button>
+ </div>
+ <div
+ :class="{'-tall-status': hideTallStatus}"
+ class="text-wrapper"
+ >
+ <button
+ v-if="hideTallStatus"
+ class="button-unstyled -link tall-status-hider"
+ :class="{ '-focused': focused }"
+ @click.prevent="toggleShowMore"
+ >
+ {{ $t("general.show_more") }}
+ </button>
+ <RichContent
+ v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
+ :class="{ '-single-line': singleLine }"
+ class="text media-body"
+ :html="status.raw_html"
+ :emoji="status.emojis"
+ :handle-links="true"
+ :greentext="mergedConfig.greentext"
+ :attentions="status.attentions"
+ @parseReady="onParseReady"
+ />
+
+ <button
+ v-if="hideSubjectStatus"
+ class="button-unstyled -link cw-status-hider"
+ @click.prevent="toggleShowMore"
+ >
+ {{ $t("status.show_content") }}
+ <FAIcon
+ v-if="attachmentTypes.includes('image')"
+ icon="image"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('video')"
+ icon="video"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('audio')"
+ icon="music"
+ />
+ <FAIcon
+ v-if="attachmentTypes.includes('unknown')"
+ icon="file"
+ />
+ <FAIcon
+ v-if="status.poll && status.poll.options"
+ icon="poll-h"
+ />
+ <FAIcon
+ v-if="status.card"
+ icon="link"
+ />
+ </button>
+ <button
+ v-if="showingMore && !fullContent"
+ class="button-unstyled -link status-unhider"
+ @click.prevent="toggleShowMore"
+ >
+ {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+ </button>
+ </div>
+ </div>
+ <slot v-if="!hideSubjectStatus" />
+ </div>
+</template>
+<script src="./status_body.js" ></script>
+<style lang="scss" src="./status_body.scss" />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index a755518c..cf72ccb8 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,11 +1,8 @@
import Attachment from '../attachment/attachment.vue'
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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
-import fileType from 'src/services/file_type/file_type.service'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters, mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@@ -26,62 +23,58 @@ library.add(
faPollH
)
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const controlledName = `controlled${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ res[name] = function () {
+ return this[toggle] ? this[controlledName] : this[uncontrolledName]
+ }
+ return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+ const camelized = camelCase(name)
+ const toggle = `controlledToggle${camelized}`
+ const uncontrolledName = `uncontrolled${camelized}`
+ if (obj[toggle]) {
+ obj[toggle]()
+ } else {
+ obj[uncontrolledName] = !obj[uncontrolledName]
+ }
+}
+
const StatusContent = {
name: 'StatusContent',
props: [
'status',
+ 'compact',
'focused',
'noHeading',
'fullContent',
'singleLine',
- 'compact'
+ 'controlledShowingTall',
+ 'controlledExpandingSubject',
+ 'controlledToggleShowingTall',
+ 'controlledToggleExpandingSubject'
],
data () {
return {
- showingTall: this.fullContent || (this.inConversation && this.focused),
- showingLongSubject: false,
+ uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused),
+ uncontrolledShowingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
- expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+ uncontrolledExpandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
}
},
computed: {
- localCollapseSubjectDefault () {
- return this.mergedConfig.collapseMessageWithSubject
- },
+ ...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']),
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
- // This is a bit hacky, but we want to approximate post height before rendering
- // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
- // as well as approximate line count by counting characters and approximating ~80
- // per line.
- //
- // Using max-height + overflow: auto for status components resulted in false positives
- // very often with japanese characters, and it was very annoying.
- tallStatus () {
- const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
- return lengthScore > 20
- },
- longSubject () {
- return this.status.summary.length > 240
- },
- // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
- mightHideBecauseSubject () {
- return !!this.status.summary && this.localCollapseSubjectDefault
- },
- mightHideBecauseTall () {
- return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
- },
- hideSubjectStatus () {
- return this.mightHideBecauseSubject && !this.expandingSubject
- },
- hideTallStatus () {
- return this.mightHideBecauseTall && !this.showingTall
- },
- showingMore () {
- return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
- },
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
@@ -92,72 +85,20 @@ const StatusContent = {
return true
},
attachmentSize () {
- if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
+ if (this.compact) {
+ return 'small'
+ } else if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
- } else if (this.compact) {
- return 'small'
}
return 'normal'
},
- galleryTypes () {
- if (this.attachmentSize === 'hide') {
- return []
- }
- return this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
- },
- galleryAttachments () {
- return this.status.attachments.filter(
- file => fileType.fileMatchesSomeType(this.galleryTypes, file)
- )
- },
- nonGalleryAttachments () {
- return this.status.attachments.filter(
- file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
- )
- },
- attachmentTypes () {
- return this.status.attachments.map(file => fileType.fileType(file.mimetype))
- },
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
- postBodyHtml () {
- const html = this.status.statusnet_html
-
- if (this.mergedConfig.greentext) {
- try {
- if (html.includes('&gt;')) {
- // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
- return processHtml(html, (string) => {
- if (string.includes('&gt;') &&
- string
- .replace(/<[^>]+?>/gi, '') // remove all tags
- .replace(/@\w+/gi, '') // remove mentions (even failed ones)
- .trim()
- .startsWith('&gt;')) {
- return `<span class='greentext'>${string}</span>`
- } else {
- return string
- }
- })
- } else {
- return html
- }
- } catch (e) {
- console.err('Failed to process status html', e)
- return html
- }
- } else {
- return html
- }
- },
...mapGetters(['mergedConfig']),
...mapState({
- betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
@@ -165,47 +106,18 @@ const StatusContent = {
Attachment,
Poll,
Gallery,
- LinkPreview
+ LinkPreview,
+ StatusBody
},
methods: {
- linkClicked (event) {
- const target = event.target.closest('.status-content a')
- if (target) {
- if (target.className.match(/mention/)) {
- const href = target.href
- const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
- if (attn) {
- event.stopPropagation()
- event.preventDefault()
- const link = this.generateUserProfileLink(attn.id, attn.screen_name)
- this.$router.push(link)
- return
- }
- }
- if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
- // Extract tag name from dataset or link url
- const tag = target.dataset.tag || extractTagFromUrl(target.href)
- if (tag) {
- const link = this.generateTagLink(tag)
- this.$router.push(link)
- return
- }
- }
- window.open(target.href, '_blank')
- }
- },
- toggleShowMore () {
- if (this.mightHideBecauseTall) {
- this.showingTall = !this.showingTall
- } else if (this.mightHideBecauseSubject) {
- this.expandingSubject = !this.expandingSubject
- }
+ toggleShowingTall () {
+ controlledOrUncontrolledToggle(this, 'showingTall')
},
- generateUserProfileLink (id, name) {
- return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
+ toggleExpandingSubject () {
+ controlledOrUncontrolledToggle(this, 'expandingSubject')
},
- generateTagLink (tag) {
- return `/tag/${tag}`
+ toggleShowingLongSubject () {
+ controlledOrUncontrolledToggle(this, 'showingLongSubject')
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 90bfaf40..9e7d7956 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,294 +1,65 @@
<template>
- <!-- eslint-disable vue/no-v-html -->
- <div class="StatusContent">
+ <div
+ class="StatusContent"
+ :class="{ '-compact': compact }"
+ >
<slot name="header" />
- <div
- v-if="status.summary_html"
- class="summary-wrapper"
- :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
+ <StatusBody
+ :status="status"
+ :compact="compact"
+ :single-line="singleLine"
+ :showing-tall="showingTall"
+ :expanding-subject="expandingSubject"
+ :showing-long-subject="showingLongSubject"
+ :toggle-showing-tall="toggleShowingTall"
+ :toggle-expanding-subject="toggleExpandingSubject"
+ :toggle-showing-long-subject="toggleShowingLongSubject"
+ @parseReady="$emit('parseReady', $event)"
>
- <div
- class="media-body summary"
- @click.prevent="linkClicked"
- v-html="status.summary_html"
- />
- <button
- v-if="longSubject && showingLongSubject"
- class="button-unstyled -link tall-subject-hider"
- @click.prevent="showingLongSubject=false"
- >
- {{ $t("status.hide_full_subject") }}
- </button>
- <button
- v-else-if="longSubject"
- class="button-unstyled -link tall-subject-hider"
- :class="{ 'tall-subject-hider_focused': focused }"
- @click.prevent="showingLongSubject=true"
- >
- {{ $t("status.show_full_subject") }}
- </button>
- </div>
- <div
- :class="{'tall-status': hideTallStatus}"
- class="status-content-wrapper"
- >
- <button
- v-if="hideTallStatus"
- class="button-unstyled -link tall-status-hider"
- :class="{ 'tall-status-hider_focused': focused }"
- @click.prevent="toggleShowMore"
- >
- {{ $t("general.show_more") }}
- </button>
- <div
- v-if="!hideSubjectStatus"
- :class="{ 'single-line': singleLine }"
- class="status-content media-body"
- @click.prevent="linkClicked"
- v-html="postBodyHtml"
- />
- <button
- v-if="hideSubjectStatus"
- class="button-unstyled -link cw-status-hider"
- @click.prevent="toggleShowMore"
- >
- {{ $t("status.show_content") }}
- <FAIcon
- v-if="attachmentTypes.includes('image')"
- icon="image"
- />
- <FAIcon
- v-if="attachmentTypes.includes('video')"
- icon="video"
- />
- <FAIcon
- v-if="attachmentTypes.includes('audio')"
- icon="music"
- />
- <FAIcon
- v-if="attachmentTypes.includes('unknown')"
- icon="file"
+ <div v-if="status.poll && status.poll.options && !compact">
+ <Poll
+ :base-poll="status.poll"
+ :emoji="status.emojis"
/>
+ </div>
+
+ <div v-else-if="status.poll && status.poll.options && compact">
<FAIcon
- v-if="status.poll && status.poll.options"
icon="poll-h"
+ size="2x"
/>
- <FAIcon
- v-if="status.card"
- icon="link"
- />
- </button>
- <button
- v-if="showingMore && !fullContent"
- class="button-unstyled -link status-unhider"
- @click.prevent="toggleShowMore"
- >
- {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
- </button>
- </div>
+ </div>
- <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
- <poll :base-poll="status.poll" />
- </div>
-
- <div
- v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
- class="attachments media-body"
- >
- <attachment
- v-for="attachment in nonGalleryAttachments"
- :key="attachment.id"
- class="non-gallery"
- :size="attachmentSize"
+ <gallery
+ v-if="status.attachments.length !== 0"
+ class="attachments media-body"
:nsfw="nsfwClickthrough"
- :attachment="attachment"
- :allow-play="true"
- :set-media="setMedia()"
+ :attachments="status.attachments"
+ :limit="compact ? 1 : 0"
+ :size="attachmentSize"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
- <gallery
- v-if="galleryAttachments.length > 0"
- :nsfw="nsfwClickthrough"
- :attachments="galleryAttachments"
- :set-media="setMedia()"
- />
- </div>
- <div
- v-if="status.card && !hideSubjectStatus && !noHeading"
- class="link-preview media-body"
- >
- <link-preview
- :card="status.card"
- :size="attachmentSize"
- :nsfw="nsfwClickthrough"
- />
- </div>
+ <div
+ v-if="status.card && !noHeading && !compact"
+ class="link-preview media-body"
+ >
+ <link-preview
+ :card="status.card"
+ :size="attachmentSize"
+ :nsfw="nsfwClickthrough"
+ />
+ </div>
+ </StatusBody>
<slot name="footer" />
</div>
- <!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
<style lang="scss">
-@import '../../_variables.scss';
-
-$status-margin: 0.75em;
-
.StatusContent {
flex: 1;
min-width: 0;
-
- .status-content-wrapper {
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- }
-
- .tall-status {
- position: relative;
- height: 220px;
- overflow-x: hidden;
- overflow-y: hidden;
- z-index: 1;
- .status-content {
- min-height: 0;
- mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
- linear-gradient(to top, white, white);
- /* Autoprefixed seem to ignore this one, and also syntax is different */
- -webkit-mask-composite: xor;
- mask-composite: exclude;
- }
- }
-
- .tall-status-hider {
- display: inline-block;
- word-break: break-all;
- position: absolute;
- height: 70px;
- margin-top: 150px;
- width: 100%;
- text-align: center;
- line-height: 110px;
- z-index: 2;
- }
-
- .status-unhider, .cw-status-hider {
- width: 100%;
- text-align: center;
- display: inline-block;
- word-break: break-all;
-
- svg {
- color: inherit;
- }
- }
-
- img, video {
- max-width: 100%;
- max-height: 400px;
- vertical-align: middle;
- object-fit: contain;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
- }
-
- .summary-wrapper {
- margin-bottom: 0.5em;
- border-style: solid;
- border-width: 0 0 1px 0;
- border-color: var(--border, $fallback--border);
- flex-grow: 0;
- }
-
- .summary {
- font-style: italic;
- padding-bottom: 0.5em;
- }
-
- .tall-subject {
- position: relative;
- .summary {
- max-height: 2em;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- }
-
- .tall-subject-hider {
- display: inline-block;
- word-break: break-all;
- // position: absolute;
- width: 100%;
- text-align: center;
- padding-bottom: 0.5em;
- }
-
- .status-content {
- font-family: var(--postFont, sans-serif);
- line-height: 1.4em;
- white-space: pre-wrap;
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
-
- blockquote {
- margin: 0.2em 0 0.2em 2em;
- font-style: italic;
- }
-
- pre {
- overflow: auto;
- }
-
- code, samp, kbd, var, pre {
- font-family: var(--postCodeFont, monospace);
- }
-
- p {
- margin: 0 0 1em 0;
- }
-
- p:last-child {
- margin: 0 0 0 0;
- }
-
- h1 {
- font-size: 1.1em;
- line-height: 1.2em;
- margin: 1.4em 0;
- }
-
- h2 {
- font-size: 1.1em;
- margin: 1.0em 0;
- }
-
- h3 {
- font-size: 1em;
- margin: 1.2em 0;
- }
-
- h4 {
- margin: 1.1em 0;
- }
-
- &.single-line {
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- height: 1.4em;
- }
- }
-}
-
-.greentext {
- color: $fallback--cGreen;
- color: var(--postGreentext, $fallback--cGreen);
}
</style>
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index 8044e994..d7abbcb5 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -5,7 +5,9 @@ const StillImage = {
'mimetype',
'imageLoadError',
'imageLoadHandler',
- 'alt'
+ 'alt',
+ 'height',
+ 'width'
],
data () {
return {
@@ -15,6 +17,13 @@ const StillImage = {
computed: {
animated () {
return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
+ },
+ style () {
+ const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
+ return {
+ height: this.height ? appendPx(this.height) : null,
+ width: this.width ? appendPx(this.width) : null
+ }
}
},
methods: {
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index d3eb5925..e939b532 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -2,6 +2,7 @@
<div
class="still-image"
:class="{ animated: animated }"
+ :style="style"
>
<canvas
v-if="animated"
@@ -18,6 +19,7 @@
@load="onLoad"
@error="onError"
>
+ <slot />
</div>
</template>
@@ -30,7 +32,7 @@
position: relative;
line-height: 0;
overflow: hidden;
- display: flex;
+ display: inline-flex;
align-items: center;
canvas {
@@ -47,12 +49,13 @@
img {
width: 100%;
- min-height: 100%;
+ height: 100%;
object-fit: contain;
}
&.animated {
&::before {
+ zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute;
line-height: 10px;
diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js
new file mode 100644
index 00000000..238e6df8
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.js
@@ -0,0 +1,84 @@
+import GestureService from '../../services/gesture_service/gesture_service'
+
+/**
+ * props:
+ * direction: a vector that indicates the direction of the intended swipe
+ * threshold: the minimum distance in pixels the swipe has moved on `direction'
+ * for swipe-finished() to have a non-zero sign
+ * perpendicularTolerance: see gesture_service
+ *
+ * Events:
+ * preview-requested(offsets)
+ * Emitted when the pointer has moved.
+ * offsets: the offsets from the start of the swipe to the current cursor position
+ *
+ * swipe-canceled()
+ * Emitted when the swipe has been canceled due to a pointercancel event.
+ *
+ * swipe-finished(sign: 0|-1|1)
+ * Emitted when the swipe has finished.
+ * sign: if the swipe does not meet the threshold, 0
+ * if the swipe meets the threshold in the positive direction, 1
+ * if the swipe meets the threshold in the negative direction, -1
+ *
+ * swipeless-clicked()
+ * Emitted when there is a click without swipe.
+ * This and swipe-finished() cannot be emitted for the same pointerup event.
+ */
+const SwipeClick = {
+ props: {
+ direction: {
+ type: Array
+ },
+ threshold: {
+ type: Function,
+ default: () => 30
+ },
+ perpendicularTolerance: {
+ type: Number,
+ default: 1.0
+ }
+ },
+ methods: {
+ handlePointerDown (event) {
+ this.$gesture.start(event)
+ },
+ handlePointerMove (event) {
+ this.$gesture.move(event)
+ },
+ handlePointerUp (event) {
+ this.$gesture.end(event)
+ },
+ handlePointerCancel (event) {
+ this.$gesture.cancel(event)
+ },
+ handleNativeClick (event) {
+ this.$gesture.click(event)
+ },
+ preview (offsets) {
+ this.$emit('preview-requested', offsets)
+ },
+ end (sign) {
+ this.$emit('swipe-finished', sign)
+ },
+ click () {
+ this.$emit('swipeless-clicked')
+ },
+ cancel () {
+ this.$emit('swipe-canceled')
+ }
+ },
+ created () {
+ this.$gesture = new GestureService.SwipeAndClickGesture({
+ direction: this.direction,
+ threshold: this.threshold,
+ perpendicularTolerance: this.perpendicularTolerance,
+ swipePreviewCallback: this.preview,
+ swipeEndCallback: this.end,
+ swipeCancelCallback: this.cancel,
+ swipelessClickCallback: this.click
+ })
+ }
+}
+
+export default SwipeClick
diff --git a/src/components/swipe_click/swipe_click.vue b/src/components/swipe_click/swipe_click.vue
new file mode 100644
index 00000000..5372071d
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.vue
@@ -0,0 +1,14 @@
+<template>
+ <div
+ v-bind="$attrs"
+ @pointerdown="handlePointerDown"
+ @pointermove="handlePointerMove"
+ @pointerup="handlePointerUp"
+ @pointercancel="handlePointerCancel"
+ @click="handleNativeClick"
+ >
+ <slot />
+ </div>
+</template>
+
+<script src="./swipe_click.js"></script>
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
new file mode 100644
index 00000000..71e63725
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.js
@@ -0,0 +1,90 @@
+import Status from '../status/status.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faAngleDoubleDown,
+ faAngleDoubleRight
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faAngleDoubleDown,
+ faAngleDoubleRight
+)
+
+const ThreadTree = {
+ components: {
+ Status
+ },
+ name: 'ThreadTree',
+ props: {
+ depth: Number,
+ status: Object,
+ inProfile: Boolean,
+ conversation: Array,
+ collapsable: Boolean,
+ isExpanded: Boolean,
+ pinnedStatusIdsObject: Object,
+ profileUserId: String,
+
+ focused: Function,
+ highlight: String,
+ getReplies: Function,
+ setHighlight: Function,
+ toggleExpanded: Function,
+
+ simple: Boolean,
+ // to control display of the whole thread forest
+ toggleThreadDisplay: Function,
+ threadDisplayStatus: Object,
+ showThreadRecursively: Function,
+ totalReplyCount: Object,
+ totalReplyDepth: Object,
+ statusContentProperties: Object,
+ setStatusContentProperty: Function,
+ toggleStatusContentProperty: Function,
+ dive: Function
+ },
+ computed: {
+ suspendable () {
+ const selfSuspendable = this.$refs.statusComponent ? this.$refs.statusComponent.suspendable : true
+ if (this.$refs.childComponent) {
+ return selfSuspendable && this.$refs.childComponent.every(s => s.suspendable)
+ }
+ return selfSuspendable
+ },
+ reverseLookupTable () {
+ return this.conversation.reduce((table, status, index) => {
+ table[status.id] = index
+ return table
+ }, {})
+ },
+ currentReplies () {
+ return this.getReplies(this.status.id).map(({ id }) => this.statusById(id))
+ },
+ threadShowing () {
+ return this.threadDisplayStatus[this.status.id] === 'showing'
+ },
+ currentProp () {
+ return this.statusContentProperties[this.status.id]
+ }
+ },
+ methods: {
+ statusById (id) {
+ return this.conversation[this.reverseLookupTable[id]]
+ },
+ collapseThread () {
+ },
+ showThread () {
+ },
+ showAllSubthreads () {
+ },
+ toggleCurrentProp (name) {
+ this.toggleStatusContentProperty(this.status.id, name)
+ },
+ setCurrentProp (name, newVal) {
+ this.setStatusContentProperty(this.status.id, name)
+ }
+ }
+}
+
+export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
new file mode 100644
index 00000000..e64455e0
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.vue
@@ -0,0 +1,127 @@
+<template>
+ <div class="thread-tree panel-body">
+ <status
+ :key="status.id"
+ ref="statusComponent"
+ :inline-expanded="collapsable && isExpanded"
+ :statusoid="status"
+ :expandable="!isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :focused="focused(status.id)"
+ :in-conversation="isExpanded"
+ :highlight="highlight"
+ :replies="getReplies(status.id)"
+ :in-profile="inProfile"
+ :profile-user-id="profileUserId"
+ class="conversation-status conversation-status-treeview status-fadein panel-body"
+
+ :simple-tree="simple"
+ :controlled-thread-display-status="threadDisplayStatus[status.id]"
+ :controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)"
+
+ :controlled-showing-tall="currentProp.showingTall"
+ :controlled-expanding-subject="currentProp.expandingSubject"
+ :controlled-showing-long-subject="currentProp.showingLongSubject"
+ :controlled-replying="currentProp.replying"
+ :controlled-media-playing="currentProp.mediaPlaying"
+ :controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')"
+ :controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')"
+ :controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')"
+ :controlled-toggle-replying="() => toggleCurrentProp('replying')"
+ :controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)"
+ :dive="dive ? () => dive(status.id) : undefined"
+
+ @goto="setHighlight"
+ @toggleExpanded="toggleExpanded"
+ />
+ <div
+ v-if="currentReplies.length && threadShowing"
+ class="thread-tree-replies"
+ >
+ <thread-tree
+ v-for="replyStatus in currentReplies"
+ :key="replyStatus.id"
+ ref="childComponent"
+ :depth="depth + 1"
+ :status="replyStatus"
+
+ :in-profile="inProfile"
+ :conversation="conversation"
+ :collapsable="collapsable"
+ :is-expanded="isExpanded"
+ :pinned-status-ids-object="pinnedStatusIdsObject"
+ :profile-user-id="profileUserId"
+
+ :focused="focused"
+ :get-replies="getReplies"
+ :highlight="highlight"
+ :set-highlight="setHighlight"
+ :toggle-expanded="toggleExpanded"
+
+ :simple="simple"
+ :toggle-thread-display="toggleThreadDisplay"
+ :thread-display-status="threadDisplayStatus"
+ :show-thread-recursively="showThreadRecursively"
+ :total-reply-count="totalReplyCount"
+ :total-reply-depth="totalReplyDepth"
+ :status-content-properties="statusContentProperties"
+ :set-status-content-property="setStatusContentProperty"
+ :toggle-status-content-property="toggleStatusContentProperty"
+ :dive="dive"
+ />
+ </div>
+ <div
+ v-if="currentReplies.length && !threadShowing"
+ class="thread-tree-replies thread-tree-replies-hidden"
+ >
+ <i18n
+ v-if="simple"
+ tag="button"
+ path="status.thread_follow_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="dive(status.id)"
+ >
+ <FAIcon
+ place="icon"
+ icon="angle-double-right"
+ />
+ <span place="text">
+ {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
+ </span>
+ </i18n>
+ <i18n
+ v-else
+ tag="button"
+ path="status.thread_show_full_with_icon"
+ class="button-unstyled -link thread-tree-show-replies-button"
+ @click.prevent="showThreadRecursively(status.id)"
+ >
+ <FAIcon
+ place="icon"
+ icon="angle-double-down"
+ />
+ <span place="text">
+ {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
+ </span>
+ </i18n>
+ </div>
+ </div>
+</template>
+
+<script src="./thread_tree.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.thread-tree-replies {
+ margin-left: var(--status-margin, $status-margin);
+ border-left: 2px solid var(--border, $fallback--border);
+}
+
+.thread-tree-replies-hidden {
+ padding: var(--status-margin, $status-margin);
+ /* Make the button stretch along the whole row */
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+}
+</style>
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index b5a45926..e896a23c 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -12,19 +12,6 @@ library.add(
faCog
)
-export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => {
- const ids = []
- if (pinnedStatusIds && pinnedStatusIds.length > 0) {
- for (let status of statuses) {
- if (!pinnedStatusIds.includes(status.id)) {
- break
- }
- ids.push(status.id)
- }
- }
- return ids
-}
-
const Timeline = {
props: [
'timeline',
@@ -77,11 +64,6 @@ const Timeline = {
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
- excludedStatusIdsObject () {
- const ids = getExcludedStatusIdsByPinning(this.timeline.visibleStatuses, this.pinnedStatusIds)
- // Convert id array to object
- return keyBy(ids)
- },
pinnedStatusIdsObject () {
return keyBy(this.pinnedStatusIds)
},
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 767428f0..ff16208d 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -37,7 +37,7 @@
</template>
<template v-for="status in timeline.visibleStatuses">
<conversation
- v-if="!excludedStatusIdsObject[status.id]"
+ v-if="timelineName !== 'user' || (status.id >= timeline.minId && status.id <= timeline.maxId)"
:key="status.id"
class="status-fadein"
:status-id="status.id"
diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/timeline/timeline_quick_settings.js
index eae65a55..92d5ac14 100644
--- a/src/components/timeline/timeline_quick_settings.js
+++ b/src/components/timeline/timeline_quick_settings.js
@@ -48,12 +48,18 @@ const TimelineQuickSettings = {
}
},
hideMutedPosts: {
- get () { return this.mergedConfig.hideMutedPosts || this.mergedConfig.hideFilteredStatuses },
+ get () { return this.mergedConfig.hideFilteredStatuses },
set () {
const value = !this.hideMutedPosts
- this.$store.dispatch('setOption', { name: 'hideMutedPosts', value })
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
}
+ },
+ muteBotStatuses: {
+ get () { return this.mergedConfig.muteBotStatuses },
+ set () {
+ const value = !this.muteBotStatuses
+ this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+ }
}
}
}
diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/timeline/timeline_quick_settings.vue
index 98996ebd..4d67e06b 100644
--- a/src/components/timeline/timeline_quick_settings.vue
+++ b/src/components/timeline/timeline_quick_settings.vue
@@ -41,6 +41,15 @@
</div>
<button
class="button-default dropdown-item"
+ @click="muteBotStatuses = !muteBotStatuses"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': muteBotStatuses }"
+ />{{ $t('settings.mute_bot_posts') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
@click="hideMedia = !hideMedia"
>
<span
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
index 4f6681b8..8f14093f 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -10,10 +10,7 @@
@close="() => isOpen = false"
>
<template v-slot:content>
- <div
- slot="content"
- class="timeline-menu-popover popover-default"
- >
+ <div class="timeline-menu-popover popover-default">
<TimelineMenuContent />
</div>
</template>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 94653004..33d9a258 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -1,10 +1,21 @@
import StillImage from '../still-image/still-image.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+
+import {
+ faRobot
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faRobot
+)
+
const UserAvatar = {
props: [
'user',
'betterShadow',
- 'compact'
+ 'compact',
+ 'bot'
],
data () {
return {
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 4040e263..847d654b 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -7,7 +7,13 @@
:src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
- />
+ >
+ <FAIcon
+ v-if="bot"
+ icon="robot"
+ class="bot-indicator"
+ />
+ </StillImage>
<div
v-else
class="Avatar -placeholder"
@@ -36,6 +42,12 @@
height: 100%;
}
+ & > .bot-indicator {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+
&.better-shadow {
box-shadow: var(--_avatarShadowInset);
filter: var(--_avatarShadowFilter);
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 3a8efafc..4168c54a 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -4,23 +4,25 @@ import ProgressButton from '../progress_button/progress_button.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
+import Select from '../select/select.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faRss,
- faChevronDown,
faSearchPlus,
- faExternalLinkAlt
+ faExternalLinkAlt,
+ faEdit
} from '@fortawesome/free-solid-svg-icons'
library.add(
faRss,
faBell,
- faChevronDown,
faSearchPlus,
- faExternalLinkAlt
+ faExternalLinkAlt,
+ faEdit
)
export default {
@@ -118,7 +120,9 @@ export default {
ModerationTools,
AccountActions,
ProgressButton,
- FollowButton
+ FollowButton,
+ Select,
+ RichContent
},
methods: {
muteUser () {
@@ -153,13 +157,16 @@ export default {
this.$store.state.instance.restrictedNicknames
)
},
+ openProfileTab () {
+ this.$store.dispatch('openSettingsModalTab', 'profile')
+ },
zoomAvatar () {
const attachment = {
url: this.user.profile_image_url_original,
mimetype: 'image'
}
this.$store.dispatch('setMedia', [attachment])
- this.$store.dispatch('setCurrent', attachment)
+ this.$store.dispatch('setCurrentMedia', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 78b0813f..2576bf87 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -38,22 +38,25 @@
</router-link>
<div class="user-summary">
<div class="top-line">
- <!-- eslint-disable vue/no-v-html -->
- <div
- v-if="user.name_html"
+ <RichContent
:title="user.name"
class="user-name"
- v-html="user.name_html"
+ :html="user.name"
+ :emoji="user.emoji"
/>
- <!-- eslint-enable vue/no-v-html -->
- <div
- v-else
- :title="user.name"
- class="user-name"
- >
- {{ user.name }}
- </div>
<button
+ v-if="!isOtherUser && user.is_local"
+ class="button-unstyled edit-profile-button"
+ @click.stop="openProfileTab"
+ >
+ <FAIcon
+ fixed-width
+ class="icon"
+ icon="edit"
+ :title="$t('user_card.edit_profile')"
+ />
+ </button>
+ <a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
@@ -80,6 +83,12 @@
</router-link>
<template v-if="!hideBio">
<span
+ v-if="user.deactivated"
+ class="alert user-role"
+ >
+ {{ $t('user_card.deactivated') }}
+ </span>
+ <span
v-if="!!visibleRole"
class="alert user-role"
>
@@ -132,25 +141,24 @@
class="userHighlightCl"
type="color"
>
- <label
- for="theme_tab"
- class="userHighlightSel select"
+ <Select
+ :id="'userHighlightSel'+user.id"
+ v-model="userHighlightType"
+ class="userHighlightSel"
>
- <select
- :id="'userHighlightSel'+user.id"
- v-model="userHighlightType"
- class="userHighlightSel"
- >
- <option value="disabled">{{ $t('user_card.highlight.disabled') }}</option>
- <option value="solid">{{ $t('user_card.highlight.solid') }}</option>
- <option value="striped">{{ $t('user_card.highlight.striped') }}</option>
- <option value="side">{{ $t('user_card.highlight.side') }}</option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
+ <option value="disabled">
+ {{ $t('user_card.highlight.disabled') }}
+ </option>
+ <option value="solid">
+ {{ $t('user_card.highlight.solid') }}
+ </option>
+ <option value="striped">
+ {{ $t('user_card.highlight.striped') }}
+ </option>
+ <option value="side">
+ {{ $t('user_card.highlight.side') }}
+ </option>
+ </Select>
</div>
</div>
<div
@@ -158,7 +166,10 @@
class="user-interactions"
>
<div class="btn-group">
- <FollowButton :relationship="relationship" />
+ <FollowButton
+ :relationship="relationship"
+ :user="user"
+ />
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.subscribing"
@@ -193,6 +204,7 @@
<button
v-if="relationship.muting"
class="btn button-default btn-block toggled"
+ :disabled="user.deactivated"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
@@ -200,6 +212,7 @@
<button
v-else
class="btn button-default btn-block"
+ :disabled="user.deactivated"
@click="muteUser"
>
{{ $t('user_card.mute') }}
@@ -208,6 +221,7 @@
<div>
<button
class="btn button-default btn-block"
+ :disabled="user.deactivated"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
@@ -256,20 +270,13 @@
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
- <!-- eslint-disable vue/no-v-html -->
- <p
- v-if="!hideBio && user.description_html"
+ <RichContent
+ v-if="!hideBio"
class="user-card-bio"
- @click.prevent="linkClicked"
- v-html="user.description_html"
+ :html="user.description_html"
+ :emoji="user.emoji"
+ :handle-links="true"
/>
- <!-- eslint-enable vue/no-v-html -->
- <p
- v-else-if="!hideBio"
- class="user-card-bio"
- >
- {{ user.description }}
- </p>
</div>
</div>
</template>
@@ -282,9 +289,10 @@
.user-card {
position: relative;
- &:hover .Avatar {
+ &:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
}
.panel-heading {
@@ -328,12 +336,12 @@
}
}
- p {
- margin-bottom: 0;
- }
-
&-bio {
text-align: center;
+ display: block;
+ line-height: 18px;
+ padding: 1em;
+ margin: 0;
a {
color: $fallback--link;
@@ -345,11 +353,6 @@
vertical-align: middle;
max-width: 100%;
max-height: 400px;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
}
}
@@ -427,7 +430,7 @@
}
}
- .external-link-button {
+ .external-link-button, .edit-profile-button {
cursor: pointer;
width: 2.5em;
text-align: center;
@@ -451,13 +454,6 @@
// big one
z-index: 1;
- img {
- width: 26px;
- height: 26px;
- vertical-align: middle;
- object-fit: contain
- }
-
.top-line {
display: flex;
}
@@ -470,12 +466,7 @@
margin-right: 1em;
font-size: 15px;
- img {
- object-fit: contain;
- height: 16px;
- width: 16px;
- vertical-align: middle;
- }
+ --emoji-size: 14px;
}
.bottom-line {
@@ -541,15 +532,11 @@
flex: 1 0 auto;
}
- .userHighlightSel,
- .userHighlightSel.select {
+ .userHighlightSel {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
- .userHighlightSel.select svg {
- line-height: 22px;
- }
.userHighlightText {
width: 70px;
@@ -558,9 +545,7 @@
.userHighlightCl,
.userHighlightText,
- .userHighlightSel,
- .userHighlightSel.select {
- height: 22px;
+ .userHighlightSel {
vertical-align: top;
margin-right: .5em;
margin-bottom: .25em;
@@ -585,6 +570,10 @@
}
}
+.sidebar .edit-profile-button {
+ display: none;
+}
+
.user-counts {
display: flex;
line-height:16px;
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index f4b93c9a..8706d0ff 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -22,7 +22,12 @@
/>
<div class="user-list-names">
<!-- eslint-disable vue/no-v-html -->
- <span v-html="user.name_html" />
+ <RichContent
+ class="username"
+ :title="'@'+user.screen_name_ui"
+ :html="user.name_html"
+ :emoji="user.emoji"
+ />
<!-- eslint-enable vue/no-v-html -->
<span class="user-list-screen-name">{{ user.screen_name_ui }}</span>
</div>
@@ -48,6 +53,8 @@
.user-list-popover {
padding: 0.5em;
+ --emoji-size: 16px;
+
.user-list-row {
padding: 0.25em;
display: flex;
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 8aa67765..eeb6ea40 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -164,7 +165,8 @@ const UserProfile = {
FriendList,
FollowCard,
TabSwitcher,
- Conversation
+ Conversation,
+ RichContent
}
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index aef897ae..726216ff 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -20,20 +20,24 @@
:key="index"
class="user-profile-field"
>
- <!-- eslint-disable vue/no-v-html -->
<dt
:title="user.fields_text[index].name"
class="user-profile-field-name"
- @click.prevent="linkClicked"
- v-html="field.name"
- />
+ >
+ <RichContent
+ :html="field.name"
+ :emoji="user.emoji"
+ />
+ </dt>
<dd
:title="user.fields_text[index].value"
class="user-profile-field-value"
- @click.prevent="linkClicked"
- v-html="field.value"
- />
- <!-- eslint-enable vue/no-v-html -->
+ >
+ <RichContent
+ :html="field.value"
+ :emoji="user.emoji"
+ />
+ </dd>
</dl>
</div>
<tab-switcher