aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/account_actions/account_actions.vue25
-rw-r--r--src/components/attachment/attachment.js114
-rw-r--r--src/components/attachment/attachment.scss268
-rw-r--r--src/components/attachment/attachment.vue524
-rw-r--r--src/components/basic_user_card/basic_user_card.js4
-rw-r--r--src/components/basic_user_card/basic_user_card.vue14
-rw-r--r--src/components/chat/chat.js10
-rw-r--r--src/components/chat/chat.scss6
-rw-r--r--src/components/chat_list/chat_list.vue5
-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.vue33
-rw-r--r--src/components/chat_message_date/chat_message_date.vue4
-rw-r--r--src/components/chat_title/chat_title.js2
-rw-r--r--src/components/chat_title/chat_title.vue18
-rw-r--r--src/components/conversation/conversation.vue1
-rw-r--r--src/components/desktop_nav/desktop_nav.vue1
-rw-r--r--src/components/domain_mute_card/domain_mute_card.vue4
-rw-r--r--src/components/emoji_input/emoji_input.js68
-rw-r--r--src/components/emoji_input/emoji_input.vue2
-rw-r--r--src/components/emoji_input/suggestor.js4
-rw-r--r--src/components/export_import/export_import.vue102
-rw-r--r--src/components/extra_buttons/extra_buttons.vue29
-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.js12
-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/global_notice_list/global_notice_list.vue8
-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.js38
-rw-r--r--src/components/media_modal/media_modal.vue160
-rw-r--r--src/components/mention_link/mention_link.js134
-rw-r--r--src/components/mention_link/mention_link.scss116
-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/mfa_form/recovery_form.vue2
-rw-r--r--src/components/mfa_form/totp_form.vue2
-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/moderation_tools/moderation_tools.js5
-rw-r--r--src/components/moderation_tools/moderation_tools.vue63
-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/nav_panel/nav_panel.js36
-rw-r--r--src/components/nav_panel/nav_panel.vue70
-rw-r--r--src/components/notification/notification.js4
-rw-r--r--src/components/notification/notification.scss13
-rw-r--r--src/components/notification/notification.vue26
-rw-r--r--src/components/notifications/notification_filters.vue122
-rw-r--r--src/components/notifications/notifications.js13
-rw-r--r--src/components/notifications/notifications.scss19
-rw-r--r--src/components/notifications/notifications.vue11
-rw-r--r--src/components/password_reset/password_reset.vue2
-rw-r--r--src/components/poll/poll.js10
-rw-r--r--src/components/poll/poll.vue21
-rw-r--r--src/components/poll/poll_form.js6
-rw-r--r--src/components/poll/poll_form.vue71
-rw-r--r--src/components/popover/popover.js23
-rw-r--r--src/components/popover/popover.vue38
-rw-r--r--src/components/post_status_form/post_status_form.js27
-rw-r--r--src/components/post_status_form/post_status_form.vue114
-rw-r--r--src/components/react_button/react_button.js6
-rw-r--r--src/components/react_button/react_button.vue129
-rw-r--r--src/components/registration/registration.js17
-rw-r--r--src/components/registration/registration.vue19
-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/scope_selector/scope_selector.vue4
-rw-r--r--src/components/search/search.vue1
-rw-r--r--src/components/search_bar/search_bar.vue3
-rw-r--r--src/components/select/select.js21
-rw-r--r--src/components/select/select.vue62
-rw-r--r--src/components/selectable_list/selectable_list.vue7
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.js38
-rw-r--r--src/components/settings_modal/helpers/boolean_setting.vue21
-rw-r--r--src/components/settings_modal/helpers/choice_setting.js39
-rw-r--r--src/components/settings_modal/helpers/choice_setting.vue29
-rw-r--r--src/components/settings_modal/helpers/modified_indicator.vue51
-rw-r--r--src/components/settings_modal/helpers/shared_computed_object.js22
-rw-r--r--src/components/settings_modal/settings_modal.js124
-rw-r--r--src/components/settings_modal/settings_modal.vue68
-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.vue207
-rw-r--r--src/components/settings_modal/tabs/general_tab.js26
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue324
-rw-r--r--src/components/settings_modal/tabs/mutes_and_blocks_tab.vue73
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue2
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js2
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue6
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.js3
-rw-r--r--src/components/settings_modal/tabs/security_tab/security_tab.vue6
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js67
-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.vue108
-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)20
-rw-r--r--src/components/shout_panel/shout_panel.vue (renamed from src/components/chat_panel/chat_panel.vue)66
-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/staff_panel/staff_panel.js20
-rw-r--r--src/components/staff_panel/staff_panel.vue27
-rw-r--r--src/components/status/status.js88
-rw-r--r--src/components/status/status.scss58
-rw-r--r--src/components/status/status.vue120
-rw-r--r--src/components/status_body/status_body.js129
-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.js153
-rw-r--r--src/components/status_content/status_content.vue301
-rw-r--r--src/components/status_popover/status_popover.vue8
-rw-r--r--src/components/still-image/still-image.js11
-rw-r--r--src/components/still-image/still-image.vue6
-rw-r--r--src/components/tab_switcher/tab_switcher.js4
-rw-r--r--src/components/timeago/timeago.vue6
-rw-r--r--src/components/timeline/timeline.js27
-rw-r--r--src/components/timeline/timeline.scss31
-rw-r--r--src/components/timeline/timeline.vue38
-rw-r--r--src/components/timeline/timeline_quick_settings.js60
-rw-r--r--src/components/timeline/timeline_quick_settings.vue102
-rw-r--r--src/components/timeline_menu/timeline_menu.js31
-rw-r--r--src/components/timeline_menu/timeline_menu.vue86
-rw-r--r--src/components/timeline_menu/timeline_menu_content.js29
-rw-r--r--src/components/timeline_menu/timeline_menu_content.vue66
-rw-r--r--src/components/user_avatar/user_avatar.vue4
-rw-r--r--src/components/user_card/user_card.js19
-rw-r--r--src/components/user_card/user_card.vue147
-rw-r--r--src/components/user_list_popover/user_list_popover.vue66
-rw-r--r--src/components/user_profile/user_profile.js4
-rw-r--r--src/components/user_profile/user_profile.vue30
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue7
148 files changed, 5063 insertions, 2388 deletions
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index ab5d1d29..1e31151c 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -6,10 +6,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <div
- slot="content"
- class="account-tools-popover"
- >
+ <template v-slot:content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
@@ -59,16 +56,15 @@
{{ $t('user_card.message') }}
</button>
</div>
- </div>
- <div
- slot="trigger"
- class="ellipsis-button"
- >
- <FAIcon
- class="icon"
- icon="ellipsis-v"
- />
- </div>
+ </template>
+ <template v-slot:trigger>
+ <button class="button-unstyled ellipsis-button">
+ <FAIcon
+ class="icon"
+ icon="ellipsis-v"
+ />
+ </button>
+ </template>
</Popover>
</div>
</template>
@@ -83,7 +79,6 @@
}
.ellipsis-button {
- cursor: pointer;
width: 2.5em;
margin: -0.5em 0;
padding: 0.5em 0;
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..59173759 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
@@ -13,310 +14,251 @@
:title="attachment.description"
>
<FAIcon :icon="placeholderIconClass" />
- <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
+ <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
</a>
- </div>
- <div
- v-else
- v-show="!isEmpty"
- class="attachment"
- :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
- >
- <a
- v-if="hidden"
- class="image-attachment"
- :href="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- @click.prevent.stop="toggleHidden"
+ <div
+ v-if="edit || remove"
+ class="attachment-buttons"
>
- <img
- :key="nsfwImage"
- class="nsfw"
- :src="nsfwImage"
- :class="{'small': isSmall}"
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ @click.prevent="onRemove"
>
- <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="trash-alt" />
+ </button>
+ </div>
+ <div
+ v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
+ class="description-container"
+ :class="{ '-static': !edit }"
>
- <FAIcon icon="times" />
- </button>
-
- <a
- v-if="type === 'image' && (!hidden || preloadImage)"
- class="image-attachment"
- :class="{'hidden': hidden && preloadImage }"
- :href="attachment.url"
- target="_blank"
- @click="openModal"
+ <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
+ class="Attachment"
+ :class="classNames"
+ >
+ <div
+ v-show="!isEmpty"
+ class="attachment-wrapper"
>
- <StillImage
- class="image"
- :referrerpolicy="referrerpolicy"
- :mimetype="attachment.mimetype"
- :src="attachment.large_thumb_url || attachment.url"
- :image-load-handler="onImageLoad"
+ <a
+ v-if="hidden"
+ class="image-container"
+ :href="attachment.url"
:alt="attachment.description"
- />
- </a>
+ :title="attachment.description"
+ @click.prevent.stop="toggleHidden"
+ >
+ <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"
+ @click.prevent="stopFlash"
+ :title="$t('status.attachment_stop_flash')"
+ >
+ <FAIcon icon="stop" />
+ </button>
+ <button
+ v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ @click.prevent="toggleDescription"
+ :title="$t('status.show_attachment_description')"
+ >
+ <FAIcon icon="align-right" />
+ </button>
+ <button
+ v-if="!useModal && type !== 'unknown'"
+ class="button-unstyled attachment-button"
+ @click.prevent="openModalForce"
+ :title="$t('status.show_attachment_in_modal')"
+ >
+ <FAIcon icon="search-plus" />
+ </button>
+ <button
+ v-if="nsfw && hideNsfwLocal"
+ class="button-unstyled attachment-button"
+ @click.prevent="toggleHidden"
+ :title="$t('status.hide_attachment')"
+ >
+ <FAIcon icon="times" />
+ </button>
+ <button
+ v-if="shiftUp"
+ class="button-unstyled attachment-button"
+ @click.prevent="onShiftUp"
+ :title="$t('status.move_up')"
+ >
+ <FAIcon icon="chevron-left" />
+ </button>
+ <button
+ v-if="shiftDn"
+ class="button-unstyled attachment-button"
+ @click.prevent="onShiftDn"
+ :title="$t('status.move_down')"
+ >
+ <FAIcon icon="chevron-right" />
+ </button>
+ <button
+ v-if="remove"
+ class="button-unstyled attachment-button"
+ @click.prevent="onRemove"
+ :title="$t('status.remove_attachment')"
+ >
+ <FAIcon icon="trash-alt" />
+ </button>
+ </div>
- <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 === '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 === 'unknown' && !hidden"
+ class="placeholder-container"
+ :href="attachment.url"
+ target="_blank"
+ >
+ <FAIcon size="5x" :icon="placeholderIconClass" />
+ <p>
+ {{ localDescription }}
+ </p>
+ </a>
+
+ <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>
- <audio
- v-if="type === 'audio'"
- :src="attachment.url"
- :alt="attachment.description"
- :title="attachment.description"
- controls
- @play="$emit('play')"
- @pause="$emit('pause')"
- />
+ <div
+ v-if="type === 'html' && attachment.oembed"
+ class="oembed-container"
+ @click.prevent="linkClicked"
+ >
+ <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="type === 'html' && attachment.oembed"
- class="oembed"
- @click.prevent="linkClicked"
+ v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
+ class="description-container"
+ :class="{ '-static': !edit }"
>
- <div
- v-if="attachment.thumb_url"
- class="image"
+ <input
+ v-if="edit"
+ v-model="localDescription"
+ type="text"
+ class="description-field"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
>
- <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>
+ <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 9e410610..53deb1df 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -25,24 +25,18 @@
: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
class="basic-user-card-screen-name"
:to="userProfileLink(user)"
>
- @{{ user.screen_name }}
+ @{{ user.screen_name_ui }}
</router-link>
</div>
<slot />
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index e57fcb91..b54f5fb2 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -73,7 +73,7 @@ const Chat = {
},
formPlaceholder () {
if (this.recipient) {
- return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
+ return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
} else {
return ''
}
@@ -234,6 +234,13 @@ const Chat = {
const scrollable = this.$refs.scrollable
return scrollable && scrollable.scrollTop <= 0
},
+ cullOlderCheck () {
+ window.setTimeout(() => {
+ if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+ this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
+ }
+ }, 5000)
+ },
handleScroll: _.throttle(function () {
if (!this.currentChat) { return }
@@ -241,6 +248,7 @@ const Chat = {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
+ this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index aef58495..3a26686c 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -98,10 +98,10 @@
.unread-message-count {
font-size: 0.8em;
left: 50%;
- transform: translate(-50%, 0);
- border-radius: 100%;
margin-top: -1rem;
- padding: 0;
+ padding: 0.1em;
+ border-radius: 50px;
+ position: absolute;
}
.chat-loading-error {
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
index e23eec13..f98b7ed2 100644
--- a/src/components/chat_list/chat_list.vue
+++ b/src/components/chat_list/chat_list.vue
@@ -23,10 +23,7 @@
class="timeline"
>
<List :items="sortedChatList">
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<ChatListItem
:key="item.id"
:compact="false"
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 0777f880..d62b831d 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -50,7 +50,7 @@
@show="menuOpened = true"
@close="menuOpened = false"
>
- <div slot="content">
+ <template v-slot:content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@@ -59,26 +59,29 @@
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
- </div>
- <button
- slot="trigger"
- class="button-default menu-icon"
- :title="$t('chats.more')"
- >
- <FAIcon icon="ellipsis-h" />
- </button>
+ </template>
+ <template v-slot:trigger>
+ <button
+ class="button-default menu-icon"
+ :title="$t('chats.more')"
+ >
+ <FAIcon icon="ellipsis-h" />
+ </button>
+ </template>
</Popover>
</div>
<StatusContent
+ class="message-content"
:status="messageForStatusContent"
:full-content="true"
>
- <span
- slot="footer"
- class="created-at"
- >
- {{ createdAt }}
- </span>
+ <template v-slot:footer>
+ <span
+ class="created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </template>
</StatusContent>
</div>
</div>
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
index 79c346b6..98349b75 100644
--- a/src/components/chat_message_date/chat_message_date.vue
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -5,6 +5,8 @@
</template>
<script>
+import localeService from 'src/services/locale/locale.service.js'
+
export default {
name: 'Timeago',
props: ['date'],
@@ -16,7 +18,7 @@ export default {
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
- return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
+ return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' })
}
}
}
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index e424bb1f..edfbe7a4 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -12,7 +12,7 @@ export default Vue.component('chat-title', {
],
computed: {
title () {
- return this.user ? this.user.screen_name : ''
+ return this.user ? this.user.screen_name_ui : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
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.vue b/src/components/conversation/conversation.vue
index 353859b8..3fb26d92 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -50,7 +50,6 @@
.Conversation {
.conversation-status {
- border-left: none;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
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/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue
index 3b5aec14..836688aa 100644
--- a/src/components/domain_mute_card/domain_mute_card.vue
+++ b/src/components/domain_mute_card/domain_mute_card.vue
@@ -9,7 +9,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.unmute') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
@@ -19,7 +19,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.mute') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 2068a598..902ec384 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -57,6 +57,7 @@ const EmojiInput = {
required: true,
type: Function
},
+ // TODO VUE3: change to modelValue, change 'input' event to 'input'
value: {
/**
* Used for v-model
@@ -143,32 +144,31 @@ const EmojiInput = {
}
},
mounted () {
- const slots = this.$slots.default
- if (!slots || slots.length === 0) return
- const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag))
+ const { root } = this.$refs
+ const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.resize()
- input.elm.addEventListener('blur', this.onBlur)
- input.elm.addEventListener('focus', this.onFocus)
- input.elm.addEventListener('paste', this.onPaste)
- input.elm.addEventListener('keyup', this.onKeyUp)
- input.elm.addEventListener('keydown', this.onKeyDown)
- input.elm.addEventListener('click', this.onClickInput)
- input.elm.addEventListener('transitionend', this.onTransition)
- input.elm.addEventListener('input', this.onInput)
+ input.addEventListener('blur', this.onBlur)
+ input.addEventListener('focus', this.onFocus)
+ input.addEventListener('paste', this.onPaste)
+ input.addEventListener('keyup', this.onKeyUp)
+ input.addEventListener('keydown', this.onKeyDown)
+ input.addEventListener('click', this.onClickInput)
+ input.addEventListener('transitionend', this.onTransition)
+ input.addEventListener('input', this.onInput)
},
unmounted () {
const { input } = this
if (input) {
- input.elm.removeEventListener('blur', this.onBlur)
- input.elm.removeEventListener('focus', this.onFocus)
- input.elm.removeEventListener('paste', this.onPaste)
- input.elm.removeEventListener('keyup', this.onKeyUp)
- input.elm.removeEventListener('keydown', this.onKeyDown)
- input.elm.removeEventListener('click', this.onClickInput)
- input.elm.removeEventListener('transitionend', this.onTransition)
- input.elm.removeEventListener('input', this.onInput)
+ input.removeEventListener('blur', this.onBlur)
+ input.removeEventListener('focus', this.onFocus)
+ input.removeEventListener('paste', this.onPaste)
+ input.removeEventListener('keyup', this.onKeyUp)
+ input.removeEventListener('keydown', this.onKeyDown)
+ input.removeEventListener('click', this.onClickInput)
+ input.removeEventListener('transitionend', this.onTransition)
+ input.removeEventListener('input', this.onInput)
}
},
watch: {
@@ -194,11 +194,18 @@ const EmojiInput = {
}
},
methods: {
+ focusPickerInput () {
+ const pickerEl = this.$refs.picker.$el
+ if (!pickerEl) return
+ const pickerInput = pickerEl.querySelector('input')
+ if (pickerInput) pickerInput.focus()
+ },
triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
+ this.focusPickerInput()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
@@ -209,11 +216,12 @@ const EmojiInput = {
}, 0)
},
togglePicker () {
- this.input.elm.focus()
+ this.input.focus()
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
+ this.$nextTick(this.focusPickerInput)
}
},
replace (replacement) {
@@ -254,13 +262,13 @@ const EmojiInput = {
this.$emit('input', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
- this.input.elm.focus()
+ this.input.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
- this.input.elm.setSelectionRange(position, position)
+ this.input.setSelectionRange(position, position)
this.caret = position
})
},
@@ -277,9 +285,9 @@ const EmojiInput = {
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
- this.input.elm.focus()
+ this.input.focus()
// Set selection right after the replacement instead of the very end
- this.input.elm.setSelectionRange(position, position)
+ this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
@@ -341,7 +349,7 @@ const EmojiInput = {
}
this.$nextTick(() => {
- const { offsetHeight } = this.input.elm
+ const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
@@ -406,8 +414,8 @@ const EmojiInput = {
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
- this.input.elm.blur()
- this.input.elm.focus()
+ this.input.blur()
+ this.input.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
@@ -436,7 +444,7 @@ const EmojiInput = {
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
- this.input.elm.focus()
+ this.input.focus()
}
}
@@ -472,7 +480,7 @@ const EmojiInput = {
if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
- const { offsetHeight, offsetTop } = this.input.elm
+ const { offsetHeight, offsetTop } = this.input
const offsetBottom = offsetTop + offsetHeight
this.setPlacement(panelBody, panel, offsetBottom)
@@ -486,7 +494,7 @@ const EmojiInput = {
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
- target.style.bottom = this.input.elm.offsetHeight + 'px'
+ target.style.bottom = this.input.offsetHeight + 'px'
}
},
overflowsBottom (el) {
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index 4becdc41..aa2950ce 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,5 +1,6 @@
<template>
<div
+ ref="root"
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
@@ -9,6 +10,7 @@
<button
v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon"
+ type="button"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'smile-beam']" />
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index 14a2b41e..e8efbd1e 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -116,8 +116,8 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
- }).map(({ screen_name, name, profile_image_url_original }) => ({
- displayText: screen_name,
+ }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
+ displayText: screen_name_ui,
detailText: name,
imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' '
diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue
deleted file mode 100644
index 8ffe34f8..00000000
--- a/src/components/export_import/export_import.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<template>
- <div class="import-export-container">
- <slot name="before" />
- <button
- class="btn button-default"
- @click="exportData"
- >
- {{ exportLabel }}
- </button>
- <button
- class="btn button-default"
- @click="importData"
- >
- {{ importLabel }}
- </button>
- <slot name="afterButtons" />
- <p
- v-if="importFailed"
- class="alert error"
- >
- {{ importFailedText }}
- </p>
- <slot name="afterError" />
- </div>
-</template>
-
-<script>
-export default {
- props: [
- 'exportObject',
- 'importLabel',
- 'exportLabel',
- 'importFailedText',
- 'validator',
- 'onImport',
- 'onImportFailure'
- ],
- data () {
- return {
- importFailed: false
- }
- },
- methods: {
- exportData () {
- const stringified = JSON.stringify(this.exportObject, null, 2) // Pretty-print and indent with 2 spaces
-
- // Create an invisible link with a data url and simulate a click
- const e = document.createElement('a')
- e.setAttribute('download', 'pleroma_theme.json')
- e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
- e.style.display = 'none'
-
- document.body.appendChild(e)
- e.click()
- document.body.removeChild(e)
- },
- importData () {
- this.importFailed = false
- const filePicker = document.createElement('input')
- filePicker.setAttribute('type', 'file')
- filePicker.setAttribute('accept', '.json')
-
- filePicker.addEventListener('change', event => {
- if (event.target.files[0]) {
- // eslint-disable-next-line no-undef
- const reader = new FileReader()
- reader.onload = ({ target }) => {
- try {
- const parsed = JSON.parse(target.result)
- const valid = this.validator(parsed)
- if (valid) {
- this.onImport(parsed)
- } else {
- this.importFailed = true
- // this.onImportFailure(valid)
- }
- } catch (e) {
- // This will happen both if there is a JSON syntax error or the theme is missing components
- this.importFailed = true
- // this.onImportFailure(e)
- }
- }
- reader.readAsText(event.target.files[0])
- }
- })
-
- document.body.appendChild(filePicker)
- filePicker.click()
- document.body.removeChild(filePicker)
- }
- }
-}
-</script>
-
-<style lang="scss">
-.import-export-container {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- justify-content: center;
-}
-</style>
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index e845d8fc..a3c3c767 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -7,10 +7,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
- <div
- slot="content"
- slot-scope="{close}"
- >
+ <template v-slot:content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
@@ -120,16 +117,15 @@
/><span>{{ $t("user_card.report") }}</span>
</button>
</div>
- </div>
- <span
- slot="trigger"
- class="popover-trigger"
- >
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- icon="ellipsis-h"
- />
- </span>
+ </template>
+ <template v-slot:trigger>
+ <button class="button-unstyled popover-trigger">
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ icon="ellipsis-h"
+ />
+ </button>
+ </template>
</Popover>
</template>
@@ -139,6 +135,11 @@
@import '../../_variables.scss';
.ExtraButtons {
+ /* override of popover internal stuff */
+ .popover-trigger-button {
+ width: auto;
+ }
+
.popover-trigger {
position: static;
padding: 10px;
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 6274780b..137ef9c0 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,14 +1,10 @@
import { set } from 'vue'
-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
+ },
props: [
'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
],
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index dd117ec0..29605084 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 f856fd0a..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.$set(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..f9cad8a9 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"
+ :shiftUp="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment"
+ :shiftDn="!(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/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
index 049e23db..a45f4586 100644
--- a/src/components/global_notice_list/global_notice_list.vue
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -71,6 +71,14 @@
}
}
+ .global-success {
+ background-color: var(--alertPopupSuccess, $fallback--cGreen);
+ color: var(--alertPopupSuccessText, $fallback--text);
+ .svg-inline--fa {
+ color: var(--alertPopupSuccessText, $fallback--text);
+ }
+ }
+
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
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 e7384c93..b8bce730 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -3,22 +3,31 @@ 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 GestureService from '../../services/gesture_service/gesture_service'
+import Flash from 'src/components/flash/flash.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
- faChevronRight
+ faChevronRight,
+ faCircleNotch
)
const MediaModal = {
components: {
StillImage,
VideoAttachment,
- Modal
+ Modal,
+ Flash
+ },
+ data () {
+ return {
+ loading: false
+ }
},
computed: {
showing () {
@@ -27,6 +36,9 @@ const MediaModal = {
media () {
return this.$store.state.mediaViewer.media
},
+ description () {
+ return this.currentMedia.description
+ },
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
@@ -37,7 +49,7 @@ 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 () {
@@ -53,6 +65,9 @@ const MediaModal = {
)
},
methods: {
+ getType (media) {
+ return fileTypeService.fileType(media.mimetype)
+ },
mediaTouchStart (e) {
GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
@@ -67,15 +82,26 @@ const MediaModal = {
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
+ },
handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape
this.hide()
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index ea7f7a7f..8680267b 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -6,6 +6,7 @@
>
<img
v-if="type === 'image'"
+ :class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
@@ -13,6 +14,7 @@
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
+ @load="onImageLoaded"
>
<VideoAttachment
v-if="type === 'video'"
@@ -28,6 +30,13 @@
: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')"
@@ -50,6 +59,27 @@
icon="chevron-right"
/>
</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>
@@ -58,6 +88,7 @@
<style lang="scss">
.modal-view.media-modal-view {
z-index: 1001;
+ flex-direction: column;
.modal-view-button-arrow {
opacity: 0.75;
@@ -67,59 +98,108 @@
outline: none;
box-shadow: none;
}
+
&:hover {
opacity: 1;
}
}
}
-.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
-}
+.media-modal-view {
+ @keyframes media-fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
-.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);
-
- .arrow-icon {
- 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);
+ .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;
}
- &--prev {
- left: 0;
- .arrow-icon {
- left: 6px;
+ .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: 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;
+
+ &.loading {
+ opacity: 0.5;
+ }
+ }
+
+ .loading-spinner {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ pointer-events: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ svg {
+ color: white;
}
}
- &--next {
- right: 0;
+ .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);
+
.arrow-icon {
- right: 6px;
+ 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);
+ }
+
+ &--prev {
+ left: 0;
+ .arrow-icon {
+ left: 6px;
+ }
+ }
+
+ &--next {
+ right: 0;
+ .arrow-icon {
+ right: 6px;
+ }
}
}
}
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..a4326296
--- /dev/null
+++ b/src/components/mention_link/mention_link.scss
@@ -0,0 +1,116 @@
+@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;
+ height: 50%;
+ 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/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue
index 0bf68e27..7c594228 100644
--- a/src/components/mfa_form/recovery_form.vue
+++ b/src/components/mfa_form/recovery_form.vue
@@ -25,6 +25,7 @@
<div>
<button
class="button-unstyled -link"
+ type="button"
@click.prevent="requireTOTP"
>
{{ $t('login.enter_two_factor_code') }}
@@ -32,6 +33,7 @@
<br>
<button
class="button-unstyled -link"
+ type="button"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue
index 79230148..4ee13992 100644
--- a/src/components/mfa_form/totp_form.vue
+++ b/src/components/mfa_form/totp_form.vue
@@ -27,6 +27,7 @@
<div>
<button
class="button-unstyled -link"
+ type="button"
@click.prevent="requireRecovery"
>
{{ $t('login.enter_recovery_code') }}
@@ -34,6 +35,7 @@
<br>
<button
class="button-unstyled -link"
+ type="button"
@click.prevent="abortMFA"
>
{{ $t('general.cancel') }}
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 366ea89c..d27fb3b8 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/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index d4fdc53e..2469327a 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -1,6 +1,11 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
+
import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popover from '../popover/popover.vue'
+library.add(faChevronDown)
+
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip'
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue
index 5c7b82ec..96476abe 100644
--- a/src/components/moderation_tools/moderation_tools.vue
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -8,7 +8,7 @@
@show="setToggled(true)"
@close="setToggled(false)"
>
- <div slot="content">
+ <template v-slot:content>
<div class="dropdown-menu">
<span v-if="user.is_local">
<button
@@ -50,96 +50,98 @@
class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)"
>
- {{ $t('user_card.admin_menu.force_nsfw') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
/>
+ {{ $t('user_card.admin_menu.force_nsfw') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleTag(tags.STRIP_MEDIA)"
>
- {{ $t('user_card.admin_menu.strip_media') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
/>
+ {{ $t('user_card.admin_menu.strip_media') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_UNLISTED)"
>
- {{ $t('user_card.admin_menu.force_unlisted') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
/>
+ {{ $t('user_card.admin_menu.force_unlisted') }}
</button>
<button
class="button-default dropdown-item"
@click="toggleTag(tags.SANDBOX)"
>
- {{ $t('user_card.admin_menu.sandbox') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
/>
+ {{ $t('user_card.admin_menu.sandbox') }}
</button>
<button
v-if="user.is_local"
class="button-default dropdown-item"
@click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)"
>
- {{ $t('user_card.admin_menu.disable_remote_subscription') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"
/>
+ {{ $t('user_card.admin_menu.disable_remote_subscription') }}
</button>
<button
v-if="user.is_local"
class="button-default dropdown-item"
@click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)"
>
- {{ $t('user_card.admin_menu.disable_any_subscription') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"
/>
+ {{ $t('user_card.admin_menu.disable_any_subscription') }}
</button>
<button
v-if="user.is_local"
class="button-default dropdown-item"
@click="toggleTag(tags.QUARANTINE)"
>
- {{ $t('user_card.admin_menu.quarantine') }}
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"
/>
+ {{ $t('user_card.admin_menu.quarantine') }}
</button>
</span>
</div>
- </div>
- <button
- slot="trigger"
- class="btn button-default btn-block"
- :class="{ toggled }"
- >
- {{ $t('user_card.admin_menu.moderation') }}
- </button>
+ </template>
+ <template v-slot:trigger>
+ <button
+ class="btn button-default btn-block moderation-tools-button"
+ :class="{ toggled }"
+ >
+ {{ $t('user_card.admin_menu.moderation') }}
+ <FAIcon icon="chevron-down" />
+ </button>
+ </template>
</Popover>
<portal to="modal">
<DialogModal
v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)"
>
- <template slot="header">
+ <template v-slot:header>
{{ $t('user_card.admin_menu.delete_user') }}
</template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
- <template slot="footer">
+ <template v-slot:footer>
<button
class="btn button-default"
@click="deleteUserDialog(false)"
@@ -163,25 +165,6 @@
<style lang="scss">
@import '../../_variables.scss';
-.menu-checkbox {
- float: right;
- min-width: 22px;
- max-width: 22px;
- min-height: 22px;
- max-height: 22px;
- line-height: 22px;
- text-align: center;
- border-radius: 0px;
- background-color: $fallback--fg;
- background-color: var(--input, $fallback--fg);
- box-shadow: 0px 0px 2px black inset;
- box-shadow: var(--inputShadow);
-
- &.menu-checkbox-checked::after {
- content: '✓';
- }
-}
-
.moderation-tools-popover {
height: 100%;
.trigger {
@@ -189,4 +172,10 @@
height: 100%;
}
}
+
+.moderation-tools-button {
+ svg,i {
+ font-size: 0.8em;
+ }
+}
</style>
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/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 81d49cc2..37bcb409 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,4 @@
-import { timelineNames } from '../timeline_menu/timeline_menu.js'
+import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
import { mapState, mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -7,10 +7,12 @@ import {
faGlobe,
faBookmark,
faEnvelope,
- faHome,
+ faChevronDown,
+ faChevronUp,
faComments,
faBell,
- faInfoCircle
+ faInfoCircle,
+ faStream
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -18,10 +20,12 @@ library.add(
faGlobe,
faBookmark,
faEnvelope,
- faHome,
+ faChevronDown,
+ faChevronUp,
faComments,
faBell,
- faInfoCircle
+ faInfoCircle,
+ faStream
)
const NavPanel = {
@@ -30,16 +34,20 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests')
}
},
+ components: {
+ TimelineMenuContent
+ },
+ data () {
+ return {
+ showTimelines: false
+ }
+ },
+ methods: {
+ toggleTimelines () {
+ this.showTimelines = !this.showTimelines
+ }
+ },
computed: {
- onTimelineRoute () {
- return !!timelineNames()[this.$route.name]
- },
- timelinesRoute () {
- if (this.$store.state.interface.lastTimeline) {
- return this.$store.state.interface.lastTimeline
- }
- return this.currentUser ? 'friends' : 'public-timeline'
- },
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 0c83d0fe..7ae7b1d6 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -3,19 +3,33 @@
<div class="panel panel-default">
<ul>
<li v-if="currentUser || !privateMode">
- <router-link
- :to="{ name: timelinesRoute }"
- :class="onTimelineRoute && 'router-link-active'"
+ <button
+ class="button-unstyled menu-item"
+ @click="toggleTimelines"
>
<FAIcon
fixed-width
class="fa-scale-110"
- icon="home"
+ icon="stream"
/>{{ $t("nav.timelines") }}
- </router-link>
+ <FAIcon
+ class="timelines-chevron"
+ fixed-width
+ :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+ />
+ </button>
+ <div
+ v-show="showTimelines"
+ class="timelines-background"
+ >
+ <TimelineMenuContent class="timelines" />
+ </div>
</li>
<li v-if="currentUser">
- <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
+ >
<FAIcon
fixed-width
class="fa-scale-110"
@@ -24,7 +38,10 @@
</router-link>
</li>
<li v-if="currentUser && pleromaChatMessagesAvailable">
- <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
+ >
<div
v-if="unreadChatCount"
class="badge badge-notification"
@@ -39,7 +56,10 @@
</router-link>
</li>
<li v-if="currentUser && currentUser.locked">
- <router-link :to="{ name: 'friend-requests' }">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'friend-requests' }"
+ >
<FAIcon
fixed-width
class="fa-scale-110"
@@ -54,7 +74,10 @@
</router-link>
</li>
<li>
- <router-link :to="{ name: 'about' }">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'about' }"
+ >
<FAIcon
fixed-width
class="fa-scale-110"
@@ -91,14 +114,14 @@
border-color: var(--border, $fallback--border);
padding: 0;
- &:first-child a {
+ &:first-child .menu-item {
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
}
- &:last-child a {
+ &:last-child .menu-item {
border-bottom-right-radius: $fallback--panelRadius;
border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
border-bottom-left-radius: $fallback--panelRadius;
@@ -110,13 +133,15 @@
border: none;
}
- a {
+ .menu-item {
display: block;
box-sizing: border-box;
- align-items: stretch;
height: 3.5em;
line-height: 3.5em;
padding: 0 1em;
+ width: 100%;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
&:hover {
background-color: $fallback--lightBg;
@@ -146,6 +171,25 @@
}
}
+ .timelines-chevron {
+ margin-left: 0.8em;
+ font-size: 1.1em;
+ }
+
+ .timelines-background {
+ padding: 0 0 0 0.6em;
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ border-top: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ }
+
+ .timelines {
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+ }
+
.fa-scale-110 {
margin-right: 0.8em;
}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 1634c035..8f74c0e6 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import Report from '../report/report.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'
@@ -46,7 +47,8 @@ const Notification = {
UserCard,
Timeago,
Status,
- Report
+ Report,
+ 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 40ea0431..e10eff00 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"
/>
@@ -11,7 +12,7 @@
>
<small>
<router-link :to="userProfileLink">
- {{ notification.from_profile.screen_name }}
+ {{ notification.from_profile.screen_name_ui }}
</router-link>
</small>
<button
@@ -51,17 +52,19 @@
<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"
- 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
class="username"
- :title="'@'+notification.from_profile.screen_name"
+ :title="'@'+notification.from_profile.screen_name_ui"
>{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'">
<FAIcon
@@ -155,7 +158,7 @@
:to="userProfileLink"
class="follow-name"
>
- @{{ notification.from_profile.screen_name }}
+ @{{ notification.from_profile.screen_name_ui }}
</router-link>
<div
v-if="notification.type === 'follow_request'"
@@ -180,7 +183,7 @@
class="move-text"
>
<router-link :to="targetUserProfileLink">
- @{{ notification.target.screen_name }}
+ @{{ notification.target.screen_name_ui }}
</router-link>
</div>
<Report
@@ -188,8 +191,9 @@
:report-id="notification.report.id"
/>
<template v-else>
- <status-content
+ <StatusContent
class="faint"
+ :compact="true"
:status="notification.action"
/>
</template>
diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue
new file mode 100644
index 00000000..ba0e90a0
--- /dev/null
+++ b/src/components/notifications/notification_filters.vue
@@ -0,0 +1,122 @@
+<template>
+ <Popover
+ trigger="click"
+ class="NotificationFilters"
+ placement="bottom"
+ :bound-to="{ x: 'container' }"
+ >
+ <template v-slot:content>
+ <div class="dropdown-menu">
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('likes')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.likes }"
+ />{{ $t('settings.notification_visibility_likes') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('repeats')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.repeats }"
+ />{{ $t('settings.notification_visibility_repeats') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('follows')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.follows }"
+ />{{ $t('settings.notification_visibility_follows') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('mentions')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.mentions }"
+ />{{ $t('settings.notification_visibility_mentions') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('emojiReactions')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.emojiReactions }"
+ />{{ $t('settings.notification_visibility_emoji_reactions') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="toggleNotificationFilter('moves')"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': filters.moves }"
+ />{{ $t('settings.notification_visibility_moves') }}
+ </button>
+ </div>
+ </template>
+ <template v-slot:trigger>
+ <button class="button-unstyled">
+ <FAIcon icon="filter" />
+ </button>
+ </template>
+ </Popover>
+</template>
+
+<script>
+import Popover from '../popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faFilter } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faFilter
+)
+
+export default {
+ components: { Popover },
+ computed: {
+ filters () {
+ return this.$store.getters.mergedConfig.notificationVisibility
+ }
+ },
+ methods: {
+ toggleNotificationFilter (type) {
+ this.$store.dispatch('setOption', {
+ name: 'notificationVisibility',
+ value: {
+ ...this.filters,
+ [type]: !this.filters[type]
+ }
+ })
+ }
+ }
+}
+</script>
+
+<style lang="scss">
+
+.NotificationFilters {
+ align-self: stretch;
+
+ > button {
+ font-size: 1.2em;
+ padding-left: 0.7em;
+ padding-right: 0.2em;
+ line-height: 100%;
+ height: 100%;
+ }
+
+ .dropdown-item {
+ margin: 0;
+ }
+}
+
+</style>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 49258563..c8f1ebcb 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,5 +1,6 @@
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue'
+import NotificationFilters from './notification_filters.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
notificationsFromStore,
@@ -17,6 +18,10 @@ library.add(
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
const Notifications = {
+ components: {
+ Notification,
+ NotificationFilters
+ },
props: {
// Disables display of panel header
noHeading: Boolean,
@@ -35,11 +40,6 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
- created () {
- const store = this.$store
- const credentials = store.state.users.currentUser.credentials
- notificationsFetcher.fetchAndUpdate({ store, credentials })
- },
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@@ -70,9 +70,6 @@ const Notifications = {
},
...mapGetters(['unreadChatCount'])
},
- components: {
- Notification
- },
watch: {
unseenCountTitle (count) {
if (count > 0) {
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 58a86fb0..47c035f1 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -1,6 +1,6 @@
@import '../../_variables.scss';
-.notifications {
+.Notifications {
&:not(.minimal) {
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
@@ -11,6 +11,10 @@
color: var(--text, $fallback--text);
}
+ .notifications-footer {
+ border: none;
+ }
+
.notification {
position: relative;
@@ -33,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 {
@@ -84,7 +83,6 @@
}
}
-
.follow-text, .move-text {
padding: 0.5em 0;
overflow-wrap: break-word;
@@ -147,13 +145,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/notifications/notifications.vue b/src/components/notifications/notifications.vue
index 725d1ad4..2ce5d56f 100644
--- a/src/components/notifications/notifications.vue
+++ b/src/components/notifications/notifications.vue
@@ -1,7 +1,7 @@
<template>
<div
:class="{ minimal: minimalMode }"
- class="notifications"
+ class="Notifications"
>
<div :class="mainClass">
<div
@@ -22,6 +22,7 @@
>
{{ $t('notifications.read') }}
</button>
+ <NotificationFilters />
</div>
<div class="panel-body">
<div
@@ -34,10 +35,10 @@
<notification :notification="notification" />
</div>
</div>
- <div class="panel-footer">
+ <div class="panel-footer notifications-footer">
<div
v-if="bottomedOut"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('notifications.no_more_notifications') }}
</div>
@@ -46,13 +47,13 @@
class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderNotifications()"
>
- <div class="new-status-notification text-center panel-footer">
+ <div class="new-status-notification text-center">
{{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
</div>
</button>
<div
v-else
- class="new-status-notification text-center panel-footer"
+ class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue
index a931cb5a..3ffa5425 100644
--- a/src/components/password_reset/password_reset.vue
+++ b/src/components/password_reset/password_reset.vue
@@ -53,7 +53,7 @@
type="submit"
class="btn button-default btn-block"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
</div>
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index 98db5582..a69b7886 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 42819c19..63b44e4f 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>
@@ -58,7 +64,12 @@
{{ $t('polls.vote') }}
</button>
<div class="total">
- {{ totalVotesCount }} {{ $t("polls.votes") }}&nbsp;·&nbsp;
+ <template v-if="typeof poll.voters_count === 'number'">
+ {{ $tc("polls.people_voted_count", poll.voters_count, { count: poll.voters_count }) }}&nbsp;·&nbsp;
+ </template>
+ <template v-else>
+ {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }}&nbsp;·&nbsp;
+ </template>
</div>
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
<Timeago
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 09496105..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,9 @@
.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;
}
}
@@ -161,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.js b/src/components/popover/popover.js
index 5e417fa0..6ccf32f0 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -3,25 +3,32 @@ const Popover = {
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
+
// Either 'top' or 'bottom'
placement: String,
+
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
+
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
+
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
+
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
+
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
+
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean
@@ -47,8 +54,11 @@ const Popover = {
}
// Popover will be anchored around this element, trigger ref is the container, so
- // its children are what are inside the slot. Expect only one slot="trigger".
+ // its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
+ // SVGs don't have offsetWidth/Height, use fallback
+ const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
+ const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const screenBox = anchorEl.getBoundingClientRect()
// Screen position of the origin point for popover
const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
@@ -107,11 +117,11 @@ const Popover = {
const yOffset = (this.offset && this.offset.y) || 0
const translateY = usingTop
- ? -anchorEl.offsetHeight + vPadding - yOffset - content.offsetHeight
+ ? -anchorHeight + vPadding - yOffset - content.offsetHeight
: yOffset
const xOffset = (this.offset && this.offset.x) || 0
- const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset
+ const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
// Note, separate translateX and translateY avoids blurry text on chromium,
// single translate or translate3d resulted in blurry text.
@@ -121,9 +131,12 @@ const Popover = {
}
},
showPopover () {
- if (this.hidden) this.$emit('show')
+ const wasHidden = this.hidden
this.hidden = false
- this.$nextTick(this.updateStyles)
+ this.$nextTick(() => {
+ if (wasHidden) this.$emit('show')
+ this.updateStyles()
+ })
},
hidePopover () {
if (!this.hidden) this.$emit('close')
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index 2252c68f..8588b351 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -6,6 +6,7 @@
<button
ref="trigger"
class="button-unstyled -fullwidth popover-trigger-button"
+ type="button"
@click="onClick"
>
<slot name="trigger" />
@@ -32,7 +33,7 @@
@import '../../_variables.scss';
.popover-trigger-button {
- display: block;
+ display: inline-block;
}
.popover {
@@ -81,10 +82,9 @@
.dropdown-item {
line-height: 21px;
- margin-right: 5px;
overflow: auto;
display: block;
- padding: .25rem 1.0rem .25rem 1.5rem;
+ padding: .5em 0.75em;
clear: both;
font-weight: 400;
text-align: inherit;
@@ -100,10 +100,9 @@
--btnText: var(--popoverText, $fallback--text);
&-icon {
- padding-left: 0.5rem;
-
svg {
- margin-right: 0.25rem;
+ width: 22px;
+ margin-right: 0.75rem;
color: var(--menuPopoverIcon, $fallback--icon)
}
}
@@ -122,6 +121,33 @@
}
}
+ .menu-checkbox {
+ display: inline-block;
+ vertical-align: middle;
+ min-width: 22px;
+ max-width: 22px;
+ min-height: 22px;
+ max-height: 22px;
+ line-height: 22px;
+ text-align: center;
+ border-radius: 0px;
+ background-color: $fallback--fg;
+ background-color: var(--input, $fallback--fg);
+ box-shadow: 0px 0px 2px black inset;
+ box-shadow: var(--inputShadow);
+ margin-right: 0.75em;
+
+ &.menu-checkbox-checked::after {
+ font-size: 1.25em;
+ content: '✓';
+ }
+
+ &.menu-checkbox-radio::after {
+ font-size: 2em;
+ content: '•';
+ }
+ }
+
}
}
</style>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 4148381c..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()
@@ -115,7 +117,7 @@ const PostStatusForm = {
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
- const { postContentType: contentType } = this.$store.getters.mergedConfig
+ const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
return {
dropFiles: [],
@@ -126,7 +128,7 @@ const PostStatusForm = {
newStatus: {
spoilerText: this.subject || '',
status: statusText,
- nsfw: false,
+ nsfw: !!sensitiveByDefault,
files: [],
poll: {},
mediaDescriptions: {},
@@ -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 73f6a4f1..2e0980a2 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'"
@@ -272,7 +263,7 @@
disabled
class="btn button-default"
>
- {{ $t('general.submit') }}
+ {{ $t('post_status.post') }}
</button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<button
@@ -282,7 +273,7 @@
@touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
>
- {{ $t('general.submit') }}
+ {{ $t('post_status.post') }}
</button>
</div>
<div
@@ -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/react_button/react_button.js b/src/components/react_button/react_button.js
index 5e7b7580..ce82c90d 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -23,6 +23,12 @@ const ReactButton = {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
close()
+ },
+ focusInput () {
+ this.$nextTick(() => {
+ const input = this.$el.querySelector('input')
+ if (input) input.focus()
+ })
}
},
computed: {
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index ac940b98..c69c315b 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -1,15 +1,14 @@
<template>
<Popover
trigger="click"
+ class="ReactButton"
placement="top"
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
+ @show="focusInput"
>
- <div
- slot="content"
- slot-scope="{close}"
- >
+ <template v-slot:content="{close}">
<div class="reaction-picker-filter">
<input
v-model="filterWord"
@@ -39,17 +38,18 @@
</span>
<div class="reaction-bottom-fader" />
</div>
- </div>
- <span
- slot="trigger"
- class="ReactButton"
- :title="$t('tool_tip.add_reaction')"
- >
- <FAIcon
- class="fa-scale-110 fa-old-padding"
- :icon="['far', 'smile-beam']"
- />
- </span>
+ </template>
+ <template v-slot:trigger>
+ <button
+ class="button-unstyled popover-trigger"
+ :title="$t('tool_tip.add_reaction')"
+ >
+ <FAIcon
+ class="fa-scale-110 fa-old-padding"
+ :icon="['far', 'smile-beam']"
+ />
+ </button>
+ </template>
</Popover>
</template>
@@ -58,62 +58,71 @@
<style lang="scss">
@import '../../_variables.scss';
-.reaction-picker-filter {
- padding: 0.5em;
- display: flex;
- input {
- flex: 1;
+.ReactButton {
+ .reaction-picker-filter {
+ padding: 0.5em;
+ display: flex;
+
+ input {
+ flex: 1;
+ }
}
-}
-.reaction-picker-divider {
- height: 1px;
- width: 100%;
- margin: 0.5em;
- background-color: var(--border, $fallback--border);
-}
+ .reaction-picker-divider {
+ height: 1px;
+ width: 100%;
+ margin: 0.5em;
+ background-color: var(--border, $fallback--border);
+ }
-.reaction-picker {
- width: 10em;
- height: 9em;
- font-size: 1.5em;
- overflow-y: scroll;
- display: flex;
- flex-wrap: wrap;
- padding: 0.5em;
- text-align: center;
- align-content: flex-start;
- user-select: none;
+ .reaction-picker {
+ width: 10em;
+ height: 9em;
+ font-size: 1.5em;
+ overflow-y: scroll;
+ display: flex;
+ flex-wrap: wrap;
+ padding: 0.5em;
+ text-align: center;
+ align-content: flex-start;
+ user-select: none;
- mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
- linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
- linear-gradient(to top, white, white);
- transition: mask-size 150ms;
- mask-size: 100% 20px, 100% 20px, auto;
- // Autoprefixed seem to ignore this one, and also syntax is different
- -webkit-mask-composite: xor;
- mask-composite: exclude;
+ mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
+ linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
+ linear-gradient(to top, white, white);
+ transition: mask-size 150ms;
+ mask-size: 100% 20px, 100% 20px, auto;
- .emoji-button {
- cursor: pointer;
+ /* Autoprefixed seem to ignore this one, and also syntax is different */
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
- flex-basis: 20%;
- line-height: 1.5em;
- align-content: center;
+ .emoji-button {
+ cursor: pointer;
- &:hover {
- transform: scale(1.25);
+ flex-basis: 20%;
+ line-height: 1.5em;
+ align-content: center;
+
+ &:hover {
+ transform: scale(1.25);
+ }
}
}
-}
-.ReactButton {
- padding: 10px;
- margin: -10px;
+ /* override of popover internal stuff */
+ .popover-trigger-button {
+ width: auto;
+ }
+
+ .popover-trigger {
+ padding: 10px;
+ margin: -10px;
- &:hover .svg-inline--fa {
- color: $fallback--text;
- color: var(--text, $fallback--text);
+ &:hover .svg-inline--fa {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
}
}
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index dab06e1e..1ac8e8be 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -10,7 +10,8 @@ const registration = {
fullname: '',
username: '',
password: '',
- confirm: ''
+ confirm: '',
+ reason: ''
},
captcha: {}
}),
@@ -24,7 +25,8 @@ const registration = {
confirm: {
required,
sameAsPassword: sameAs('password')
- }
+ },
+ reason: { required: requiredIf(() => this.accountApprovalRequired) }
}
}
},
@@ -38,7 +40,10 @@ const registration = {
computed: {
token () { return this.$route.params.token },
bioPlaceholder () {
- return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n')
+ return this.replaceNewlines(this.$t('registration.bio_placeholder'))
+ },
+ reasonPlaceholder () {
+ return this.replaceNewlines(this.$t('registration.reason_placeholder'))
},
...mapState({
registrationOpen: (state) => state.instance.registrationOpen,
@@ -46,7 +51,8 @@ const registration = {
isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors,
termsOfService: (state) => state.instance.tos,
- accountActivationRequired: (state) => state.instance.accountActivationRequired
+ accountActivationRequired: (state) => state.instance.accountActivationRequired,
+ accountApprovalRequired: (state) => state.instance.accountApprovalRequired
})
},
methods: {
@@ -73,6 +79,9 @@ const registration = {
},
setCaptcha () {
this.getCaptcha().then(cpt => { this.captcha = cpt })
+ },
+ replaceNewlines (str) {
+ return str.replace(/\s*\n\s*/g, ' \n')
}
}
}
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index 100df0d6..65b4bb33 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -163,6 +163,23 @@
</div>
<div
+ v-if="accountApprovalRequired"
+ class="form-group"
+ >
+ <label
+ class="form--label"
+ for="reason"
+ >{{ $t('registration.reason') }}</label>
+ <textarea
+ id="reason"
+ v-model="user.reason"
+ :disabled="isPending"
+ class="form-control"
+ :placeholder="reasonPlaceholder"
+ />
+ </div>
+
+ <div
v-if="captcha.type != 'none'"
id="captcha-group"
class="form-group"
@@ -213,7 +230,7 @@
type="submit"
class="btn button-default"
>
- {{ $t('general.submit') }}
+ {{ $t('registration.register') }}
</button>
</div>
</div>
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/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue
index 66ac612e..a01242fc 100644
--- a/src/components/scope_selector/scope_selector.vue
+++ b/src/components/scope_selector/scope_selector.vue
@@ -8,6 +8,7 @@
class="button-unstyled scope"
:class="css.direct"
:title="$t('post_status.scope.direct')"
+ type="button"
@click="changeVis('direct')"
>
<FAIcon
@@ -20,6 +21,7 @@
class="button-unstyled scope"
:class="css.private"
:title="$t('post_status.scope.private')"
+ type="button"
@click="changeVis('private')"
>
<FAIcon
@@ -32,6 +34,7 @@
class="button-unstyled scope"
:class="css.unlisted"
:title="$t('post_status.scope.unlisted')"
+ type="button"
@click="changeVis('unlisted')"
>
<FAIcon
@@ -44,6 +47,7 @@
class="button-unstyled scope"
:class="css.public"
:title="$t('post_status.scope.public')"
+ type="button"
@click="changeVis('public')"
>
<FAIcon
diff --git a/src/components/search/search.vue b/src/components/search/search.vue
index a6503c9f..b7bfc1f3 100644
--- a/src/components/search/search.vue
+++ b/src/components/search/search.vue
@@ -15,6 +15,7 @@
>
<button
class="btn button-default search-button"
+ type="submit"
@click="newQuery(searchTerm)"
>
<FAIcon icon="search" />
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 6cf9179e..222f57ba 100644
--- a/src/components/search_bar/search_bar.vue
+++ b/src/components/search_bar/search_bar.vue
@@ -7,6 +7,7 @@
v-if="hidden"
class="button-unstyled nav-icon"
:title="$t('nav.search')"
+ type="button"
@click.prevent.stop="toggleHidden"
>
<FAIcon
@@ -27,6 +28,7 @@
>
<button
class="button-default search-button"
+ type="submit"
@click="find(searchTerm)"
>
<FAIcon
@@ -36,6 +38,7 @@
</button>
<button
class="button-unstyled cancel-search"
+ type="button"
@click.prevent.stop="toggleHidden"
>
<FAIcon
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..5ade1fa6
--- /dev/null
+++ b/src/components/select/select.vue
@@ -0,0 +1,62 @@
+
+<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%;
+ color: $fallback--text;
+ color: var(--inputText, $fallback--text);
+ line-height: 28px;
+ z-index: 0;
+ pointer-events: none;
+ }
+
+}
+</style>
diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue
index a9bb12a1..3f885881 100644
--- a/src/components/selectable_list/selectable_list.vue
+++ b/src/components/selectable_list/selectable_list.vue
@@ -24,10 +24,7 @@
:items="items"
:get-key="getKey"
>
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<div
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
@@ -44,7 +41,7 @@
/>
</div>
</template>
- <template slot="empty">
+ <template v-slot:empty>
<slot name="empty" />
</template>
</List>
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..5c52f697
--- /dev/null
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -0,0 +1,38 @@
+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 () {
+ 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
+ }
+ },
+ 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
new file mode 100644
index 00000000..c3ee6583
--- /dev/null
+++ b/src/components/settings_modal/helpers/boolean_setting.vue
@@ -0,0 +1,21 @@
+<template>
+ <label
+ class="BooleanSetting"
+ >
+ <Checkbox
+ :checked="state"
+ :disabled="disabled"
+ @change="update"
+ >
+ <span
+ v-if="!!$slots.default"
+ class="label"
+ >
+ <slot />
+ </span>
+ <ModifiedIndicator :changed="isChanged" />
+ </Checkbox>
+ </label>
+</template>
+
+<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..a15f6bac
--- /dev/null
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -0,0 +1,39 @@
+import { get, set } from 'lodash'
+import Select from 'src/components/select/select.vue'
+import ModifiedIndicator from './modified_indicator.vue'
+export default {
+ components: {
+ Select,
+ ModifiedIndicator
+ },
+ props: [
+ 'path',
+ 'disabled',
+ 'options'
+ ],
+ 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
+ }
+ },
+ 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..fa17661b
--- /dev/null
+++ b/src/components/settings_modal/helpers/choice_setting.vue
@@ -0,0 +1,29 @@
+<template>
+ <label
+ 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" />
+ </label>
+</template>
+
+<script src="./choice_setting.js"></script>
+
+<style lang="scss">
+.ChoiceSetting {
+}
+</style>
diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue
new file mode 100644
index 00000000..ad212db9
--- /dev/null
+++ b/src/components/settings_modal/helpers/modified_indicator.vue
@@ -0,0 +1,51 @@
+<template>
+ <span
+ v-if="changed"
+ class="ModifiedIndicator"
+ >
+ <Popover
+ trigger="hover"
+ >
+ <template v-slot:trigger>
+ &nbsp;
+ <FAIcon
+ icon="wrench"
+ :aria-label="$t('settings.setting_changed')"
+ />
+ </template>
+ <template v-slot:content>
+ <div class="modified-tooltip">
+ {{ $t('settings.setting_changed') }}
+ </div>
+ </template>
+ </Popover>
+ </span>
+</template>
+
+<script>
+import Popover from 'src/components/popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faWrench } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faWrench
+)
+
+export default {
+ components: { Popover },
+ props: ['changed']
+}
+</script>
+
+<style lang="scss">
+.ModifiedIndicator {
+ display: inline-block;
+ position: relative;
+
+ .modified-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 86703697..2c833c0c 100644
--- a/src/components/settings_modal/helpers/shared_computed_object.js
+++ b/src/components/settings_modal/helpers/shared_computed_object.js
@@ -1,29 +1,15 @@
-import {
- instanceDefaultProperties,
- multiChoiceProperties,
- defaultState as configDefaultState
-} from 'src/modules/config.js'
+import { defaultState as configDefaultState } from 'src/modules/config.js'
const SharedComputedObject = () => ({
user () {
return this.$store.state.users.currentUser
},
- // Getting localized values for instance-default properties
- ...instanceDefaultProperties
- .filter(key => multiChoiceProperties.includes(key))
+ // Getting values for default properties
+ ...Object.keys(configDefaultState)
.map(key => [
key + 'DefaultValue',
function () {
- return this.$store.getters.instanceDefaultConfig[key]
- }
- ])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
- ...instanceDefaultProperties
- .filter(key => !multiChoiceProperties.includes(key))
- .map(key => [
- key + 'LocalizedValue',
- function () {
- return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
+ return this.$store.getters.defaultConfig[key]
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js
index f0d49c91..04043483 100644
--- a/src/components/settings_modal/settings_modal.js
+++ b/src/components/settings_modal/settings_modal.js
@@ -2,10 +2,55 @@ 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 { library } from '@fortawesome/fontawesome-svg-core'
+import { cloneDeep } from 'lodash'
+import {
+ newImporter,
+ newExporter
+} from 'src/services/export_import/export_import.js'
+import {
+ faTimes,
+ faFileUpload,
+ faFileDownload,
+ faChevronDown
+} from '@fortawesome/free-solid-svg-icons'
+import {
+ faWindowMinimize
+} from '@fortawesome/free-regular-svg-icons'
+
+const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1
+const PLEROMAFE_SETTINGS_MINOR_VERSION = 0
+
+library.add(
+ faTimes,
+ faWindowMinimize,
+ faFileUpload,
+ faFileDownload,
+ faChevronDown
+)
const SettingsModal = {
+ data () {
+ return {
+ dataImporter: newImporter({
+ validator: this.importValidator,
+ onImport: this.onImport,
+ onImportFailure: this.onImportFailure
+ }),
+ dataThemeExporter: newExporter({
+ filename: 'pleromafe_settings.full',
+ getExportedObject: () => this.generateExport(true)
+ }),
+ dataExporter: newExporter({
+ filename: 'pleromafe_settings',
+ getExportedObject: () => this.generateExport()
+ })
+ }
+ },
components: {
Modal,
+ Popover,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
@@ -21,6 +66,85 @@ const SettingsModal = {
},
peekModal () {
this.$store.dispatch('togglePeekSettingsModal')
+ },
+ importValidator (data) {
+ if (!Array.isArray(data._pleroma_settings_version)) {
+ return {
+ messageKey: 'settings.file_import_export.invalid_file'
+ }
+ }
+
+ const [major, minor] = data._pleroma_settings_version
+
+ if (major > PLEROMAFE_SETTINGS_MAJOR_VERSION) {
+ return {
+ messageKey: 'settings.file_export_import.errors.file_too_new',
+ messageArgs: {
+ fileMajor: major,
+ feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
+ }
+ }
+ }
+
+ if (major < PLEROMAFE_SETTINGS_MAJOR_VERSION) {
+ return {
+ messageKey: 'settings.file_export_import.errors.file_too_old',
+ messageArgs: {
+ fileMajor: major,
+ feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION
+ }
+ }
+ }
+
+ if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) {
+ this.$store.dispatch('pushGlobalNotice', {
+ level: 'warning',
+ messageKey: 'settings.file_export_import.errors.file_slightly_new'
+ })
+ }
+
+ return true
+ },
+ onImportFailure (result) {
+ if (result.error) {
+ this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_settings_imported', level: 'error' })
+ } else {
+ this.$store.dispatch('pushGlobalNotice', { ...result.validationResult, level: 'error' })
+ }
+ },
+ onImport (data) {
+ if (data) { this.$store.dispatch('loadSettings', data) }
+ },
+ restore () {
+ this.dataImporter.importData()
+ },
+ backup () {
+ this.dataExporter.exportData()
+ },
+ backupWithTheme () {
+ this.dataThemeExporter.exportData()
+ },
+ generateExport (theme = false) {
+ const { config } = this.$store.state
+ let sample = config
+ if (!theme) {
+ const ignoreList = new Set([
+ 'customTheme',
+ 'customThemeSource',
+ 'colors'
+ ])
+ sample = Object.fromEntries(
+ Object
+ .entries(sample)
+ .filter(([key]) => !ignoreList.has(key))
+ )
+ }
+ const clone = cloneDeep(sample)
+ clone._pleroma_settings_version = [
+ PLEROMAFE_SETTINGS_MAJOR_VERSION,
+ PLEROMAFE_SETTINGS_MINOR_VERSION
+ ]
+ return clone
}
},
computed: {
diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue
index 552ca41f..583c2ecc 100644
--- a/src/components/settings_modal/settings_modal.vue
+++ b/src/components/settings_modal/settings_modal.vue
@@ -31,20 +31,84 @@
</transition>
<button
class="btn button-default"
+ :title="$t('general.peek')"
@click="peekModal"
>
- {{ $t('general.peek') }}
+ <FAIcon
+ :icon="['far', 'window-minimize']"
+ fixed-width
+ />
</button>
<button
class="btn button-default"
+ :title="$t('general.close')"
@click="closeModal"
>
- {{ $t('general.close') }}
+ <FAIcon
+ icon="times"
+ fixed-width
+ />
</button>
</div>
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
+ <div class="panel-footer">
+ <Popover
+ class="export"
+ trigger="click"
+ placement="top"
+ :offset="{ y: 5, x: 5 }"
+ :bound-to="{ x: 'container' }"
+ remove-padding
+ >
+ <template v-slot:trigger>
+ <button
+ class="btn button-default"
+ :title="$t('general.close')"
+ >
+ <span>{{ $t("settings.file_export_import.backup_restore") }}</span>
+ <FAIcon
+ icon="chevron-down"
+ />
+ </button>
+ </template>
+ <template v-slot:content="{close}">
+ <div class="dropdown-menu">
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="backup"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-download"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.backup_settings") }}</span>
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="backupWithTheme"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-download"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.backup_settings_theme") }}</span>
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="restore"
+ @click="close"
+ >
+ <FAIcon
+ icon="file-upload"
+ fixed-width
+ /><span>{{ $t("settings.file_export_import.restore_settings") }}</span>
+ </button>
+ </div>
+ </template>
+ </Popover>
+ </div>
</div>
</Modal>
</template>
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 5f38a5ae..4eaf4217 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,24 +1,23 @@
import { filter, trim } from 'lodash'
-import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_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: {
- Checkbox
+ BooleanSetting,
+ ChoiceSetting
},
computed: {
...SharedComputedObject(),
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index 8f850c8b..50ee20e0 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -1,89 +1,138 @@
<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>
- <Checkbox v-model="notificationVisibility.likes">
- {{ $t('settings.notification_visibility_likes') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.repeats">
- {{ $t('settings.notification_visibility_repeats') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.follows">
- {{ $t('settings.notification_visibility_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.mentions">
- {{ $t('settings.notification_visibility_mentions') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.moves">
- {{ $t('settings.notification_visibility_moves') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationVisibility.emojiReactions">
- {{ $t('settings.notification_visibility_emoji_reactions') }}
- </Checkbox>
- </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
+ :disabled="hideFilteredStatuses"
+ path="hideMutedThreads"
+ >
+ {{ $t('settings.hide_muted_threads') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting
+ :disabled="hideFilteredStatuses"
+ path="hideMutedPosts"
+ >
+ {{ $t('settings.hide_muted_posts') }}
+ </BooleanSetting>
+ </li>
+ </ul>
+ </li>
+ <li>
+ <BooleanSetting path="hidePostStats">
+ {{ $t('settings.hide_post_stats') }}
+ </BooleanSetting>
+ </li>
+ <ChoiceSetting
+ 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>
- <Checkbox v-model="hidePostStats">
- {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
- </Checkbox>
- </div>
- <div>
- <Checkbox v-model="hideUserStats">
- {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
- </Checkbox>
- </div>
+ <div>{{ $t('settings.filtering_explanation') }}</div>
+ </li>
+ <h3>{{ $t('settings.attachments') }}</h3>
+ <li>
+ <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>
+ <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">
+ <h2>{{ $t('settings.user_profiles') }}</h2>
+ <ul class="setting-list">
+ <li>
+ <BooleanSetting path="hideUserStats">
+ {{ $t('settings.hide_user_stats') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</div>
<div class="setting-item">
- <div>
- <p>{{ $t('settings.filtering_explanation') }}</p>
- <textarea
- id="muteWords"
- class="resize-height"
- v-model="muteWordsString"
- />
- </div>
- <div>
- <Checkbox v-model="hideFilteredStatuses">
- {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
- </Checkbox>
- </div>
+ <h2>{{ $t('settings.notifications') }}</h2>
+ <ul class="setting-list">
+ <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>
</template>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index 029ee7a1..952c328d 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,21 +1,30 @@
-import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import ChoiceSetting from '../helpers/choice_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
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}`)
+ })),
+ 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') ||
@@ -26,18 +35,27 @@ const GeneralTab = {
}
},
components: {
- Checkbox,
+ BooleanSetting,
+ ChoiceSetting,
InterfaceLanguageSwitcher
},
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()
}
}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index a9081793..eba3b268 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -7,228 +7,126 @@
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
- <Checkbox v-model="hideISP">
+ <BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
- </Checkbox>
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="sidebarRight">
+ {{ $t('settings.right_sidebar') }}
+ </BooleanSetting>
</li>
<li v-if="instanceWallpaperUsed">
- <Checkbox v-model="hideInstanceWallpaper">
+ <BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
- </Checkbox>
+ </BooleanSetting>
</li>
- </ul>
- </div>
- <div class="setting-item">
- <h2>{{ $t('nav.timeline') }}</h2>
- <ul class="setting-list">
<li>
- <Checkbox v-model="hideMutedPosts">
- {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="collapseMessageWithSubject">
- {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
- </Checkbox>
+ <BooleanSetting path="stopGifs">
+ {{ $t('settings.stop_gifs') }}
+ </BooleanSetting>
</li>
<li>
- <Checkbox v-model="streaming">
+ <BooleanSetting path="streaming">
{{ $t('settings.streaming') }}
- </Checkbox>
+ </BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
- <Checkbox
- v-model="pauseOnUnfocused"
+ <BooleanSetting
+ path="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
- </Checkbox>
+ </BooleanSetting>
</li>
</ul>
</li>
<li>
- <Checkbox v-model="useStreamingApi">
+ <BooleanSetting path="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="emojiReactionsOnTimeline">
- {{ $t('settings.emoji_reactions_on_timeline') }}
- </Checkbox>
+ </BooleanSetting>
</li>
<li>
- <Checkbox v-model="virtualScrolling">
+ <BooleanSetting path="virtualScrolling">
{{ $t('settings.virtual_scrolling') }}
- </Checkbox>
- </li>
- </ul>
- </div>
-
- <div class="setting-item">
- <h2>{{ $t('settings.composing') }}</h2>
- <ul class="setting-list">
- <li>
- <Checkbox v-model="scopeCopy">
- {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="alwaysShowSubjectInput">
- {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
- </Checkbox>
+ </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>
- <Checkbox v-model="minimalScopesMode">
- {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="autohideFloatingPostButton">
+ <BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
- </Checkbox>
+ </BooleanSetting>
</li>
- <li>
- <Checkbox v-model="padEmoji">
- {{ $t('settings.pad_emoji') }}
- </Checkbox>
+ <li v-if="instanceShoutboxPresent">
+ <BooleanSetting path="hideShoutbox">
+ {{ $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>
- <Checkbox v-model="hideAttachments">
- {{ $t('settings.hide_attachments_in_tl') }}
- </Checkbox>
+ <BooleanSetting path="collapseMessageWithSubject">
+ {{ $t('settings.collapse_subject') }}
+ </BooleanSetting>
</li>
<li>
- <Checkbox v-model="hideAttachmentsInConv">
- {{ $t('settings.hide_attachments_in_convo') }}
- </Checkbox>
+ <BooleanSetting path="emojiReactionsOnTimeline">
+ {{ $t('settings.emoji_reactions_on_timeline') }}
+ </BooleanSetting>
</li>
+ <h3>{{ $t('settings.attachments') }}</h3>
<li>
- <label for="maxThumbnails">
- {{ $t('settings.max_thumbnails') }}
- </label>
- <input
- id="maxThumbnails"
- v-model.number="maxThumbnails"
- class="number-input"
- type="number"
- min="0"
- step="1"
- >
+ <BooleanSetting path="useContainFit">
+ {{ $t('settings.use_contain_fit') }}
+ </BooleanSetting>
</li>
<li>
- <Checkbox v-model="hideNsfw">
+ <BooleanSetting path="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
- </Checkbox>
+ </BooleanSetting>
</li>
<ul class="setting-list suboptions">
<li>
- <Checkbox
- v-model="preloadImage"
+ <BooleanSetting
+ path="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
- </Checkbox>
+ </BooleanSetting>
</li>
<li>
- <Checkbox
- v-model="useOneClickNsfw"
+ <BooleanSetting
+ path="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
- </Checkbox>
+ </BooleanSetting>
</li>
</ul>
<li>
- <Checkbox v-model="stopGifs">
- {{ $t('settings.stop_gifs') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="loopVideo">
+ <BooleanSetting path="loopVideo">
{{ $t('settings.loop_video') }}
- </Checkbox>
+ </BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
- <Checkbox
- v-model="loopVideoSilentOnly"
+ <BooleanSetting
+ path="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
- </Checkbox>
+ </BooleanSetting>
<div
v-if="!loopSilentAvailable"
class="unavailable"
@@ -239,36 +137,130 @@
</ul>
</li>
<li>
- <Checkbox v-model="playVideosInModal">
+ <BooleanSetting path="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
- </Checkbox>
+ </BooleanSetting>
</li>
+ <h3>{{ $t('settings.fun') }}</h3>
<li>
- <Checkbox v-model="useContainFit">
- {{ $t('settings.use_contain_fit') }}
- </Checkbox>
+ <BooleanSetting path="greentext">
+ {{ $t('settings.greentext') }}
+ </BooleanSetting>
</li>
+ <li>
+ <BooleanSetting path="mentionLinkShowYous">
+ {{ $t('settings.show_yous') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <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">
+ {{ $t('settings.mention_link_show_tooltip') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="useAtIcon">
+ {{ $t('settings.use_at_icon') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="mentionLinkShowAvatar">
+ {{ $t('settings.mention_link_show_avatar') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="mentionLinkFadeDomain">
+ {{ $t('settings.mention_link_fade_domain') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="mentionLinkBoldenYou">
+ {{ $t('settings.mention_link_bolden_you') }}
+ </BooleanSetting>
+ </li>
+ </ul>
</ul>
</div>
<div class="setting-item">
- <h2>{{ $t('settings.notifications') }}</h2>
+ <h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
- <Checkbox v-model="webPushNotifications">
- {{ $t('settings.enable_web_push_notifications') }}
- </Checkbox>
+ <BooleanSetting path="scopeCopy">
+ {{ $t('settings.scope_copy') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="alwaysShowSubjectInput">
+ {{ $t('settings.subject_input_always_show') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <ChoiceSetting
+ id="subjectLineBehavior"
+ path="subjectLineBehavior"
+ :options="subjectLineOptions"
+ >
+ {{ $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">
+ {{ $t('settings.minimal_scopes_mode') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="sensitiveByDefault">
+ {{ $t('settings.sensitive_by_default') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="alwaysShowNewPostButton">
+ {{ $t('settings.always_show_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="autohideFloatingPostButton">
+ {{ $t('settings.autohide_floating_post_button') }}
+ </BooleanSetting>
+ </li>
+ <li>
+ <BooleanSetting path="padEmoji">
+ {{ $t('settings.pad_emoji') }}
+ </BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
- <h2>{{ $t('settings.fun') }}</h2>
+ <h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
- <Checkbox v-model="greentext">
- {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
- </Checkbox>
+ <BooleanSetting path="webPushNotifications">
+ {{ $t('settings.enable_web_push_notifications') }}
+ </BooleanSetting>
</li>
</ul>
</div>
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
index 63d36bf9..32a21415 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -10,20 +10,18 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
- <BlockCard
- slot-scope="row"
- :user-id="row.item"
- />
+ <template v-slot="row">
+ <BlockCard
+ :user-id="row.item"
+ />
+ </template>
</Autosuggest>
</div>
<BlockList
:refresh="true"
:get-key="i => i"
>
- <template
- slot="header"
- slot-scope="{selected}"
- >
+ <template v-slot:header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -31,7 +29,7 @@
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
@@ -41,19 +39,16 @@
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<BlockCard :user-id="item" />
</template>
- <template slot="empty">
+ <template v-slot:empty>
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
@@ -68,20 +63,18 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
- <MuteCard
- slot-scope="row"
- :user-id="row.item"
- />
+ <template v-slot="row">
+ <MuteCard
+ :user-id="row.item"
+ />
+ </template>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="i => i"
>
- <template
- slot="header"
- slot-scope="{selected}"
- >
+ <template v-slot:header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -89,7 +82,7 @@
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
@@ -99,19 +92,16 @@
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<MuteCard :user-id="item" />
</template>
- <template slot="empty">
+ <template v-slot:empty>
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
@@ -124,20 +114,18 @@
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
- <DomainMuteCard
- slot-scope="row"
- :domain="row.item"
- />
+ <template v-slot="row">
+ <DomainMuteCard
+ :domain="row.item"
+ />
+ </template>
</Autosuggest>
</div>
<DomainMuteList
:refresh="true"
:get-key="i => i"
>
- <template
- slot="header"
- slot-scope="{selected}"
- >
+ <template v-slot:header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
@@ -145,19 +133,16 @@
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
- <template slot="progress">
+ <template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<DomainMuteCard :domain="item" />
</template>
- <template slot="empty">
+ <template v-slot:empty>
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index 8f8fe48e..7e0568ea 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -24,7 +24,7 @@
class="btn button-default"
@click="updateNotificationSettings"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
</div>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 9709424c..64079fcd 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -24,7 +24,7 @@ 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,
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 175a0219..bb3c301d 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -153,7 +153,7 @@
class="btn button-default"
@click="updateProfile"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
@@ -227,7 +227,7 @@
class="btn button-default"
@click="submitBanner(banner)"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
@@ -266,7 +266,7 @@
class="btn button-default"
@click="submitBackground(background)"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
</div>
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index 811161a5..65d20fc0 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -1,6 +1,7 @@
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Mfa from './mfa.vue'
+import localeService from 'src/services/locale/locale.service.js'
const SecurityTab = {
data () {
@@ -37,7 +38,7 @@ const SecurityTab = {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
- validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
+ validUntil: new Date(oauthToken.valid_until).toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale))
}
})
}
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
index 56bea1f4..275d4616 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.vue
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -22,7 +22,7 @@
class="btn button-default"
@click="changeEmail"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
<p v-if="changedEmail">
{{ $t('settings.changed_email') }}
@@ -60,7 +60,7 @@
class="btn button-default"
@click="changePassword"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
<p v-if="changedPassword">
{{ $t('settings.changed_password') }}
@@ -133,7 +133,7 @@
class="btn button-default"
@click="confirmDelete"
>
- {{ $t('general.submit') }}
+ {{ $t('settings.save') }}
</button>
</div>
</div>
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 6cf75fe7..0b6669fc 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -16,6 +16,10 @@ import {
colors2to3
} from 'src/services/style_setter/style_setter.js'
import {
+ newImporter,
+ newExporter
+} from 'src/services/export_import/export_import.js'
+import {
SLOT_INHERITANCE
} from 'src/services/theme_data/pleromafe.js'
import {
@@ -31,18 +35,10 @@ import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
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.js'
-import ExportImport from 'src/components/export_import/export_import.vue'
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 = [
@@ -67,8 +63,18 @@ const colorConvert = (color) => {
export default {
data () {
return {
+ themeImporter: newImporter({
+ validator: this.importValidator,
+ onImport: this.onImport,
+ onImportFailure: this.onImportFailure
+ }),
+ themeExporter: newExporter({
+ filename: 'pleroma_theme',
+ getExportedObject: () => this.exportedTheme
+ }),
availableStyles: [],
- selected: this.$store.getters.mergedConfig.theme,
+ selected: '',
+ selectedTheme: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
@@ -202,7 +208,7 @@ export default {
}
},
selectedVersion () {
- return Array.isArray(this.selected) ? 1 : 2
+ return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors () {
return Object.keys(SLOT_INHERITANCE)
@@ -383,8 +389,8 @@ export default {
FontControl,
TabSwitcher,
Preview,
- ExportImport,
- Checkbox
+ Checkbox,
+ Select
},
methods: {
loadTheme (
@@ -469,7 +475,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()
@@ -528,10 +534,15 @@ export default {
this.previewColors.mod
)
},
+ importTheme () { this.themeImporter.importData() },
+ exportTheme () { this.themeExporter.exportData() },
onImport (parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
+ onImportFailure (result) {
+ this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
+ },
importValidator (parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
@@ -735,6 +746,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) {
@@ -752,17 +773,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 b8add42f..c02986ed 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -48,46 +48,47 @@
</template>
</div>
</div>
- <ExportImport
- :export-object="exportedTheme"
- :export-label="$t(&quot;settings.export_theme&quot;)"
- :import-label="$t(&quot;settings.import_theme&quot;)"
- :import-failed-text="$t(&quot;settings.invalid_theme_imported&quot;)"
- :on-import="onImport"
- :validator="importValidator"
- >
- <template slot="before">
- <div class="presets">
- {{ $t('settings.presets') }}
- <label
- for="preset-switcher"
- class="select"
+ <div class="top">
+ <div class="presets">
+ {{ $t('settings.presets') }}
+ <label
+ for="preset-switcher"
+ class="select"
+ >
+ <Select
+ id="preset-switcher"
+ v-model="selected"
+ class="preset-switcher"
>
- <select
- id="preset-switcher"
- v-model="selected"
- class="preset-switcher"
+ <option
+ v-for="style in availableStyles"
+ :key="style.name"
+ :value="style.name || style[0]"
+ :style="{
+ backgroundColor: style[1] || (style.theme || style.source).colors.bg,
+ color: style[3] || (style.theme || style.source).colors.text
+ }"
>
- <option
- v-for="style in availableStyles"
- :key="style.name"
- :value="style"
- :style="{
- backgroundColor: style[1] || (style.theme || style.source).colors.bg,
- color: style[3] || (style.theme || style.source).colors.text
- }"
- >
- {{ style[0] || style.name }}
- </option>
- </select>
- <FAIcon
- class="select-down-icon"
- icon="chevron-down"
- />
- </label>
- </div>
- </template>
- </ExportImport>
+ {{ style[0] || style.name }}
+ </option>
+ </Select>
+ </label>
+ </div>
+ <div class="export-import">
+ <button
+ class="btn button-default"
+ @click="importTheme"
+ >
+ {{ $t(&quot;settings.import_theme&quot;) }}
+ </button>
+ <button
+ class="btn button-default"
+ @click="exportTheme"
+ >
+ {{ $t(&quot;settings.export_theme&quot;) }}
+ </button>
+ </div>
+ </div>
</div>
<div class="save-load-options">
<span class="keep-option">
@@ -902,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 c3887098..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 () {
@@ -35,7 +35,19 @@ const chatPanel = {
userProfileLink (user) {
return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
}
+ },
+ watch: {
+ messages (newVal) {
+ const scrollEl = this.$el.querySelector('.chat-window')
+ if (!scrollEl) return
+ if (scrollEl.scrollTop + scrollEl.offsetHeight + 20 > scrollEl.scrollHeight) {
+ this.$nextTick(() => {
+ if (!scrollEl) return
+ scrollEl.scrollTop = scrollEl.scrollHeight - scrollEl.offsetHeight
+ })
+ }
+ }
}
}
-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 7993c94d..c88797d1 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -1,52 +1,50 @@
<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">
- <span>{{ $t('shoutbox.title') }}</span>
+ {{ $t('shoutbox.title') }}
<FAIcon
v-if="floating"
icon="times"
+ class="close-icon"
/>
</div>
</div>
- <div
- v-chat-scroll
- 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)"
/>
@@ -55,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">
@@ -74,45 +72,59 @@
</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 {
color: $fallback--text;
color: var(--text, $fallback--text);
+ margin-right: 0.5em;
+ }
+
+ .title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
}
- .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;
@@ -123,7 +135,7 @@
}
}
- .chat-input {
+ .shout-input {
display: flex;
textarea {
flex: 1;
@@ -133,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/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index 8665648a..b9561bf1 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -1,4 +1,6 @@
import map from 'lodash/map'
+import groupBy from 'lodash/groupBy'
+import { mapGetters, mapState } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = {
@@ -10,9 +12,21 @@ const StaffPanel = {
BasicUserCard
},
computed: {
- staffAccounts () {
- return map(this.$store.state.instance.staffAccounts, nickname => this.$store.getters.findUser(nickname)).filter(_ => _)
- }
+ groupedStaffAccounts () {
+ const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _)
+ const groupedStaffAccounts = groupBy(staffAccounts, 'role')
+
+ return [
+ { role: 'admin', users: groupedStaffAccounts['admin'] },
+ { role: 'moderator', users: groupedStaffAccounts['moderator'] }
+ ].filter(group => group.users)
+ },
+ ...mapGetters([
+ 'findUser'
+ ]),
+ ...mapState({
+ staffAccounts: state => state.instance.staffAccounts
+ })
}
}
diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue
index 1d13003d..c52ade42 100644
--- a/src/components/staff_panel/staff_panel.vue
+++ b/src/components/staff_panel/staff_panel.vue
@@ -7,11 +7,18 @@
</div>
</div>
<div class="panel-body">
- <basic-user-card
- v-for="user in staffAccounts"
- :key="user.screen_name"
- :user="user"
- />
+ <div
+ v-for="group in groupedStaffAccounts"
+ :key="group.role"
+ class="staff-group"
+ >
+ <h4>{{ $t('general.role.' + group.role) }}</h4>
+ <basic-user-card
+ v-for="user in group.users"
+ :key="user.screen_name"
+ :user="user"
+ />
+ </div>
</div>
</div>
</div>
@@ -20,4 +27,14 @@
<script src="./staff_panel.js" ></script>
<style lang="scss">
+
+.staff-group {
+ padding-left: 1em;
+ padding-top: 1em;
+
+ .basic-user-card {
+ padding-left: 0;
+ }
+}
+
</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 2bf93a9e..d8f94926 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'
@@ -68,7 +71,10 @@ const Status = {
StatusPopover,
UserListPopover,
EmojiReactions,
- StatusContent
+ StatusContent,
+ RichContent,
+ MentionLink,
+ MentionsLine
},
props: [
'statusoid',
@@ -92,7 +98,8 @@ const Status = {
userExpanded: false,
mediaPlaying: [],
suspendable: true,
- error: null
+ error: null,
+ headTailLinks: null
}
},
computed: {
@@ -132,12 +139,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 },
- retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name },
- retweeterHtml () { return this.statusoid.user.name_html },
+ retweeterUser () { return this.statusoid.user },
+ retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
+ retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
@@ -156,27 +166,52 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
+ 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
+ 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)
@@ -189,14 +224,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
@@ -216,7 +263,7 @@ const Status = {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
- return user && user.screen_name
+ return user && user.screen_name_ui
}
},
replySubject () {
@@ -303,6 +350,9 @@ const Status = {
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+ },
+ setHeadTailLinks (headTailLinks) {
+ this.headTailLinks = headTailLinks
}
},
watch: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 58b55bc8..2028ade9 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,10 +1,12 @@
-
@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;
@@ -93,12 +95,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 +153,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 +213,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;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 6ee8117f..3bb29db6 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"
class="Status"
@@ -26,7 +25,7 @@
icon="retweet"
/>
<router-link :to="userProfileLink">
- {{ status.user.screen_name }}
+ {{ status.user.screen_name_ui }}
</router-link>
</small>
<small
@@ -89,8 +88,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"
@@ -145,8 +148,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"
@@ -156,10 +163,10 @@
</h4>
<router-link
class="account-name"
- :title="status.user.screen_name"
+ :title="status.user.screen_name_ui"
:to="userProfileLink"
>
- {{ status.user.screen_name }}
+ {{ status.user.screen_name_ui }}
</router-link>
<img
v-if="!!(status.user && status.user.favicon)"
@@ -214,11 +221,13 @@
</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"
@@ -238,7 +247,7 @@
flip="horizontal"
/>
<span
- class="faint-link reply-to-text"
+ class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
@@ -251,50 +260,76 @@
>
<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"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
+ @parseReady="setHeadTailLinks"
/>
+ <div
+ v-if="inConversation && !isPreview && replies && replies.length"
+ class="replies"
+ >
+ <span 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"
@@ -402,7 +437,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..91c33135
--- /dev/null
+++ b/src/components/status_body/status_body.js
@@ -0,0 +1,129 @@
+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'
+ ],
+ data () {
+ return {
+ showingTall: this.fullContent || (this.inConversation && this.focused),
+ showingLongSubject: false,
+ // not as computed because it sets the initial state which will be changed later
+ expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+ 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.showingTall = !this.showingTall
+ } else if (this.mightHideBecauseSubject) {
+ this.expandingSubject = !this.expandingSubject
+ }
+ },
+ 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..a088e6bc
--- /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="showingLongSubject=false"
+ >
+ {{ $t("status.hide_full_subject") }}
+ </button>
+ <button
+ v-else-if="longSubject"
+ class="button-unstyled -link tall-subject-hider"
+ @click.prevent="showingLongSubject=true"
+ >
+ {{ $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 a6f79d76..dec8914a 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 {
@@ -30,57 +27,17 @@ const StatusContent = {
name: 'StatusContent',
props: [
'status',
+ 'compact',
'focused',
'noHeading',
'fullContent',
'singleLine'
],
- data () {
- return {
- showingTall: this.fullContent || (this.inConversation && this.focused),
- showingLongSubject: false,
- // not as computed because it sets the initial state which will be changed later
- expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
- }
- },
computed: {
- localCollapseSubjectDefault () {
- return this.mergedConfig.collapseMessageWithSubject
- },
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
@@ -91,72 +48,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
})
},
@@ -164,52 +69,8 @@ const StatusContent = {
Attachment,
Poll,
Gallery,
- LinkPreview
- },
- 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
- }
- },
- generateUserProfileLink (id, name) {
- return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
- },
- generateTagLink (tag) {
- return `/tag/${tag}`
- },
- setMedia () {
- const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
- return () => this.$store.dispatch('setMedia', attachments)
- }
+ LinkPreview,
+ StatusBody
}
}
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 90bfaf40..69635aad 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,133 +1,53 @@
<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"
+ @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 v-if="status.poll && status.poll.options && !hideSubjectStatus">
- <poll :base-poll="status.poll" />
- </div>
+ </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>
@@ -139,156 +59,5 @@ $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/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index 8237ce00..fdca8c9c 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -5,12 +5,10 @@
:bound-to="{ x: 'container' }"
@show="enter"
>
- <template slot="trigger">
+ <template v-slot:trigger>
<slot />
</template>
- <div
- slot="content"
- >
+ <template v-slot:content>
<Status
v-if="status"
:is-preview="true"
@@ -33,7 +31,7 @@
size="2x"
/>
</div>
- </div>
+ </template>
</Popover>
</template>
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..cca75fcb 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"
@@ -30,7 +31,7 @@
position: relative;
line-height: 0;
overflow: hidden;
- display: flex;
+ display: inline-flex;
align-items: center;
canvas {
@@ -47,12 +48,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/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
index 76e7ef03..12aac8e6 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.js
@@ -93,7 +93,9 @@ export default Vue.component('tab-switcher', {
<button
disabled={slot.data.attrs.disabled}
onClick={this.clickTab(index)}
- class={classesTab.join(' ')}>
+ class={classesTab.join(' ')}
+ type="button"
+ >
<img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/>
{slot.data.attrs.label ? '' : slot.data.attrs.label}
</button>
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 6df0524d..55a2dd94 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -9,6 +9,7 @@
<script>
import * as DateUtils from 'src/services/date_utils/date_utils.js'
+import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
@@ -21,9 +22,10 @@ export default {
},
computed: {
localeDateString () {
+ const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale)
return typeof this.time === 'string'
- ? new Date(Date.parse(this.time)).toLocaleString()
- : this.time.toLocaleString()
+ ? new Date(Date.parse(this.time)).toLocaleString(browserLocale)
+ : this.time.toLocaleString(browserLocale)
}
},
created () {
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 665d195e..04f0e7d6 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -2,27 +2,16 @@ import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import Conversation from '../conversation/conversation.vue'
import TimelineMenu from '../timeline_menu/timeline_menu.vue'
+import TimelineQuickSettings from './timeline_quick_settings.vue'
import { debounce, throttle, keyBy } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons'
library.add(
- faCircleNotch
+ faCircleNotch,
+ 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',
@@ -47,7 +36,8 @@ const Timeline = {
components: {
Status,
Conversation,
- TimelineMenu
+ TimelineMenu,
+ TimelineQuickSettings
},
computed: {
newStatusCount () {
@@ -74,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.scss b/src/components/timeline/timeline.scss
new file mode 100644
index 00000000..2c5a67e2
--- /dev/null
+++ b/src/components/timeline/timeline.scss
@@ -0,0 +1,31 @@
+@import '../../_variables.scss';
+
+.Timeline {
+ .loadmore-text {
+ opacity: 1;
+ }
+
+ &.-blocked {
+ cursor: progress;
+ }
+
+ .timeline-heading {
+ max-width: 100%;
+ flex-wrap: nowrap;
+ align-items: center;
+ position: relative;
+
+ .loadmore-button {
+ flex-shrink: 0;
+ }
+
+ .loadmore-text {
+ flex-shrink: 0;
+ line-height: 1em;
+ }
+ }
+
+ .timeline-footer {
+ border: none;
+ }
+}
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 4c43fe5c..ff16208d 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -16,6 +16,7 @@
>
{{ $t('timeline.up_to_date') }}
</div>
+ <TimelineQuickSettings v-if="!embedded" />
</div>
<div :class="classes.body">
<div
@@ -36,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"
@@ -51,13 +52,13 @@
<div :class="classes.footer">
<div
v-if="count===0"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('timeline.no_statuses') }}
</div>
<div
v-else-if="bottomedOut"
- class="new-status-notification text-center panel-footer faint"
+ class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
@@ -66,13 +67,13 @@
class="button-unstyled -link -fullwidth"
@click.prevent="fetchOlderStatuses()"
>
- <div class="new-status-notification text-center panel-footer">
+ <div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
<div
v-else
- class="new-status-notification text-center panel-footer"
+ class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
@@ -86,29 +87,4 @@
<script src="./timeline.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.Timeline {
- .loadmore-text {
- opacity: 1;
- }
-
- &.-blocked {
- cursor: progress;
- }
-}
-
-.timeline-heading {
- max-width: 100%;
- flex-wrap: nowrap;
- align-items: center;
- .loadmore-button {
- flex-shrink: 0;
- }
- .loadmore-text {
- flex-shrink: 0;
- line-height: 1em;
- }
-}
-</style>
+<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/timeline/timeline_quick_settings.js
new file mode 100644
index 00000000..7b4931ce
--- /dev/null
+++ b/src/components/timeline/timeline_quick_settings.js
@@ -0,0 +1,60 @@
+import Popover from '../popover/popover.vue'
+import { mapGetters } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faFilter, faFont, faWrench } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faFilter,
+ faFont,
+ faWrench
+)
+
+const TimelineQuickSettings = {
+ components: {
+ Popover
+ },
+ methods: {
+ setReplyVisibility (visibility) {
+ this.$store.dispatch('setOption', { name: 'replyVisibility', value: visibility })
+ this.$store.dispatch('queueFlushAll')
+ },
+ openTab (tab) {
+ this.$store.dispatch('openSettingsModalTab', tab)
+ }
+ },
+ computed: {
+ ...mapGetters(['mergedConfig']),
+ loggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ replyVisibilitySelf: {
+ get () { return this.mergedConfig.replyVisibility === 'self' },
+ set () { this.setReplyVisibility('self') }
+ },
+ replyVisibilityFollowing: {
+ get () { return this.mergedConfig.replyVisibility === 'following' },
+ set () { this.setReplyVisibility('following') }
+ },
+ replyVisibilityAll: {
+ get () { return this.mergedConfig.replyVisibility === 'all' },
+ set () { this.setReplyVisibility('all') }
+ },
+ hideMedia: {
+ get () { return this.mergedConfig.hideAttachments || this.mergedConfig.hideAttachmentsInConv },
+ set () {
+ const value = !this.hideMedia
+ this.$store.dispatch('setOption', { name: 'hideAttachments', value })
+ this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value })
+ }
+ },
+ hideMutedPosts: {
+ get () { return this.mergedConfig.hideFilteredStatuses },
+ set () {
+ const value = !this.hideMutedPosts
+ this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
+ }
+ }
+ }
+}
+
+export default TimelineQuickSettings
diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/timeline/timeline_quick_settings.vue
new file mode 100644
index 00000000..98996ebd
--- /dev/null
+++ b/src/components/timeline/timeline_quick_settings.vue
@@ -0,0 +1,102 @@
+<template>
+ <Popover
+ trigger="click"
+ class="TimelineQuickSettings"
+ :bound-to="{ x: 'container' }"
+ >
+ <template v-slot:content>
+ <div class="dropdown-menu">
+ <div v-if="loggedIn">
+ <button
+ class="button-default dropdown-item"
+ @click="replyVisibilityAll = true"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-radio': replyVisibilityAll }"
+ />{{ $t('settings.reply_visibility_all') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="replyVisibilityFollowing = true"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-radio': replyVisibilityFollowing }"
+ />{{ $t('settings.reply_visibility_following_short') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="replyVisibilitySelf = true"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-radio': replyVisibilitySelf }"
+ />{{ $t('settings.reply_visibility_self_short') }}
+ </button>
+ <div
+ role="separator"
+ class="dropdown-divider"
+ />
+ </div>
+ <button
+ class="button-default dropdown-item"
+ @click="hideMedia = !hideMedia"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': hideMedia }"
+ />{{ $t('settings.hide_media_previews') }}
+ </button>
+ <button
+ class="button-default dropdown-item"
+ @click="hideMutedPosts = !hideMutedPosts"
+ >
+ <span
+ class="menu-checkbox"
+ :class="{ 'menu-checkbox-checked': hideMutedPosts }"
+ />{{ $t('settings.hide_all_muted_posts') }}
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click="openTab('filtering')"
+ >
+ <FAIcon icon="font" />{{ $t('settings.word_filter') }}
+ </button>
+ <button
+ class="button-default dropdown-item dropdown-item-icon"
+ @click="openTab('general')"
+ >
+ <FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
+ </button>
+ </div>
+ </template>
+ <template v-slot:trigger>
+ <button class="button-unstyled">
+ <FAIcon icon="filter" />
+ </button>
+ </template>
+ </Popover>
+</template>
+
+<script src="./timeline_quick_settings.js"></script>
+
+<style lang="scss">
+
+.TimelineQuickSettings {
+ align-self: stretch;
+
+ > button {
+ font-size: 1.2em;
+ padding-left: 0.7em;
+ padding-right: 0.2em;
+ line-height: 100%;
+ height: 100%;
+ }
+
+ .dropdown-item {
+ margin: 0;
+ }
+}
+
+</style>
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index 8d6a58b1..bab51e75 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -1,29 +1,17 @@
import Popover from '../popover/popover.vue'
-import { mapState } from 'vuex'
+import TimelineMenuContent from './timeline_menu_content.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome,
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
-library.add(
- faUsers,
- faGlobe,
- faBookmark,
- faEnvelope,
- faHome,
- faChevronDown
-)
+library.add(faChevronDown)
// Route -> i18n key mapping, exported and not in the computed
// because nav panel benefits from the same information.
export const timelineNames = () => {
return {
- 'friends': 'nav.timeline',
+ 'friends': 'nav.home_timeline',
'bookmarks': 'nav.bookmarks',
'dms': 'nav.dms',
'public-timeline': 'nav.public_tl',
@@ -33,7 +21,8 @@ export const timelineNames = () => {
const TimelineMenu = {
components: {
- Popover
+ Popover,
+ TimelineMenuContent
},
data () {
return {
@@ -41,9 +30,6 @@ const TimelineMenu = {
}
},
created () {
- if (this.currentUser && this.currentUser.locked) {
- this.$store.dispatch('startFetchingFollowRequests')
- }
if (timelineNames()[this.$route.name]) {
this.$store.dispatch('setLastTimeline', this.$route.name)
}
@@ -75,13 +61,6 @@ const TimelineMenu = {
const i18nkey = timelineNames()[this.$route.name]
return i18nkey ? this.$t(i18nkey) : route
}
- },
- computed: {
- ...mapState({
- currentUser: state => state.users.currentUser,
- privateMode: state => state.instance.private,
- federating: state => state.instance.federating
- })
}
}
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
index 3c86842b..8f14093f 100644
--- a/src/components/timeline_menu/timeline_menu.vue
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -9,74 +9,26 @@
@show="openMenu"
@close="() => isOpen = false"
>
- <div
- slot="content"
- class="timeline-menu-popover panel panel-default"
- >
- <ul>
- <li v-if="currentUser">
- <router-link :to="{ name: 'friends' }">
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="home"
- />{{ $t("nav.timeline") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link :to="{ name: 'bookmarks'}">
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="bookmark"
- />{{ $t("nav.bookmarks") }}
- </router-link>
- </li>
- <li v-if="currentUser">
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="envelope"
- />{{ $t("nav.dms") }}
- </router-link>
- </li>
- <li v-if="currentUser || !privateMode">
- <router-link :to="{ name: 'public-timeline' }">
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="users"
- />{{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li v-if="federating && (currentUser || !privateMode)">
- <router-link :to="{ name: 'public-external-timeline' }">
- <FAIcon
- fixed-width
- class="fa-scale-110 fa-old-padding "
- icon="globe"
- />{{ $t("nav.twkn") }}
- </router-link>
- </li>
- </ul>
- </div>
- <div
- slot="trigger"
- class="title timeline-menu-title"
- >
- <span class="timeline-title">{{ timelineName() }}</span>
- <span>
- <FAIcon
- size="sm"
- icon="chevron-down"
+ <template v-slot:content>
+ <div class="timeline-menu-popover popover-default">
+ <TimelineMenuContent />
+ </div>
+ </template>
+ <template v-slot:trigger>
+ <button class="button-unstyled title timeline-menu-title">
+ <span class="timeline-title">{{ timelineName() }}</span>
+ <span>
+ <FAIcon
+ size="sm"
+ icon="chevron-down"
+ />
+ </span>
+ <span
+ class="click-blocker"
+ @click="blockOpen"
/>
- </span>
- <span
- class="click-blocker"
- @click="blockOpen"
- />
- </div>
+ </button>
+ </template>
</Popover>
</template>
diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js
new file mode 100644
index 00000000..671570dd
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu_content.js
@@ -0,0 +1,29 @@
+import { mapState } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faHome
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faHome
+)
+
+const TimelineMenuContent = {
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default TimelineMenuContent
diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue
new file mode 100644
index 00000000..bed1b679
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu_content.vue
@@ -0,0 +1,66 @@
+<template>
+ <ul>
+ <li v-if="currentUser">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'friends' }"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding "
+ icon="home"
+ />{{ $t("nav.home_timeline") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser || !privateMode">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'public-timeline' }"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding "
+ icon="users"
+ />{{ $t("nav.public_tl") }}
+ </router-link>
+ </li>
+ <li v-if="federating && (currentUser || !privateMode)">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'public-external-timeline' }"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding "
+ icon="globe"
+ />{{ $t("nav.twkn") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'bookmarks'}"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding "
+ icon="bookmark"
+ />{{ $t("nav.bookmarks") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link
+ class="menu-item"
+ :to="{ name: 'dms', params: { username: currentUser.screen_name } }"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding "
+ icon="envelope"
+ />{{ $t("nav.dms") }}
+ </router-link>
+ </li>
+ </ul>
+</template>
+
+<script src="./timeline_menu_content.js" ></script>
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 0f7c584b..4040e263 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -2,8 +2,8 @@
<StillImage
v-if="user"
class="Avatar"
- :alt="user.screen_name"
- :title="user.screen_name"
+ :alt="user.screen_name_ui"
+ :title="user.screen_name_ui"
:src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
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 773f764a..0708f387 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -38,26 +38,29 @@
</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"
+ <button
+ v-if="!isOtherUser && user.is_local"
+ class="button-unstyled edit-profile-button"
+ @click.stop="openProfileTab"
>
- {{ user.name }}
- </div>
+ <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"
- class="external-link-button"
+ class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
@@ -73,23 +76,29 @@
<div class="bottom-line">
<router-link
class="user-screen-name"
- :title="user.screen_name"
+ :title="user.screen_name_ui"
:to="userProfileLink(user)"
>
- @{{ user.screen_name }}
+ @{{ user.screen_name_ui }}
</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"
>
- {{ $t(`user_card.roles.${visibleRole}`) }}
+ {{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
- bot
+ {{ $t('user_card.bot') }}
</span>
</template>
<span v-if="user.locked">
@@ -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">No highlight</option>
- <option value="solid">Solid bg</option>
- <option value="striped">Striped bg</option>
- <option value="side">Side stripe</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 95673733..8706d0ff 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -4,40 +4,44 @@
placement="top"
:offset="{ y: 5 }"
>
- <template slot="trigger">
+ <template v-slot:trigger>
<slot />
</template>
- <div
- slot="content"
- class="user-list-popover"
- >
- <div v-if="users.length">
- <div
- v-for="(user) in usersCapped"
- :key="user.id"
- class="user-list-row"
- >
- <UserAvatar
- :user="user"
- class="avatar-small"
- :compact="true"
- />
- <div class="user-list-names">
- <!-- eslint-disable vue/no-v-html -->
- <span v-html="user.name_html" />
- <!-- eslint-enable vue/no-v-html -->
- <span class="user-list-screen-name">{{ user.screen_name }}</span>
+ <template v-slot:content>
+ <div class="user-list-popover">
+ <template v-if="users.length">
+ <div
+ v-for="(user) in usersCapped"
+ :key="user.id"
+ class="user-list-row"
+ >
+ <UserAvatar
+ :user="user"
+ class="avatar-small"
+ :compact="true"
+ />
+ <div class="user-list-names">
+ <!-- eslint-disable vue/no-v-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>
</div>
- </div>
- </div>
- <div v-else>
- <FAIcon
- icon="circle-notch"
- spin
- size="3x"
- />
+ </template>
+ <template v-else>
+ <FAIcon
+ icon="circle-notch"
+ spin
+ size="3x"
+ />
+ </template>
</div>
- </div>
+ </template>
</Popover>
</template>
@@ -49,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 c0b55a6c..7a475609 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.js'
+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 745e795d..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
@@ -60,10 +64,7 @@
:disabled="!user.friends_count"
>
<FriendList :user-id="userId">
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<FollowCard :user="item" />
</template>
</FriendList>
@@ -75,10 +76,7 @@
:disabled="!user.followers_count"
>
<FollowerList :user-id="userId">
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<FollowCard
:user="item"
:no-follows-you="isUs"
diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue
index fb43094f..1f67a5cc 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.vue
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -6,7 +6,7 @@
<div class="user-reporting-panel panel">
<div class="panel-heading">
<div class="title">
- {{ $t('user_reporting.title', [user.screen_name]) }}
+ {{ $t('user_reporting.title', [user.screen_name_ui]) }}
</div>
</div>
<div class="panel-body">
@@ -45,10 +45,7 @@
</div>
<div class="user-reporting-panel-right">
<List :items="statuses">
- <template
- slot="item"
- slot-scope="{item}"
- >
+ <template v-slot:item="{item}">
<div class="status-fadein user-reporting-panel-sitem">
<Status
:in-conversation="false"