aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/account_actions/account_actions.js3
-rw-r--r--src/components/account_actions/account_actions.vue7
-rw-r--r--src/components/attachment/attachment.js3
-rw-r--r--src/components/conversation/conversation.js16
-rw-r--r--src/components/edit_status_modal/edit_status_modal.js75
-rw-r--r--src/components/edit_status_modal/edit_status_modal.vue48
-rw-r--r--src/components/emoji_input/emoji_input.js50
-rw-r--r--src/components/emoji_input/emoji_input.vue3
-rw-r--r--src/components/emoji_input/suggestor.js34
-rw-r--r--src/components/emoji_picker/emoji_picker.js302
-rw-r--r--src/components/emoji_picker/emoji_picker.scss52
-rw-r--r--src/components/emoji_picker/emoji_picker.vue52
-rw-r--r--src/components/extra_buttons/extra_buttons.js27
-rw-r--r--src/components/extra_buttons/extra_buttons.vue22
-rw-r--r--src/components/follow_card/follow_card.js4
-rw-r--r--src/components/follow_card/follow_card.vue11
-rw-r--r--src/components/nav_panel/nav_panel.vue7
-rw-r--r--src/components/navigation/navigation_entry.js4
-rw-r--r--src/components/navigation/navigation_entry.vue133
-rw-r--r--src/components/optional_router_link/optional_router_link.vue23
-rw-r--r--src/components/post_status_form/post_status_form.js54
-rw-r--r--src/components/post_status_form/post_status_form.vue18
-rw-r--r--src/components/react_button/react_button.js4
-rw-r--r--src/components/remove_follower_button/remove_follower_button.js25
-rw-r--r--src/components/remove_follower_button/remove_follower_button.vue13
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js4
-rw-r--r--src/components/status/status.js6
-rw-r--r--src/components/status/status.scss3
-rw-r--r--src/components/status/status.vue18
-rw-r--r--src/components/status_history_modal/status_history_modal.js60
-rw-r--r--src/components/status_history_modal/status_history_modal.vue46
-rw-r--r--src/components/still-image/still-image.js27
-rw-r--r--src/components/still-image/still-image.vue5
-rw-r--r--src/components/timeago/timeago.vue21
-rw-r--r--src/components/update_notification/update_notification.js25
-rw-r--r--src/components/update_notification/update_notification.scss12
-rw-r--r--src/components/update_notification/update_notification.vue7
37 files changed, 979 insertions, 245 deletions
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 735dd81c..c23407f9 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -36,6 +36,9 @@ const AccountActions = {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
+ removeUserFromFollowers () {
+ this.$store.dispatch('removeUserFromFollowers', this.user.id)
+ },
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 770740e0..218aa6b3 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -30,6 +30,13 @@
</template>
<UserListMenu :user="user" />
<button
+ v-if="relationship.followed_by"
+ class="btn button-default btn-block dropdown-item"
+ @click="removeUserFromFollowers"
+ >
+ {{ $t('user_card.remove_follower') }}
+ </button>
+ <button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@click="unblockUser"
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index d62a4adc..5dc50475 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -129,6 +129,9 @@ const Attachment = {
...mapGetters(['mergedConfig'])
},
watch: {
+ 'attachment.description' (newVal) {
+ this.localDescription = newVal
+ },
localDescription (newVal) {
this.onEdit(newVal)
}
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 712e2a2c..85e6d8ad 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,6 +1,8 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
@@ -79,6 +81,9 @@ const conversation = {
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
},
@@ -341,7 +346,11 @@ const conversation = {
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
- }
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+ })
},
components: {
Status,
@@ -399,6 +408,11 @@ const conversation = {
setHighlight (id) {
if (!id) return
this.highlight = id
+
+ if (!this.streamingEnabled) {
+ this.$store.dispatch('fetchStatus', id)
+ }
+
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
new file mode 100644
index 00000000..75adfea7
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+ components: {
+ PostStatusForm,
+ Modal
+ },
+ data () {
+ return {
+ resettingForm: false
+ }
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ modalActivated () {
+ return this.$store.state.editStatus.modalActivated
+ },
+ isFormVisible () {
+ return this.isLoggedIn && !this.resettingForm && this.modalActivated
+ },
+ params () {
+ return this.$store.state.editStatus.params || {}
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+ this.resettingForm = true
+ this.$nextTick(() => {
+ this.resettingForm = false
+ })
+ }
+ },
+ isFormVisible (val) {
+ if (val) {
+ this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+ }
+ }
+ },
+ methods: {
+ doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+ const params = {
+ store: this.$store,
+ statusId: this.$store.state.editStatus.params.statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media,
+ contentType
+ }
+
+ return statusPosterService.editStatus(params)
+ .then((data) => {
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeEditStatusModal')
+ }
+ }
+}
+
+export default EditStatusModal
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
new file mode 100644
index 00000000..1dbacaab
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,48 @@
+<template>
+ <Modal
+ v-if="isFormVisible"
+ class="edit-form-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="edit-form-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('post_status.edit_status') }}
+ </div>
+ <PostStatusForm
+ class="panel-body"
+ v-bind="params"
+ :post-handler="doEditStatus"
+ :disable-polls="true"
+ :disable-visibility-selector="true"
+ @posted="closeModal"
+ />
+ </div>
+ </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+ align-items: flex-start;
+}
+.edit-form-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+
+ .form-bottom-left {
+ max-width: 6.5em;
+
+ .emoji-icon {
+ justify-content: right;
+ }
+ }
+}
+</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index b664d6b3..ffc0ffac 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -3,7 +3,7 @@ import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-
+import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam
@@ -143,6 +143,51 @@ const EmojiInput = {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiNamesAndKeywords () {
+ return emoji => {
+ const names = [emoji.displayText]
+ const keywords = []
+
+ if (emoji.displayTextI18n) {
+ names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
+ }
+
+ if (emoji.annotations) {
+ this.languages.forEach(lang => {
+ names.push(emoji.annotations[lang]?.name)
+
+ keywords.push(...(emoji.annotations[lang]?.keywords || []))
+ })
+ }
+
+ return {
+ names: names.filter(k => k),
+ keywords: keywords.filter(k => k)
+ }
+ }
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
},
mounted () {
@@ -181,7 +226,7 @@ const EmojiInput = {
const firstchar = newWord.charAt(0)
this.suggestions = []
if (newWord === firstchar) return
- const matchedSuggestions = await this.suggest(newWord)
+ const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return
@@ -207,7 +252,6 @@ const EmojiInput = {
},
triggerShowPicker () {
this.showPicker = true
- this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index 81b81913..43581dbf 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -19,6 +19,7 @@
v-if="enableEmojiPicker"
ref="picker"
:class="{ hide: !showPicker }"
+ :showing="showPicker"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@@ -63,7 +64,7 @@
v-if="!suggestion.user"
class="displayText"
>
- {{ suggestion.displayText }}
+ {{ maybeLocalizedEmojiName(suggestion) }}
</span>
<span class="detailText">{{ suggestion.detailText }}</span>
</div>
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index 0ddb4d68..adaa879e 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -2,7 +2,7 @@
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
- * (state.instance.emoji + state.instance.customEmoji)
+ * (getters.standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
@@ -13,10 +13,10 @@
export default data => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
- return input => {
+ return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
- return emojiCurry(input)
+ return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
@@ -25,34 +25,34 @@ export default data => {
}
}
-export const suggestEmoji = emojis => input => {
+export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
- .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
- .sort((a, b) => {
- let aScore = 0
- let bScore = 0
+ .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
+ .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
+ .map(k => {
+ let score = 0
// An exact match always wins
- aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
- bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
+ score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
// Prioritize custom emoji a lot
- aScore += a.imageUrl ? 100 : 0
- bScore += b.imageUrl ? 100 : 0
+ score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
- aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
- bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+ score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
// Sort by length
- aScore -= a.displayText.length
- bScore -= b.displayText.length
+ score -= k.displayText.length
+ k.score = score
+ return k
+ })
+ .sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
- return bScore - aScore + alphabetically
+ return b.score - a.score + alphabetically
})
}
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index f6920208..fafc2af1 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,33 +1,76 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
+import StillImage from '../still-image/still-image.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
+import lozad from 'lozad'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
} from '@fortawesome/free-solid-svg-icons'
-import { trim } from 'lodash'
+import { debounce, trim } from 'lodash'
library.add(
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
)
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
+const UNICODE_EMOJI_GROUP_ICON = {
+ 'smileys-and-emotion': 'smile',
+ 'people-and-body': 'user',
+ 'animals-and-nature': 'paw',
+ 'food-and-drink': 'ice-cream',
+ 'travel-and-places': 'bus',
+ activities: 'basketball-ball',
+ objects: 'lightbulb',
+ symbols: 'code',
+ flags: 'flag'
+}
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
+const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
+ const res = [emoji.displayText, nameLocalizer(emoji)]
+ if (emoji.annotations) {
+ languages.forEach(lang => {
+ const keywords = emoji.annotations[lang]?.keywords || []
+ const name = emoji.annotations[lang]?.name
+ res.push(...(keywords.concat([name]).filter(k => k)))
+ })
+ }
+ return res
+}
-const filterByKeyword = (list, keyword = '') => {
+const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
- const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
+ const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
+
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
@@ -44,6 +87,10 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
+ },
+ showing: {
+ required: true,
+ type: Boolean
}
},
data () {
@@ -53,16 +100,26 @@ const EmojiPicker = {
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
- customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
- customEmojiLoadAllConfirmed: false
+ // Lazy-load only after the first time `showing` becomes true.
+ contentLoaded: false,
+ groupRefs: {},
+ emojiRefs: {},
+ filteredEmojiGroups: []
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
- Checkbox
+ Checkbox,
+ StillImage
},
methods: {
+ setGroupRef (name) {
+ return el => { this.groupRefs[name] = el }
+ },
+ setEmojiRef (name) {
+ return el => { this.emojiRefs[name] = el }
+ },
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
@@ -77,10 +134,38 @@ const EmojiPicker = {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
- this.triggerLoadMore(target)
+ },
+ scrolledGroup (target) {
+ const top = target.scrollTop + 5
+ this.$nextTick(() => {
+ this.allEmojiGroups.forEach(group => {
+ const ref = this.groupRefs['group-' + group.id]
+ if (ref && ref.offsetTop <= top) {
+ this.activeGroup = group.id
+ }
+ })
+ this.scrollHeader()
+ })
+ },
+ scrollHeader () {
+ // Scroll the active tab's header into view
+ const headerRef = this.groupRefs['group-header-' + this.activeGroup]
+ const left = headerRef.offsetLeft
+ const right = left + headerRef.offsetWidth
+ const headerCont = this.$refs.header
+ const currentScroll = headerCont.scrollLeft
+ const currentScrollRight = currentScroll + headerCont.clientWidth
+ const setScroll = s => { headerCont.scrollLeft = s }
+
+ const margin = 7 // .emoji-tabs-item: padding
+ if (left - margin < currentScroll) {
+ setScroll(left - margin)
+ } else if (right + margin > currentScrollRight) {
+ setScroll(right + margin - headerCont.clientWidth)
+ }
},
highlight (key) {
- const ref = this.$refs['group-' + key]
+ const ref = this.groupRefs['group-' + key]
const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
@@ -97,73 +182,90 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle'
}
},
- triggerLoadMore (target) {
- const ref = this.$refs['group-end-custom']
- if (!ref) return
- const bottom = ref.offsetTop + ref.offsetHeight
-
- const scrollerBottom = target.scrollTop + target.clientHeight
- const scrollerTop = target.scrollTop
- const scrollerMax = target.scrollHeight
-
- // Loads more emoji when they come into view
- const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
- // Always load when at the very top in case there's no scroll space yet
- const atTop = scrollerTop < 5
- // Don't load when looking at unicode category or at the very bottom
- const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
- if (!bottomAboveViewport && (approachingBottom || atTop)) {
- this.loadEmoji()
- }
+ toggleStickers () {
+ this.showingStickers = !this.showingStickers
},
- scrolledGroup (target) {
- const top = target.scrollTop + 5
+ setShowStickers (value) {
+ this.showingStickers = value
+ },
+ filterByKeyword (list, keyword) {
+ return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
+ },
+ initializeLazyLoad () {
+ this.destroyLazyLoad()
this.$nextTick(() => {
- this.emojisView.forEach(group => {
- const ref = this.$refs['group-' + group.id]
- if (ref.offsetTop <= top) {
- this.activeGroup = group.id
+ this.$lozad = lozad('.still-image.emoji-picker-emoji', {
+ load: el => {
+ const name = el.getAttribute('data-emoji-name')
+ const vn = this.emojiRefs[name]
+ if (!vn) {
+ return
+ }
+
+ vn.loadLazy()
}
})
+ this.$lozad.observe()
})
},
- loadEmoji () {
- const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
- if (allLoaded) {
- return
- }
-
- this.customEmojiBufferSlice += LOAD_EMOJI_BY
+ waitForDomAndInitializeLazyLoad () {
+ this.$nextTick(() => this.initializeLazyLoad())
},
- startEmojiLoad (forceUpdate = false) {
- if (!forceUpdate) {
- this.keyword = ''
- }
- this.$nextTick(() => {
- this.$refs['emoji-groups'].scrollTop = 0
- })
- const bufferSize = this.customEmojiBuffer.length
- const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
- if (bufferPrefilledAll && !forceUpdate) {
- return
+ destroyLazyLoad () {
+ if (this.$lozad) {
+ if (this.$lozad.observer) {
+ this.$lozad.observer.disconnect()
+ }
+ if (this.$lozad.mutationObserver) {
+ this.$lozad.mutationObserver.disconnect()
+ }
}
- this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
- toggleStickers () {
- this.showingStickers = !this.showingStickers
+ onShowing () {
+ const oldContentLoaded = this.contentLoaded
+ this.contentLoaded = true
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ if (!oldContentLoaded) {
+ this.$nextTick(() => {
+ if (this.defaultGroup) {
+ this.highlight(this.defaultGroup)
+ }
+ })
+ }
},
- setShowStickers (value) {
- this.showingStickers = value
+ getFilteredEmojiGroups () {
+ return this.allEmojiGroups
+ .map(group => ({
+ ...group,
+ emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+ }))
+ .filter(group => group.emojis.length > 0)
}
},
watch: {
keyword () {
- this.customEmojiLoadAllConfirmed = false
this.onScroll()
- this.startEmojiLoad(true)
+ this.debouncedHandleKeywordChange()
+ },
+ allCustomGroups () {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ },
+ showing (val) {
+ if (val) {
+ this.onShowing()
+ }
}
},
+ mounted () {
+ if (this.showing) {
+ this.onShowing()
+ }
+ },
+ destroyed () {
+ this.destroyLazyLoad()
+ },
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@@ -174,39 +276,55 @@ const EmojiPicker = {
}
return 0
},
- filteredEmoji () {
- return filterByKeyword(
- this.$store.state.instance.customEmoji || [],
- trim(this.keyword)
- )
+ allCustomGroups () {
+ return this.$store.getters.groupedCustomEmojis
},
- customEmojiBuffer () {
- return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
+ defaultGroup () {
+ return Object.keys(this.allCustomGroups)[0]
},
- emojis () {
- const standardEmojis = this.$store.state.instance.emoji || []
- const customEmojis = this.customEmojiBuffer
-
- return [
- {
- id: 'custom',
- text: this.$t('emoji.custom'),
- icon: 'smile-beam',
- emojis: customEmojis
- },
- {
- id: 'standard',
- text: this.$t('emoji.unicode'),
- icon: 'box-open',
- emojis: filterByKeyword(standardEmojis, trim(this.keyword))
- }
- ]
+ unicodeEmojiGroups () {
+ return this.$store.getters.standardEmojiGroupList.map(group => ({
+ id: `standard-${group.id}`,
+ text: this.$t(`emoji.unicode_groups.${group.id}`),
+ icon: UNICODE_EMOJI_GROUP_ICON[group.id],
+ emojis: group.emojis
+ }))
},
- emojisView () {
- return this.emojis.filter(value => value.emojis.length > 0)
+ allEmojiGroups () {
+ return Object.entries(this.allCustomGroups)
+ .map(([_, v]) => v)
+ .concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0
+ },
+ debouncedHandleKeywordChange () {
+ return debounce(() => {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ }, 500)
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
}
}
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index a2f17c51..016c46d7 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,5 +1,10 @@
@import '../../_variables.scss';
+$emoji-picker-header-height: 36px;
+$emoji-picker-header-picture-width: 32px;
+$emoji-picker-header-picture-height: 32px;
+$emoji-picker-emoji-size: 32px;
+
.emoji-picker {
display: flex;
flex-direction: column;
@@ -19,6 +24,23 @@
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
+ &-header-image {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ .still-image {
+ max-width: 100%;
+ max-height: 100%;
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+ }
+ }
+
.keep-open,
.too-many-emoji {
padding: 7px;
@@ -37,7 +59,6 @@
.heading {
display: flex;
- height: 32px;
padding: 10px 7px 5px;
}
@@ -50,6 +71,10 @@
.emoji-tabs {
flex-grow: 1;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
.emoji-groups {
@@ -57,6 +82,8 @@
}
.additional-tabs {
+ display: flex;
+ flex: 1;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
@@ -66,15 +93,20 @@
.additional-tabs,
.emoji-tabs {
- display: block;
- min-width: 0;
flex-basis: auto;
- flex-shrink: 1;
+ display: flex;
+ align-content: center;
&-item {
padding: 0 7px;
cursor: pointer;
font-size: 1.85em;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ display: flex;
+ align-items: center;
&.disabled {
opacity: 0.5;
@@ -164,22 +196,26 @@
}
&-item {
- width: 32px;
- height: 32px;
+ width: $emoji-picker-emoji-size;
+ height: $emoji-picker-emoji-size;
box-sizing: border-box;
display: flex;
- font-size: 32px;
+ line-height: $emoji-picker-emoji-size;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
- img {
+ .emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
+ .emoji-picker-emoji.-unicode {
+ font-size: 24px;
+ overflow: hidden;
+ }
}
}
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index a7269120..57bb0037 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,19 +1,34 @@
<template>
- <div class="emoji-picker panel panel-default panel-body">
+ <div
+ class="emoji-picker panel panel-default panel-body"
+ >
<div class="heading">
- <span class="emoji-tabs">
+ <span
+ ref="header"
+ class="emoji-tabs"
+ >
<span
- v-for="group in emojis"
+ v-for="group in filteredEmojiGroups"
+ :ref="setGroupRef('group-header-' + group.id)"
:key="group.id"
class="emoji-tabs-item"
:class="{
- active: activeGroupView === group.id,
- disabled: group.emojis.length === 0
+ active: activeGroupView === group.id
}"
:title="group.text"
@click.prevent="highlight(group.id)"
>
+ <span
+ v-if="group.image"
+ class="emoji-picker-header-image"
+ >
+ <still-image
+ :alt="group.text"
+ :src="group.image"
+ />
+ </span>
<FAIcon
+ v-else
:icon="group.icon"
fixed-width
/>
@@ -36,7 +51,10 @@
</span>
</span>
</div>
- <div class="content">
+ <div
+ v-if="contentLoaded"
+ class="content"
+ >
<div
class="emoji-content"
:class="{hidden: showingStickers}"
@@ -57,12 +75,12 @@
@scroll="onScroll"
>
<div
- v-for="group in emojisView"
+ v-for="group in filteredEmojiGroups"
:key="group.id"
class="emoji-group"
>
<h6
- :ref="'group-' + group.id"
+ :ref="setGroupRef('group-' + group.id)"
class="emoji-group-title"
>
{{ group.text }}
@@ -70,17 +88,23 @@
<span
v-for="emoji in group.emojis"
:key="group.id + emoji.displayText"
- :title="emoji.displayText"
+ :title="maybeLocalizedEmojiName(emoji)"
class="emoji-item"
@click.stop.prevent="onEmoji(emoji)"
>
- <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
- <img
+ <span
+ v-if="!emoji.imageUrl"
+ class="emoji-picker-emoji -unicode"
+ >{{ emoji.replacement }}</span>
+ <still-image
v-else
- :src="emoji.imageUrl"
- >
+ :ref="setEmojiRef(group.id + emoji.displayText)"
+ class="emoji-picker-emoji -custom"
+ :data-src="emoji.imageUrl"
+ :data-emoji-name="group.id + emoji.displayText"
+ />
</span>
- <span :ref="'group-end-' + group.id" />
+ <span :ref="setGroupRef('group-end-' + group.id)" />
</div>
</div>
<div class="keep-open">
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 68fa66ad..2e495423 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -7,6 +7,7 @@ import {
faThumbtack,
faShareAlt,
faExternalLinkAlt,
+ faHistory,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons'
@@ -24,6 +25,7 @@ library.add(
faShareAlt,
faExternalLinkAlt,
faFlag,
+ faHistory,
faPlus,
faTimes
)
@@ -86,6 +88,25 @@ const ExtraButtons = {
},
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+ },
+ editStatus () {
+ this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+ .then(data => this.$store.dispatch('openEditStatusModal', {
+ statusId: this.status.id,
+ subject: data.spoiler_text,
+ statusText: data.text,
+ statusIsSensitive: this.status.nsfw,
+ statusPoll: this.status.poll,
+ statusFiles: [...this.status.attachments],
+ visibility: this.status.visibility,
+ statusContentType: data.content_type
+ }))
+ },
+ showStatusHistory () {
+ const originalStatus = { ...this.status }
+ const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+ stripFieldsList.forEach(p => delete originalStatus[p])
+ this.$store.dispatch('openStatusHistoryModal', originalStatus)
}
},
computed: {
@@ -109,7 +130,11 @@ const ExtraButtons = {
},
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
- }
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () { return this.$store.state.instance.editingAvailable }
}
}
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index 011dff9b..b2fad1c9 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -78,6 +78,28 @@
</button>
</template>
<button
+ v-if="ownStatus && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="editStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="pen"
+ /><span>{{ $t("status.edit") }}</span>
+ </button>
+ <button
+ v-if="isEdited && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="showStatusHistory"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="history"
+ /><span>{{ $t("status.status_history") }}</span>
+ </button>
+ <button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js
index 6dcb6d47..b26b27a7 100644
--- a/src/components/follow_card/follow_card.js
+++ b/src/components/follow_card/follow_card.js
@@ -1,6 +1,7 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue'
+import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
const FollowCard = {
props: [
@@ -10,7 +11,8 @@ const FollowCard = {
components: {
BasicUserCard,
RemoteFollow,
- FollowButton
+ FollowButton,
+ RemoveFollowerButton
},
computed: {
isMe () {
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index 895a8fa3..c919b11a 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -22,6 +22,11 @@
class="follow-card-follow-button"
:user="user"
/>
+ <RemoveFollowerButton
+ v-if="noFollowsYou && relationship.followed_by"
+ :relationship="relationship"
+ class="follow-card-button"
+ />
</template>
</div>
</basic-user-card>
@@ -40,6 +45,12 @@
line-height: 1.5em;
}
+ &-button {
+ margin-top: 0.5em;
+ padding: 0 1.5em;
+ margin-left: 1em;
+ }
+
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 2688bcf4..7373ca63 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -121,7 +121,6 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- padding: 0;
}
> li {
@@ -150,12 +149,6 @@
font-size: 1.1em;
}
- .menu-item {
- .timelines-chevron {
- margin-right: 0;
- }
- }
-
.timelines-background {
padding: 0 0 0 0.6em;
background-color: $fallback--lightBg;
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
index fe3402fc..81cc936a 100644
--- a/src/components/navigation/navigation_entry.js
+++ b/src/components/navigation/navigation_entry.js
@@ -1,5 +1,6 @@
import { mapState } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
@@ -7,6 +8,9 @@ library.add(faThumbtack)
const NavigationEntry = {
props: ['item', 'showPin'],
+ components: {
+ OptionalRouterLink
+ },
methods: {
isPinned (value) {
return this.pinnedItems.has(value)
diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue
index 824c00a2..f4d53836 100644
--- a/src/components/navigation/navigation_entry.vue
+++ b/src/components/navigation/navigation_entry.vue
@@ -1,26 +1,37 @@
<template>
- <li class="NavigationEntry">
- <component
- :is="routeTo ? 'router-link' : 'button'"
- class="menu-item button-unstyled"
- :to="routeTo"
+ <OptionalRouterLink
+ v-slot="{ isActive, href, navigate } = {}"
+ ass="ass"
+ :to="routeTo"
+ >
+ <li
+ class="NavigationEntry menu-item"
+ :class="{ '-active': isActive }"
+ v-bind="$attrs"
>
- <span>
- <FAIcon
- v-if="item.icon"
- fixed-width
- class="fa-scale-110 menu-icon"
- :icon="item.icon"
- />
- </span>
- <span
- v-if="item.iconLetter"
- class="icon iconLetter fa-scale-110 menu-icon"
- >{{ item.iconLetter }}
- </span>
- <span class="label">
- {{ item.labelRaw || $t(item.label) }}
- </span>
+ <component
+ :is="routeTo ? 'a' : 'button'"
+ class="main-link button-unstyled"
+ :href="href"
+ @click="navigate"
+ >
+ <span>
+ <FAIcon
+ v-if="item.icon"
+ fixed-width
+ class="fa-scale-110 menu-icon"
+ :icon="item.icon"
+ />
+ </span>
+ <span
+ v-if="item.iconLetter"
+ class="icon iconLetter fa-scale-110 menu-icon"
+ >{{ item.iconLetter }}
+ </span>
+ <span class="label">
+ {{ item.labelRaw || $t(item.label) }}
+ </span>
+ </component>
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
@@ -45,8 +56,8 @@
icon="thumbtack"
/>
</button>
- </component>
- </li>
+ </li>
+ </OptionalRouterLink>
</template>
<script src="./navigation_entry.js"></script>
@@ -55,7 +66,21 @@
@import '../../_variables.scss';
.NavigationEntry {
- .label {
+ display: flex;
+ box-sizing: border-box;
+ align-items: baseline;
+ height: 3.5em;
+ line-height: 3.5em;
+ padding: 0 1em;
+ width: 100%;
+ color: $fallback--link;
+ color: var(--link, $fallback--link);
+
+ .timelines-chevron {
+ margin-right: 0;
+ }
+
+ .main-link {
flex: 1;
}
@@ -72,48 +97,36 @@
}
}
- .menu-item {
- display: flex;
- box-sizing: border-box;
- align-items: baseline;
- height: 3.5em;
- line-height: 3.5em;
- padding: 0 1em;
- width: 100%;
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
- color: var(--link, $fallback--link);
-
- &:hover {
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--link;
- color: var(--selectedMenuText, $fallback--link);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
- .menu-icon {
- --icon: var(--text, $fallback--icon);
- }
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
}
+ }
- &.router-link-active {
- font-weight: bolder;
- background-color: $fallback--lightBg;
- background-color: var(--selectedMenu, $fallback--lightBg);
- color: $fallback--text;
- color: var(--selectedMenuText, $fallback--text);
- --faint: var(--selectedMenuFaintText, $fallback--faint);
- --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
- --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ &.-active {
+ font-weight: bolder;
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedMenuText, $fallback--text);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
- .menu-icon {
- --icon: var(--text, $fallback--icon);
- }
+ .menu-icon {
+ --icon: var(--text, $fallback--icon);
+ }
- &:hover {
- text-decoration: underline;
- }
+ &:hover {
+ text-decoration: underline;
}
}
}
diff --git a/src/components/optional_router_link/optional_router_link.vue b/src/components/optional_router_link/optional_router_link.vue
new file mode 100644
index 00000000..d56ad268
--- /dev/null
+++ b/src/components/optional_router_link/optional_router_link.vue
@@ -0,0 +1,23 @@
+<template>
+ <!-- eslint-disable vue/no-multiple-template-root -->
+ <router-link
+ v-if="to"
+ v-slot="props"
+ :to="to"
+ custom
+ >
+ <slot
+ v-bind="props"
+ />
+ </router-link>
+ <slot
+ v-else
+ v-bind="{}"
+ />
+</template>
+
+<script>
+export default {
+ props: ['to']
+}
+</script>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c0d80b20..5c536b74 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
const PostStatusForm = {
props: [
+ 'statusId',
+ 'statusText',
+ 'statusIsSensitive',
+ 'statusPoll',
+ 'statusFiles',
+ 'statusMediaDescriptions',
+ 'statusScope',
+ 'statusContentType',
'replyTo',
'repliedUser',
'attentions',
@@ -62,6 +70,7 @@ const PostStatusForm = {
'subject',
'disableSubject',
'disableScopeSelector',
+ 'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
@@ -125,22 +134,38 @@ const PostStatusForm = {
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
+ let statusParams = {
+ spoilerText: this.subject || '',
+ status: statusText,
+ nsfw: !!sensitiveByDefault,
+ files: [],
+ poll: {},
+ mediaDescriptions: {},
+ visibility: scope,
+ contentType
+ }
+
+ if (this.statusId) {
+ const statusContentType = this.statusContentType || contentType
+ statusParams = {
+ spoilerText: this.subject || '',
+ status: this.statusText || '',
+ nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+ files: this.statusFiles || [],
+ poll: this.statusPoll || {},
+ mediaDescriptions: this.statusMediaDescriptions || {},
+ visibility: this.statusScope || scope,
+ contentType: statusContentType
+ }
+ }
+
return {
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
- newStatus: {
- spoilerText: this.subject || '',
- status: statusText,
- nsfw: !!sensitiveByDefault,
- files: [],
- poll: {},
- mediaDescriptions: {},
- visibility: scope,
- contentType
- },
+ newStatus: statusParams,
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
@@ -164,7 +189,7 @@ const PostStatusForm = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
@@ -173,13 +198,13 @@ const PostStatusForm = {
emojiSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
},
emoji () {
- return this.$store.state.instance.emoji || []
+ return this.$store.getters.standardEmojiList || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
@@ -236,6 +261,9 @@ const PostStatusForm = {
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
+ isEdit () {
+ return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+ },
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 62613bd1..f65058f4 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -67,6 +67,13 @@
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<div
+ v-if="isEdit"
+ class="visibility-notice edit-warning"
+ >
+ <p>{{ $t('post_status.edit_remote_warning') }}</p>
+ <p>{{ $t('post_status.edit_unsupported_warning') }}</p>
+ </div>
+ <div
v-if="!disablePreview"
class="preview-heading faint"
>
@@ -170,6 +177,7 @@
class="visibility-tray"
>
<scope-selector
+ v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
@@ -410,6 +418,16 @@
align-items: baseline;
}
+ .visibility-notice.edit-warning {
+ > :first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 1.85em;
line-height: 1.1;
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index 5e052e1e..e65bfd93 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -59,7 +59,7 @@ const ReactButton = {
if (this.filterWord !== '') {
const filterWordLowercase = trim(this.filterWord.toLowerCase())
const orderedEmojiList = []
- for (const emoji of this.$store.state.instance.emoji) {
+ for (const emoji of this.$store.getters.standardEmojiList) {
if (emoji.replacement === this.filterWord) return [emoji]
const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
@@ -72,7 +72,7 @@ const ReactButton = {
}
return orderedEmojiList.flat()
}
- return this.$store.state.instance.emoji || []
+ return this.$store.getters.standardEmojiList || []
},
mergedConfig () {
return this.$store.getters.mergedConfig
diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js
new file mode 100644
index 00000000..e1a7531b
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.js
@@ -0,0 +1,25 @@
+export default {
+ props: ['relationship'],
+ data () {
+ return {
+ inProgress: false
+ }
+ },
+ computed: {
+ label () {
+ if (this.inProgress) {
+ return this.$t('user_card.follow_progress')
+ } else {
+ return this.$t('user_card.remove_follower')
+ }
+ }
+ },
+ methods: {
+ onClick () {
+ this.inProgress = true
+ this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
+ this.inProgress = false
+ })
+ }
+ }
+}
diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue
new file mode 100644
index 00000000..a3a4c242
--- /dev/null
+++ b/src/components/remove_follower_button/remove_follower_button.vue
@@ -0,0 +1,13 @@
+<template>
+ <button
+ class="btn button-default follow-button"
+ :class="{ toggled: inProgress }"
+ :disabled="inProgress"
+ :title="$t('user_card.remove_follower')"
+ @click="onClick"
+ >
+ {{ label }}
+ </button>
+</template>
+
+<script src="./remove_follower_button.js"></script>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 376248ef..b86faef0 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -64,7 +64,7 @@ const ProfileTab = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
@@ -73,7 +73,7 @@ const ProfileTab = {
emojiSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 19356664..9a9bca7a 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -395,6 +395,12 @@ const Status = {
},
visibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () {
+ return this.$store.state.instance.editingAvailable
}
},
methods: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index b3ad3818..ada9841e 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -156,7 +156,8 @@
margin-right: 0.2em;
}
- & .heading-reply-row {
+ & .heading-reply-row,
+ & .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 91f5fffa..82eb7ac6 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -327,6 +327,24 @@
class="mentions-line"
/>
</div>
+ <div
+ v-if="isEdited && editingAvailable && !isPreview"
+ class="heading-edited-row"
+ >
+ <i18n-t
+ keypath="status.edited_at"
+ tag="span"
+ >
+ <template #time>
+ <Timeago
+ template-key="time.in_past"
+ :time="status.edited_at"
+ :auto-update="60"
+ :long-format="true"
+ />
+ </template>
+ </i18n-t>
+ </div>
</div>
<StatusContent
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
new file mode 100644
index 00000000..3941a56f
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -0,0 +1,60 @@
+import { get } from 'lodash'
+import Modal from '../modal/modal.vue'
+import Status from '../status/status.vue'
+
+const StatusHistoryModal = {
+ components: {
+ Modal,
+ Status
+ },
+ data () {
+ return {
+ statuses: []
+ }
+ },
+ computed: {
+ modalActivated () {
+ return this.$store.state.statusHistory.modalActivated
+ },
+ params () {
+ return this.$store.state.statusHistory.params
+ },
+ statusId () {
+ return this.params.id
+ },
+ historyCount () {
+ return this.statuses.length
+ },
+ history () {
+ return this.statuses
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
+ if (newStatusId) {
+ this.resetHistory()
+ }
+
+ if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) {
+ this.fetchStatusHistory()
+ }
+ }
+ },
+ methods: {
+ resetHistory () {
+ this.statuses = []
+ },
+ fetchStatusHistory () {
+ this.$store.dispatch('fetchStatusHistory', this.params)
+ .then(data => {
+ this.statuses = data
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
new file mode 100644
index 00000000..990be35b
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,46 @@
+<template>
+ <Modal
+ v-if="modalActivated"
+ class="status-history-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="status-history-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('status.status_history') }} ({{ historyCount }})
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="historyCount > 0"
+ class="history-body"
+ >
+ <status
+ v-for="status in history"
+ :key="status.id"
+ :statusoid="status"
+ :is-preview="true"
+ class="conversation-status status-fadein panel-body"
+ />
+ </div>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./status_history_modal.js"></script>
+
+<style lang="scss">
+.modal-view.status-history-modal-view {
+ align-items: flex-start;
+}
+.status-history-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+}
+</style>
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index d7abbcb5..200ef147 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -7,16 +7,23 @@ const StillImage = {
'imageLoadHandler',
'alt',
'height',
- 'width'
+ 'width',
+ 'dataSrc'
],
data () {
return {
+ // for lazy loading, see loadLazy()
+ realSrc: this.src,
stopGifs: this.$store.getters.mergedConfig.stopGifs
}
},
computed: {
animated () {
- return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
+ if (!this.realSrc) {
+ return false
+ }
+
+ return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif'))
},
style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
@@ -27,7 +34,15 @@ const StillImage = {
}
},
methods: {
+ loadLazy () {
+ if (this.dataSrc) {
+ this.realSrc = this.dataSrc
+ }
+ },
onLoad () {
+ if (!this.realSrc) {
+ return
+ }
const image = this.$refs.src
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
@@ -42,6 +57,14 @@ const StillImage = {
onError () {
this.imageLoadError && this.imageLoadError()
}
+ },
+ watch: {
+ src () {
+ this.realSrc = this.src
+ },
+ dataSrc () {
+ this.$el.removeAttribute('data-loaded')
+ }
}
}
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index ab3080c8..633fb229 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -11,10 +11,11 @@
<!-- NOTE: key is required to force to re-render img tag when src is changed -->
<img
ref="src"
- :key="src"
+ :key="realSrc"
:alt="alt"
:title="alt"
- :src="src"
+ :data-src="dataSrc"
+ :src="realSrc"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@error="onError"
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 2b487dfd..b5f49515 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -3,7 +3,7 @@
:datetime="time"
:title="localeDateString"
>
- {{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }}
+ {{ relativeTimeString }}
</time>
</template>
@@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
- props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'],
+ props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data () {
return {
relativeTime: { key: 'time.now', num: 0 },
@@ -26,6 +26,23 @@ export default {
return typeof this.time === 'string'
? new Date(Date.parse(this.time)).toLocaleString(browserLocale)
: this.time.toLocaleString(browserLocale)
+ },
+ relativeTimeString () {
+ const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num])
+
+ if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') {
+ return this.$i18n.t(this.templateKey, [timeString])
+ }
+
+ return timeString
+ }
+ },
+ watch: {
+ time (newVal, oldVal) {
+ if (oldVal !== newVal) {
+ clearTimeout(this.interval)
+ this.refreshRelativeTimeObject()
+ }
}
},
created () {
diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js
index 609842c4..ddf379f5 100644
--- a/src/components/update_notification/update_notification.js
+++ b/src/components/update_notification/update_notification.js
@@ -2,6 +2,8 @@ import Modal from 'src/components/modal/modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import pleromaTan from 'src/assets/pleromatan_apology.png'
import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png'
+import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
+import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
import {
faTimes
@@ -15,9 +17,9 @@ export const CURRENT_UPDATE_COUNTER = 1
const UpdateNotification = {
data () {
return {
+ showingImage: false,
pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
- showingMore: false,
- contentHeight: 0
+ showingMore: false
}
},
components: {
@@ -25,13 +27,9 @@ const UpdateNotification = {
},
computed: {
pleromaTanStyles () {
+ const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
return {
- 'shape-outside': 'url(' + this.pleromaTanVariant + ')'
- }
- },
- dynamicStyles () {
- return {
- '--____extraInfoGroupHeight': this.contentHeight + 'px'
+ 'shape-outside': 'url(' + mask + ')'
}
},
shouldShow () {
@@ -57,9 +55,14 @@ const UpdateNotification = {
}
},
mounted () {
- setTimeout(() => {
- this.contentHeight = this.$refs.animatedText.scrollHeight
- }, 1000)
+ this.contentHeightNoImage = this.$refs.animatedText.scrollHeight
+
+ // Workaround to get the text height only after mask loaded. A bit hacky.
+ const newImg = new Image()
+ newImg.onload = () => {
+ setTimeout(() => { this.showingImage = true }, 100)
+ }
+ newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
}
}
diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss
index 8cad1bc7..ce8129d0 100644
--- a/src/components/update_notification/update_notification.scss
+++ b/src/components/update_notification/update_notification.scss
@@ -35,6 +35,12 @@
margin-top: calc(-1 * var(--__top-fringe));
margin-bottom: calc(-1 * var(--__bottom-fringe));
margin-right: calc(-1 * var(--__right-fringe));
+
+ &.-noImage {
+ .text {
+ padding-right: var(--__right-fringe);
+ }
+ }
}
.panel-body {
@@ -75,9 +81,9 @@
.extra-info-group {
transition: max-height, padding, height;
- transition-timing-function: ease-in-out;
- transition-duration: 500ms;
- max-height: calc(var(--____extraInfoGroupHeight) + 1em); // include bottom padding
+ transition-timing-function: ease-in;
+ transition-duration: 700ms;
+ max-height: 70vh;
mask:
linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat,
linear-gradient(to top, white, white);
diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue
index 00841af2..78e70a74 100644
--- a/src/components/update_notification/update_notification.vue
+++ b/src/components/update_notification/update_notification.vue
@@ -7,7 +7,6 @@
<div
class="UpdateNotificationModal panel"
:class="{ '-peek': !showingMore }"
- :style="dynamicStyles"
>
<div class="panel-heading">
<span class="title">
@@ -15,8 +14,12 @@
</span>
</div>
<div class="panel-body">
- <div class="content">
+ <div
+ class="content"
+ :class="{ '-noImage': !showingImage }"
+ >
<img
+ v-if="showingImage"
class="pleroma-tan"
:src="pleromaTanVariant"
:style="pleromaTanStyles"