Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into froth
Some checks reported errors
continuous-integration/drone/push Build was killed
Some checks reported errors
continuous-integration/drone/push Build was killed
This commit is contained in:
commit
f40c9d69e8
70 changed files with 1687 additions and 1003 deletions
25
.gitlab/issue_templates/Bug.md
Normal file
25
.gitlab/issue_templates/Bug.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Environment info
|
||||
|
||||
<!-- Everything is optional and where applicable but the more information the better. -->
|
||||
* Browser, version, OS, platform:
|
||||
* Instance URL:
|
||||
* Frontend version (see settings -> about):
|
||||
* Backend version (see settings -> about):
|
||||
* Browser extensions (ublock, rikaichamp etc):
|
||||
* Known instance/user customizations (i.e. pleromafe mods/forks, instance styles etc)
|
||||
|
||||
# Bug description & reproduction steps
|
||||
|
||||
<!-- Type out here how to reproduce the bug, what goes wrong and what should go right -->
|
||||
<!-- Screenshots and videos help a lot ;) any observations might also help -->
|
||||
<!-- Also mention if there any errors in browser's console if relevant -->
|
||||
|
||||
# Bug seriousness
|
||||
|
||||
<!-- Everything is optional and free-form -->
|
||||
* How annoying it is:
|
||||
* How often does it happen:
|
||||
* How many people does it affect:
|
||||
* Is there a workaround for it:
|
||||
|
||||
/label ~Bug
|
11
.gitlab/issue_templates/Suggestion.md
Normal file
11
.gitlab/issue_templates/Suggestion.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Behavior suggestion/Feature request
|
||||
<!--
|
||||
Type out what you want to see changed or what feature you want to see added to
|
||||
PleormaFE. Please also explain how it would benefit users (or admins/moderators)
|
||||
and what intended usecase is. Any background information (i.e. porting behavior
|
||||
from other frontends/services, specific situations, personal preferences etc.)
|
||||
as well as examples would be greatly appreciated.
|
||||
-->
|
||||
|
||||
/label ~suggestion
|
||||
|
7
.gitlab/issue_templates/default.md
Normal file
7
.gitlab/issue_templates/default.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
<!--
|
||||
please use one of the templates if applicable, otherwise - type out here
|
||||
in free-form
|
||||
-->
|
||||
|
||||
/label ~needs-triage
|
||||
|
30
.gitlab/merge_request_templates/default.md
Normal file
30
.gitlab/merge_request_templates/default.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
<!--
|
||||
Feel free to submit merge requests that are work-in-progress, but mark them as
|
||||
Draft: or WIP:.
|
||||
Merge requests that have Draft or WIP status will not be merged and have less chances
|
||||
of being reviewed, but you can still ask people to take a look if you need advice.
|
||||
-->
|
||||
# Changes
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
<!-- List what your merge request changes and how -->
|
||||
<!--
|
||||
Try to not to break existing behavior, if your changes do break existing behavior
|
||||
make it configurable to toggle between old behavior and new. Which one should be
|
||||
default is up to discussion.
|
||||
-->
|
||||
<!-- If your merge request resolves some issue link it like so: "Closes #99999" -->
|
||||
<!--
|
||||
If merge request adds some new feature that depends on backend:
|
||||
|
||||
1. Make sure it gracefully degrades if backend hasn't been updated to support the feature,
|
||||
we try to make PleromaFE compatible with older versions of BE so that people can still
|
||||
update frontend safely without updating backend since it's costly and much riskier.
|
||||
2. Link related BE merge request here
|
||||
-->
|
||||
<!-- Screenshots are welcome -->
|
||||
|
||||
/label ~needs-review
|
|
@ -1 +1 @@
|
|||
16.16.0
|
||||
16.18.1
|
||||
|
|
|
@ -48,6 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Enabled users to zoom and pan images in media viewer with mouse and touch
|
||||
- Timelines/panels and conversations have sticky headers now
|
||||
- Added frontend ui for account migration
|
||||
- Implemented remote interaction with statuses
|
||||
|
||||
|
||||
## [2.4.2] - 2022-01-09
|
||||
|
|
|
@ -11,5 +11,6 @@
|
|||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
<script type="text/javascript" src="/instance/pleroma-mod-loader.js"></script>
|
||||
<div id="popovers" />
|
||||
</body>
|
||||
</html>
|
||||
|
|
37
package.json
37
package.json
|
@ -16,7 +16,7 @@
|
|||
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.18.9",
|
||||
"@babel/runtime": "7.20.0",
|
||||
"@chenfengyuan/vue-qrcode": "2.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.2.0",
|
||||
|
@ -24,7 +24,6 @@
|
|||
"@fortawesome/vue-fontawesome": "3.0.1",
|
||||
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
|
||||
"@kazvmoe-infra/unicode-emoji-json": "0.4.0",
|
||||
"@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
|
||||
"@vuelidate/core": "2.0.0",
|
||||
"@vuelidate/validators": "2.0.0",
|
||||
"body-scroll-lock": "3.1.5",
|
||||
|
@ -42,43 +41,43 @@
|
|||
"querystring-es3": "0.2.1",
|
||||
"url": "0.11.0",
|
||||
"utf8": "3.0.0",
|
||||
"vue": "3.2.38",
|
||||
"vue": "3.2.41",
|
||||
"vue-i18n": "9.2.2",
|
||||
"vue-router": "4.1.5",
|
||||
"vue-template-compiler": "2.7.10",
|
||||
"vuex": "4.0.2"
|
||||
"vue-router": "4.1.6",
|
||||
"vue-template-compiler": "2.7.13",
|
||||
"vuex": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.18.13",
|
||||
"@babel/eslint-parser": "7.18.9",
|
||||
"@babel/plugin-transform-runtime": "7.18.10",
|
||||
"@babel/preset-env": "7.18.10",
|
||||
"@babel/core": "7.19.6",
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/plugin-transform-runtime": "7.19.6",
|
||||
"@babel/preset-env": "7.19.4",
|
||||
"@babel/register": "7.18.9",
|
||||
"@intlify/vue-i18n-loader": "5.0.0",
|
||||
"@ungap/event-target": "0.2.3",
|
||||
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
|
||||
"@vue/babel-plugin-jsx": "1.1.1",
|
||||
"@vue/compiler-sfc": "3.2.38",
|
||||
"@vue/test-utils": "2.0.2",
|
||||
"@vue/compiler-sfc": "3.2.41",
|
||||
"@vue/test-utils": "2.2.1",
|
||||
"autoprefixer": "10.4.12",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-lodash": "3.3.4",
|
||||
"chai": "4.3.6",
|
||||
"chai": "4.3.7",
|
||||
"chalk": "1.1.3",
|
||||
"chromedriver": "104.0.0",
|
||||
"connect-history-api-fallback": "2.0.0",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"cross-spawn": "7.0.3",
|
||||
"css-loader": "6.7.1",
|
||||
"css-minimizer-webpack-plugin": "4.0.0",
|
||||
"css-minimizer-webpack-plugin": "4.2.2",
|
||||
"custom-event-polyfill": "1.0.7",
|
||||
"eslint": "8.23.0",
|
||||
"eslint": "8.26.0",
|
||||
"eslint-config-standard": "17.0.0",
|
||||
"eslint-formatter-friendly": "7.0.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-n": "15.2.5",
|
||||
"eslint-plugin-promise": "6.0.1",
|
||||
"eslint-plugin-vue": "9.4.0",
|
||||
"eslint-plugin-n": "15.5.0",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-vue": "9.7.0",
|
||||
"eslint-webpack-plugin": "3.2.0",
|
||||
"eventsource-polyfill": "0.9.6",
|
||||
"express": "4.18.2",
|
||||
|
@ -115,7 +114,7 @@
|
|||
"stylelint": "13.13.1",
|
||||
"stylelint-config-standard": "20.0.0",
|
||||
"stylelint-rscss": "0.4.0",
|
||||
"vue-loader": "17.0.0",
|
||||
"vue-loader": "17.0.1",
|
||||
"vue-style-loader": "4.1.3",
|
||||
"webpack": "5.74.0",
|
||||
"webpack-dev-middleware": "3.7.3",
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
<div id="notifs-sidebar" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
<main
|
||||
id="main-scroller"
|
||||
class="column main"
|
||||
:class="{ '-full-height': isChats || isListEdit }"
|
||||
|
@ -50,7 +50,7 @@
|
|||
</router-link>
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
id="notifs-column"
|
||||
class="column -scrollable"
|
||||
|
@ -73,7 +73,6 @@
|
|||
<UpdateNotification />
|
||||
<div id="modal" />
|
||||
<GlobalNoticeList />
|
||||
<div id="popovers" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -27,16 +27,16 @@
|
|||
&.nativeColor {
|
||||
flex: 0 0 2em;
|
||||
min-width: 2em;
|
||||
align-self: center;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
.computedIndicator,
|
||||
.transparentIndicator {
|
||||
flex: 0 0 2em;
|
||||
min-width: 2em;
|
||||
align-self: center;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
min-height: 100%;
|
||||
}
|
||||
.transparentIndicator {
|
||||
// forgot to install counter-strike source, ooops
|
||||
|
|
|
@ -20,10 +20,12 @@
|
|||
<QuickFilterSettings
|
||||
v-if="!collapsable"
|
||||
:conversation="true"
|
||||
class="rightside-button"
|
||||
/>
|
||||
<QuickViewSettings
|
||||
v-if="!collapsable"
|
||||
:conversation="true"
|
||||
class="rightside-button"
|
||||
/>
|
||||
</div>
|
||||
<div class="conversation-body panel-body">
|
||||
|
@ -58,7 +60,7 @@
|
|||
v-if="shouldShowAncestors"
|
||||
class="thread-ancestors"
|
||||
>
|
||||
<div
|
||||
<article
|
||||
v-for="status in ancestorsOf(diveRoot)"
|
||||
:key="status.id"
|
||||
class="thread-ancestor"
|
||||
|
@ -128,7 +130,7 @@
|
|||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<thread-tree
|
||||
v-for="status in showingTopLevel"
|
||||
|
@ -166,34 +168,36 @@
|
|||
v-if="isLinearView"
|
||||
class="thread-body"
|
||||
>
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status status-fadein panel-body"
|
||||
<article>
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status status-fadein panel-body"
|
||||
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:status-content-properties="statusContentProperties"
|
||||
:set-status-content-property="setStatusContentProperty"
|
||||
:toggle-status-content-property="toggleStatusContentProperty"
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:status-content-properties="statusContentProperties"
|
||||
:set-status-content-property="setStatusContentProperty"
|
||||
:toggle-status-content-property="toggleStatusContentProperty"
|
||||
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import Completion from '../../services/completion/completion.js'
|
||||
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
|
||||
import Popover from 'src/components/popover/popover.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'
|
||||
|
@ -109,18 +110,20 @@ const EmojiInput = {
|
|||
data () {
|
||||
return {
|
||||
input: undefined,
|
||||
caretEl: undefined,
|
||||
highlighted: 0,
|
||||
caret: 0,
|
||||
focused: false,
|
||||
blurTimeout: null,
|
||||
showPicker: false,
|
||||
temporarilyHideSuggestions: false,
|
||||
keepOpen: false,
|
||||
disableClickOutside: false,
|
||||
suggestions: []
|
||||
suggestions: [],
|
||||
overlayStyle: {},
|
||||
pickerShown: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Popover,
|
||||
EmojiPicker,
|
||||
UnicodeDomainIndicator
|
||||
},
|
||||
|
@ -128,15 +131,21 @@ const EmojiInput = {
|
|||
padEmoji () {
|
||||
return this.$store.getters.mergedConfig.padEmoji
|
||||
},
|
||||
preText () {
|
||||
return this.modelValue.slice(0, this.caret)
|
||||
},
|
||||
postText () {
|
||||
return this.modelValue.slice(this.caret)
|
||||
},
|
||||
showSuggestions () {
|
||||
return this.focused &&
|
||||
this.suggestions &&
|
||||
this.suggestions.length > 0 &&
|
||||
!this.showPicker &&
|
||||
!this.pickerShown &&
|
||||
!this.temporarilyHideSuggestions
|
||||
},
|
||||
textAtCaret () {
|
||||
return (this.wordAtCaret || {}).word || ''
|
||||
return this.wordAtCaret?.word
|
||||
},
|
||||
wordAtCaret () {
|
||||
if (this.modelValue && this.caret) {
|
||||
|
@ -188,13 +197,35 @@ const EmojiInput = {
|
|||
|
||||
return emoji.displayText
|
||||
}
|
||||
},
|
||||
onInputScroll () {
|
||||
this.$refs.hiddenOverlay.scrollTo({
|
||||
top: this.input.scrollTop,
|
||||
left: this.input.scrollLeft
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
const { root } = this.$refs
|
||||
const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
|
||||
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
|
||||
if (!input) return
|
||||
this.input = input
|
||||
this.caretEl = hiddenOverlayCaret
|
||||
if (suggestorPopover.setAnchorEl) {
|
||||
suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
|
||||
this.$refs.picker.setAnchorEl(this.caretEl)
|
||||
} else {
|
||||
console.warn('setAnchorEl not found, are we in a unit test?')
|
||||
}
|
||||
const style = getComputedStyle(this.input)
|
||||
this.overlayStyle.padding = style.padding
|
||||
this.overlayStyle.border = style.border
|
||||
this.overlayStyle.margin = style.margin
|
||||
this.overlayStyle.lineHeight = style.lineHeight
|
||||
this.overlayStyle.fontFamily = style.fontFamily
|
||||
this.overlayStyle.fontSize = style.fontSize
|
||||
this.overlayStyle.wordWrap = style.wordWrap
|
||||
this.overlayStyle.whiteSpace = style.whiteSpace
|
||||
this.resize()
|
||||
input.addEventListener('blur', this.onBlur)
|
||||
input.addEventListener('focus', this.onFocus)
|
||||
|
@ -204,6 +235,7 @@ const EmojiInput = {
|
|||
input.addEventListener('click', this.onClickInput)
|
||||
input.addEventListener('transitionend', this.onTransition)
|
||||
input.addEventListener('input', this.onInput)
|
||||
input.addEventListener('scroll', this.onInputScroll)
|
||||
},
|
||||
unmounted () {
|
||||
const { input } = this
|
||||
|
@ -216,45 +248,43 @@ const EmojiInput = {
|
|||
input.removeEventListener('click', this.onClickInput)
|
||||
input.removeEventListener('transitionend', this.onTransition)
|
||||
input.removeEventListener('input', this.onInput)
|
||||
input.removeEventListener('scroll', this.onInputScroll)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showSuggestions: function (newValue) {
|
||||
showSuggestions: function (newValue, oldValue) {
|
||||
this.$emit('shown', newValue)
|
||||
if (newValue) {
|
||||
this.$refs.suggestorPopover.showPopover()
|
||||
} else {
|
||||
this.$refs.suggestorPopover.hidePopover()
|
||||
}
|
||||
},
|
||||
textAtCaret: async function (newWord) {
|
||||
if (newWord === undefined) return
|
||||
const firstchar = newWord.charAt(0)
|
||||
this.suggestions = []
|
||||
if (newWord === firstchar) return
|
||||
if (newWord === firstchar) {
|
||||
this.suggestions = []
|
||||
return
|
||||
}
|
||||
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
|
||||
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
|
||||
this.suggestions = []
|
||||
return
|
||||
}
|
||||
this.suggestions = take(matchedSuggestions, 5)
|
||||
.map(({ imageUrl, ...rest }) => ({
|
||||
...rest,
|
||||
img: imageUrl || ''
|
||||
}))
|
||||
},
|
||||
suggestions: {
|
||||
handler (newValue) {
|
||||
this.$nextTick(this.resize)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
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.$nextTick(() => {
|
||||
this.$refs.picker.showPicker()
|
||||
this.scrollIntoView()
|
||||
this.focusPickerInput()
|
||||
})
|
||||
// This temporarily disables "click outside" handler
|
||||
// since external trigger also means click originates
|
||||
|
@ -266,11 +296,12 @@ const EmojiInput = {
|
|||
},
|
||||
togglePicker () {
|
||||
this.input.focus()
|
||||
this.showPicker = !this.showPicker
|
||||
if (this.showPicker) {
|
||||
if (!this.pickerShown) {
|
||||
this.scrollIntoView()
|
||||
this.$refs.picker.showPicker()
|
||||
this.$refs.picker.startEmojiLoad()
|
||||
this.$nextTick(this.focusPickerInput)
|
||||
} else {
|
||||
this.$refs.picker.hidePicker()
|
||||
}
|
||||
},
|
||||
replace (replacement) {
|
||||
|
@ -307,7 +338,6 @@ const EmojiInput = {
|
|||
spaceAfter,
|
||||
after
|
||||
].join('')
|
||||
this.keepOpen = keepOpen
|
||||
this.$emit('update:modelValue', newValue)
|
||||
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
|
||||
if (!keepOpen) {
|
||||
|
@ -407,8 +437,11 @@ const EmojiInput = {
|
|||
}
|
||||
})
|
||||
},
|
||||
onTransition (e) {
|
||||
this.resize()
|
||||
onPickerShown () {
|
||||
this.pickerShown = true
|
||||
},
|
||||
onPickerClosed () {
|
||||
this.pickerShown = false
|
||||
},
|
||||
onBlur (e) {
|
||||
// Clicking on any suggestion removes focus from autocomplete,
|
||||
|
@ -416,7 +449,6 @@ const EmojiInput = {
|
|||
this.blurTimeout = setTimeout(() => {
|
||||
this.focused = false
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
}, 200)
|
||||
},
|
||||
onClick (e, suggestion) {
|
||||
|
@ -428,18 +460,13 @@ const EmojiInput = {
|
|||
this.blurTimeout = null
|
||||
}
|
||||
|
||||
if (!this.keepOpen) {
|
||||
this.showPicker = false
|
||||
}
|
||||
this.focused = true
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
this.temporarilyHideSuggestions = false
|
||||
},
|
||||
onKeyUp (e) {
|
||||
const { key } = e
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
|
||||
// Setting hider in keyUp to prevent suggestions from blinking
|
||||
// when moving away from suggested spot
|
||||
|
@ -451,7 +478,6 @@ const EmojiInput = {
|
|||
},
|
||||
onPaste (e) {
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
},
|
||||
onKeyDown (e) {
|
||||
const { ctrlKey, shiftKey, key } = e
|
||||
|
@ -496,58 +522,24 @@ const EmojiInput = {
|
|||
this.input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
this.showPicker = false
|
||||
this.resize()
|
||||
},
|
||||
onInput (e) {
|
||||
this.showPicker = false
|
||||
this.setCaret(e)
|
||||
this.resize()
|
||||
this.$emit('update:modelValue', e.target.value)
|
||||
},
|
||||
onClickInput (e) {
|
||||
this.showPicker = false
|
||||
},
|
||||
onClickOutside (e) {
|
||||
if (this.disableClickOutside) return
|
||||
this.showPicker = false
|
||||
},
|
||||
onStickerUploaded (e) {
|
||||
this.showPicker = false
|
||||
this.$emit('sticker-uploaded', e)
|
||||
},
|
||||
onStickerUploadFailed (e) {
|
||||
this.showPicker = false
|
||||
this.$emit('sticker-upload-Failed', e)
|
||||
},
|
||||
setCaret ({ target: { selectionStart } }) {
|
||||
this.caret = selectionStart
|
||||
this.$nextTick(() => {
|
||||
this.$refs.suggestorPopover.updateStyles()
|
||||
})
|
||||
},
|
||||
resize () {
|
||||
const panel = this.$refs.panel
|
||||
if (!panel) return
|
||||
const picker = this.$refs.picker.$el
|
||||
const panelBody = this.$refs['panel-body']
|
||||
const { offsetHeight, offsetTop } = this.input
|
||||
const offsetBottom = offsetTop + offsetHeight
|
||||
|
||||
this.setPlacement(panelBody, panel, offsetBottom)
|
||||
this.setPlacement(picker, picker, offsetBottom)
|
||||
},
|
||||
setPlacement (container, target, offsetBottom) {
|
||||
if (!container || !target) return
|
||||
|
||||
target.style.top = offsetBottom + 'px'
|
||||
target.style.bottom = 'auto'
|
||||
|
||||
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
|
||||
target.style.top = 'auto'
|
||||
target.style.bottom = this.input.offsetHeight + 'px'
|
||||
}
|
||||
},
|
||||
overflowsBottom (el) {
|
||||
return el.getBoundingClientRect().bottom > window.innerHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,23 @@
|
|||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
v-click-outside="onClickOutside"
|
||||
class="emoji-input"
|
||||
:class="{ 'with-picker': !hideEmojiButton }"
|
||||
>
|
||||
<slot />
|
||||
<!-- TODO: make the 'x' disappear if at the end maybe? -->
|
||||
<div
|
||||
ref="hiddenOverlay"
|
||||
class="hidden-overlay"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<span>{{ preText }}</span>
|
||||
<span
|
||||
ref="hiddenOverlayCaret"
|
||||
class="caret"
|
||||
>x</span>
|
||||
<span>{{ postText }}</span>
|
||||
</div>
|
||||
<template v-if="enableEmojiPicker">
|
||||
<button
|
||||
v-if="!hideEmojiButton"
|
||||
|
@ -18,59 +30,61 @@
|
|||
<EmojiPicker
|
||||
v-if="enableEmojiPicker"
|
||||
ref="picker"
|
||||
:class="{ hide: !showPicker }"
|
||||
:showing="showPicker"
|
||||
:enable-sticker-picker="enableStickerPicker"
|
||||
class="emoji-picker-panel"
|
||||
@emoji="insert"
|
||||
@sticker-uploaded="onStickerUploaded"
|
||||
@sticker-upload-failed="onStickerUploadFailed"
|
||||
@show="onPickerShown"
|
||||
@close="onPickerClosed"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
ref="panel"
|
||||
<Popover
|
||||
ref="suggestorPopover"
|
||||
class="autocomplete-panel"
|
||||
:class="{ hide: !showSuggestions }"
|
||||
placement="bottom"
|
||||
>
|
||||
<div
|
||||
ref="panel-body"
|
||||
class="autocomplete-panel-body"
|
||||
>
|
||||
<template #content>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
class="autocomplete-item"
|
||||
:class="{ highlighted: index === highlighted }"
|
||||
@click.stop.prevent="onClick($event, suggestion)"
|
||||
ref="panel-body"
|
||||
class="autocomplete-panel-body"
|
||||
>
|
||||
<span class="image">
|
||||
<img
|
||||
v-if="suggestion.img"
|
||||
:src="suggestion.img"
|
||||
>
|
||||
<span v-else>{{ suggestion.replacement }}</span>
|
||||
</span>
|
||||
<div class="label">
|
||||
<span
|
||||
v-if="suggestion.user"
|
||||
class="displayText"
|
||||
>
|
||||
{{ suggestion.displayText }}<UnicodeDomainIndicator
|
||||
:user="suggestion.user"
|
||||
:at="false"
|
||||
/>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
class="autocomplete-item"
|
||||
:class="{ highlighted: index === highlighted }"
|
||||
@click.stop.prevent="onClick($event, suggestion)"
|
||||
>
|
||||
<span class="image">
|
||||
<img
|
||||
v-if="suggestion.img"
|
||||
:src="suggestion.img"
|
||||
>
|
||||
<span v-else>{{ suggestion.replacement }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="!suggestion.user"
|
||||
class="displayText"
|
||||
>
|
||||
{{ maybeLocalizedEmojiName(suggestion) }}
|
||||
</span>
|
||||
<span class="detailText">{{ suggestion.detailText }}</span>
|
||||
<div class="label">
|
||||
<span
|
||||
v-if="suggestion.user"
|
||||
class="displayText"
|
||||
>
|
||||
{{ suggestion.displayText }}<UnicodeDomainIndicator
|
||||
:user="suggestion.user"
|
||||
:at="false"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="!suggestion.user"
|
||||
class="displayText"
|
||||
>
|
||||
{{ maybeLocalizedEmojiName(suggestion) }}
|
||||
</span>
|
||||
<span class="detailText">{{ suggestion.detailText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -102,6 +116,7 @@
|
|||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
|
@ -112,89 +127,83 @@
|
|||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
&-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
margin-top: 2px;
|
||||
|
||||
&.hide {
|
||||
display: none
|
||||
}
|
||||
|
||||
&-body {
|
||||
margin: 0 0.5em 0 0.5em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--popupShadow);
|
||||
min-width: 75%;
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--popover, $fallback--bg);
|
||||
color: $fallback--link;
|
||||
color: var(--popoverText, $fallback--link);
|
||||
--faint: var(--popoverFaintText, $fallback--faint);
|
||||
--faintLink: var(--popoverFaintLink, $fallback--faint);
|
||||
--lightText: var(--popoverLightText, $fallback--lightText);
|
||||
--postLink: var(--popoverPostLink, $fallback--link);
|
||||
--postFaintLink: var(--popoverPostFaintLink, $fallback--link);
|
||||
--icon: var(--popoverIcon, $fallback--icon);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
height: 32px;
|
||||
|
||||
.image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
|
||||
margin-right: 4px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 0.1em 0 0.2em;
|
||||
|
||||
.displayText {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detailText {
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--selectedMenuPopover, $fallback--fg);
|
||||
color: var(--selectedMenuPopoverText, $fallback--text);
|
||||
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
|
||||
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
|
||||
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
|
||||
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.hidden-overlay {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
/* DEBUG STUFF */
|
||||
color: red;
|
||||
/* set opacity to non-zero to see the overlay */
|
||||
|
||||
.caret {
|
||||
width: 0;
|
||||
margin-right: calc(-1ch - 1px);
|
||||
border: 1px solid red;
|
||||
}
|
||||
}
|
||||
}
|
||||
.autocomplete {
|
||||
&-panel {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.4em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
||||
height: 32px;
|
||||
|
||||
.image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
|
||||
margin-right: 4px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 0.1em 0 0.2em;
|
||||
|
||||
.displayText {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detailText {
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--selectedMenuPopover, $fallback--fg);
|
||||
color: var(--selectedMenuPopoverText, $fallback--text);
|
||||
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
|
||||
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
|
||||
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
|
||||
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { defineAsyncComponent } from 'vue'
|
||||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
import Popover from 'src/components/popover/popover.vue'
|
||||
import StillImage from '../still-image/still-image.vue'
|
||||
import { ensureFinalFallback } from '../../i18n/languages.js'
|
||||
import lozad from 'lozad'
|
||||
|
@ -87,10 +88,6 @@ const EmojiPicker = {
|
|||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showing: {
|
||||
required: true,
|
||||
type: Boolean
|
||||
}
|
||||
},
|
||||
data () {
|
||||
|
@ -111,15 +108,32 @@ const EmojiPicker = {
|
|||
components: {
|
||||
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
|
||||
Checkbox,
|
||||
StillImage
|
||||
StillImage,
|
||||
Popover
|
||||
},
|
||||
methods: {
|
||||
showPicker () {
|
||||
this.$refs.popover.showPopover()
|
||||
this.onShowing()
|
||||
},
|
||||
hidePicker () {
|
||||
this.$refs.popover.hidePopover()
|
||||
},
|
||||
setAnchorEl (el) {
|
||||
this.$refs.popover.setAnchorEl(el)
|
||||
},
|
||||
setGroupRef (name) {
|
||||
return el => { this.groupRefs[name] = el }
|
||||
},
|
||||
setEmojiRef (name) {
|
||||
return el => { this.emojiRefs[name] = el }
|
||||
},
|
||||
onPopoverShown () {
|
||||
this.$emit('show')
|
||||
},
|
||||
onPopoverClosed () {
|
||||
this.$emit('close')
|
||||
},
|
||||
onStickerUploaded (e) {
|
||||
this.$emit('sticker-uploaded', e)
|
||||
},
|
||||
|
@ -128,6 +142,9 @@ const EmojiPicker = {
|
|||
},
|
||||
onEmoji (emoji) {
|
||||
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
|
||||
if (!this.keepOpen) {
|
||||
this.$refs.popover.hidePopover()
|
||||
}
|
||||
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
|
||||
},
|
||||
onScroll (e) {
|
||||
|
@ -223,6 +240,9 @@ const EmojiPicker = {
|
|||
},
|
||||
onShowing () {
|
||||
const oldContentLoaded = this.contentLoaded
|
||||
this.$nextTick(() => {
|
||||
this.$refs.search.focus()
|
||||
})
|
||||
this.contentLoaded = true
|
||||
this.waitForDomAndInitializeLazyLoad()
|
||||
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
|
||||
|
@ -251,16 +271,6 @@ const EmojiPicker = {
|
|||
allCustomGroups () {
|
||||
this.waitForDomAndInitializeLazyLoad()
|
||||
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
|
||||
},
|
||||
showing (val) {
|
||||
if (val) {
|
||||
this.onShowing()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.showing) {
|
||||
this.onShowing()
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
|
|
|
@ -6,14 +6,10 @@ $emoji-picker-header-picture-height: 32px;
|
|||
$emoji-picker-emoji-size: 32px;
|
||||
|
||||
.emoji-picker {
|
||||
width: 25em;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin: 0 !important;
|
||||
// TODO: actually use popover in emoji picker
|
||||
z-index: var(--ZI_popovers);
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--popover, $fallback--bg);
|
||||
color: $fallback--link;
|
||||
|
|
|
@ -1,129 +1,136 @@
|
|||
<template>
|
||||
<div
|
||||
class="emoji-picker panel panel-default panel-body"
|
||||
<Popover
|
||||
ref="popover"
|
||||
trigger="click"
|
||||
popover-class="emoji-picker popover-default"
|
||||
@show="onPopoverShown"
|
||||
@close="onPopoverClosed"
|
||||
>
|
||||
<div class="heading">
|
||||
<span
|
||||
ref="header"
|
||||
class="emoji-tabs"
|
||||
>
|
||||
<template #content>
|
||||
<div class="heading">
|
||||
<span
|
||||
v-for="group in filteredEmojiGroups"
|
||||
:ref="setGroupRef('group-header-' + group.id)"
|
||||
:key="group.id"
|
||||
class="emoji-tabs-item"
|
||||
:class="{
|
||||
active: activeGroupView === group.id
|
||||
}"
|
||||
:title="group.text"
|
||||
@click.prevent="highlight(group.id)"
|
||||
ref="header"
|
||||
class="emoji-tabs"
|
||||
>
|
||||
<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
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="stickerPickerEnabled"
|
||||
class="additional-tabs"
|
||||
>
|
||||
<span
|
||||
class="stickers-tab-icon additional-tabs-item"
|
||||
:class="{active: showingStickers}"
|
||||
:title="$t('emoji.stickers')"
|
||||
@click.prevent="toggleStickers"
|
||||
>
|
||||
<FAIcon
|
||||
icon="sticky-note"
|
||||
fixed-width
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contentLoaded"
|
||||
class="content"
|
||||
>
|
||||
<div
|
||||
class="emoji-content"
|
||||
:class="{hidden: showingStickers}"
|
||||
>
|
||||
<div class="emoji-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="$t('emoji.search_emoji')"
|
||||
@input="$event.target.composing = false"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="emoji-groups"
|
||||
class="emoji-groups"
|
||||
:class="groupsScrolledClass"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="group in filteredEmojiGroups"
|
||||
:ref="setGroupRef('group-header-' + group.id)"
|
||||
:key="group.id"
|
||||
class="emoji-group"
|
||||
class="emoji-tabs-item"
|
||||
:class="{
|
||||
active: activeGroupView === group.id
|
||||
}"
|
||||
:title="group.text"
|
||||
@click.prevent="highlight(group.id)"
|
||||
>
|
||||
<h6
|
||||
:ref="setGroupRef('group-' + group.id)"
|
||||
class="emoji-group-title"
|
||||
>
|
||||
{{ group.text }}
|
||||
</h6>
|
||||
<span
|
||||
v-for="emoji in group.emojis"
|
||||
:key="group.id + emoji.displayText"
|
||||
:title="maybeLocalizedEmojiName(emoji)"
|
||||
class="emoji-item"
|
||||
@click.stop.prevent="onEmoji(emoji)"
|
||||
v-if="group.image"
|
||||
class="emoji-picker-header-image"
|
||||
>
|
||||
<span
|
||||
v-if="!emoji.imageUrl"
|
||||
class="emoji-picker-emoji -unicode"
|
||||
>{{ emoji.replacement }}</span>
|
||||
<still-image
|
||||
v-else
|
||||
:ref="setEmojiRef(group.id + emoji.displayText)"
|
||||
class="emoji-picker-emoji -custom"
|
||||
:data-src="emoji.imageUrl"
|
||||
:data-emoji-name="group.id + emoji.displayText"
|
||||
:alt="group.text"
|
||||
:src="group.image"
|
||||
/>
|
||||
</span>
|
||||
<span :ref="setGroupRef('group-end-' + group.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="keep-open">
|
||||
<Checkbox v-model="keepOpen">
|
||||
{{ $t('emoji.keep_open') }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<FAIcon
|
||||
v-else
|
||||
:icon="group.icon"
|
||||
fixed-width
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="stickerPickerEnabled"
|
||||
class="additional-tabs"
|
||||
>
|
||||
<span
|
||||
class="stickers-tab-icon additional-tabs-item"
|
||||
:class="{active: showingStickers}"
|
||||
:title="$t('emoji.stickers')"
|
||||
@click.prevent="toggleStickers"
|
||||
>
|
||||
<FAIcon
|
||||
icon="sticky-note"
|
||||
fixed-width
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showingStickers"
|
||||
class="stickers-content"
|
||||
v-if="contentLoaded"
|
||||
class="content"
|
||||
>
|
||||
<sticker-picker
|
||||
@uploaded="onStickerUploaded"
|
||||
@upload-failed="onStickerUploadFailed"
|
||||
/>
|
||||
<div
|
||||
class="emoji-content"
|
||||
:class="{hidden: showingStickers}"
|
||||
>
|
||||
<div class="emoji-search">
|
||||
<input
|
||||
ref="search"
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="$t('emoji.search_emoji')"
|
||||
@input="$event.target.composing = false"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="emoji-groups"
|
||||
class="emoji-groups"
|
||||
:class="groupsScrolledClass"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="group in filteredEmojiGroups"
|
||||
:key="group.id"
|
||||
class="emoji-group"
|
||||
>
|
||||
<h6
|
||||
:ref="setGroupRef('group-' + group.id)"
|
||||
class="emoji-group-title"
|
||||
>
|
||||
{{ group.text }}
|
||||
</h6>
|
||||
<span
|
||||
v-for="emoji in group.emojis"
|
||||
:key="group.id + emoji.displayText"
|
||||
:title="maybeLocalizedEmojiName(emoji)"
|
||||
class="emoji-item"
|
||||
@click.stop.prevent="onEmoji(emoji)"
|
||||
>
|
||||
<span
|
||||
v-if="!emoji.imageUrl"
|
||||
class="emoji-picker-emoji -unicode"
|
||||
>{{ emoji.replacement }}</span>
|
||||
<still-image
|
||||
v-else
|
||||
: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="setGroupRef('group-end-' + group.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="keep-open">
|
||||
<Checkbox v-model="keepOpen">
|
||||
{{ $t('emoji.keep_open') }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showingStickers"
|
||||
class="stickers-content"
|
||||
>
|
||||
<sticker-picker
|
||||
@uploaded="onStickerUploaded"
|
||||
@upload-failed="onStickerUploadFailed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script src="./emoji_picker.js"></script>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="emoji-reactions">
|
||||
<div class="EmojiReactions">
|
||||
<UserListPopover
|
||||
v-for="(reaction) in emojiReactions"
|
||||
:key="reaction.name"
|
||||
|
@ -7,7 +7,7 @@
|
|||
>
|
||||
<button
|
||||
class="emoji-reaction btn button-default"
|
||||
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
|
||||
:class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
|
||||
@click="emojiOnClick(reaction.name, $event)"
|
||||
@mouseenter="fetchEmojiReactionsByIfMissing()"
|
||||
>
|
||||
|
@ -30,52 +30,55 @@
|
|||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.emoji-reactions {
|
||||
.EmojiReactions {
|
||||
display: flex;
|
||||
margin-top: 0.25em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.emoji-reaction {
|
||||
padding: 0 0.5em;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
.reaction-emoji {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
.emoji-reaction {
|
||||
padding: 0 0.5em;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.not-clickable {
|
||||
cursor: default;
|
||||
&:hover {
|
||||
box-shadow: $fallback--buttonShadow;
|
||||
box-shadow: var(--buttonShadow);
|
||||
.reaction-emoji {
|
||||
width: 1.25em;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.not-clickable {
|
||||
cursor: default;
|
||||
&:hover {
|
||||
box-shadow: $fallback--buttonShadow;
|
||||
box-shadow: var(--buttonShadow);
|
||||
}
|
||||
}
|
||||
|
||||
&.-picked-reaction {
|
||||
border: 1px solid var(--accent, $fallback--link);
|
||||
margin-left: -1px; // offset the border, can't use inset shadows either
|
||||
margin-right: calc(0.5em - 1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-reaction-expand {
|
||||
padding: 0 0.5em;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
.emoji-reaction-expand {
|
||||
padding: 0 0.5em;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picked-reaction {
|
||||
border: 1px solid var(--accent, $fallback--link);
|
||||
margin-left: -1px; // offset the border, can't use inset shadows either
|
||||
margin-right: calc(0.5em - 1px);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -113,8 +113,7 @@ const ExtraButtons = {
|
|||
currentUser () { return this.$store.state.users.currentUser },
|
||||
canDelete () {
|
||||
if (!this.currentUser) { return }
|
||||
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
|
||||
return superuser || this.status.user.id === this.currentUser.id
|
||||
return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
|
||||
},
|
||||
ownStatus () {
|
||||
return this.status.user.id === this.currentUser.id
|
||||
|
|
|
@ -39,7 +39,10 @@ const FavoriteButton = {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['mergedConfig'])
|
||||
...mapGetters(['mergedConfig']),
|
||||
remoteInteractionLink () {
|
||||
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -33,13 +33,19 @@
|
|||
/>
|
||||
</FALayers>
|
||||
</button>
|
||||
<span v-else>
|
||||
<a
|
||||
v-else
|
||||
class="button-unstyled interactive"
|
||||
target="_blank"
|
||||
role="button"
|
||||
:href="remoteInteractionLink"
|
||||
>
|
||||
<FAIcon
|
||||
class="fa-scale-110 fa-old-padding"
|
||||
:title="$t('tool_tip.favorite')"
|
||||
:icon="['far', 'star']"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<span
|
||||
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
|
||||
class="action-counter"
|
||||
|
|
|
@ -15,7 +15,7 @@ const Interactions = {
|
|||
return {
|
||||
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
|
||||
filterMode: tabModeDict.mentions,
|
||||
canSeeReports: ['moderator', 'admin'].includes(this.$store.state.users.currentUser.role)
|
||||
canSeeReports: this.$store.state.users.currentUser.privileges.includes('reports_manage_reports')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -8,13 +8,17 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
|||
import {
|
||||
faTimes,
|
||||
faBell,
|
||||
faBars
|
||||
faBars,
|
||||
faArrowUp,
|
||||
faMinus
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faTimes,
|
||||
faBell,
|
||||
faBars
|
||||
faBars,
|
||||
faArrowUp,
|
||||
faMinus
|
||||
)
|
||||
|
||||
const MobileNav = {
|
||||
|
@ -25,12 +29,13 @@ const MobileNav = {
|
|||
},
|
||||
data: () => ({
|
||||
notificationsCloseGesture: undefined,
|
||||
notificationsOpen: false
|
||||
notificationsOpen: false,
|
||||
notificationsAtTop: true
|
||||
}),
|
||||
created () {
|
||||
this.notificationsCloseGesture = GestureService.swipeGesture(
|
||||
GestureService.DIRECTION_RIGHT,
|
||||
this.closeMobileNotifications,
|
||||
() => this.closeMobileNotifications(true),
|
||||
50
|
||||
)
|
||||
},
|
||||
|
@ -61,12 +66,14 @@ const MobileNav = {
|
|||
openMobileNotifications () {
|
||||
this.notificationsOpen = true
|
||||
},
|
||||
closeMobileNotifications () {
|
||||
closeMobileNotifications (markRead) {
|
||||
if (this.notificationsOpen) {
|
||||
// make sure to mark notifs seen only when the notifs were open and not
|
||||
// from close-calls.
|
||||
this.notificationsOpen = false
|
||||
this.markNotificationsAsSeen()
|
||||
if (markRead) {
|
||||
this.markNotificationsAsSeen()
|
||||
}
|
||||
}
|
||||
},
|
||||
notificationsTouchStart (e) {
|
||||
|
@ -78,6 +85,9 @@ const MobileNav = {
|
|||
scrollToTop () {
|
||||
window.scrollTo(0, 0)
|
||||
},
|
||||
scrollMobileNotificationsToTop () {
|
||||
this.$refs.mobileNotifications.scrollTo(0, 0)
|
||||
},
|
||||
logout () {
|
||||
this.$router.replace('/main/public')
|
||||
this.$store.dispatch('logout')
|
||||
|
@ -87,6 +97,7 @@ const MobileNav = {
|
|||
this.$store.dispatch('markNotificationsAsSeen')
|
||||
},
|
||||
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
|
||||
this.notificationsAtTop = scrollTop > 0
|
||||
if (scrollTop + clientHeight >= scrollHeight) {
|
||||
this.$refs.notifications.fetchOlderNotifications()
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
<div class="item">
|
||||
<button
|
||||
class="button-unstyled mobile-nav-button"
|
||||
:title="$t('nav.mobile_sidebar')"
|
||||
:aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed"
|
||||
@click.stop.prevent="toggleMobileSidebar()"
|
||||
>
|
||||
<FAIcon
|
||||
|
@ -26,6 +28,7 @@
|
|||
<button
|
||||
v-if="currentUser"
|
||||
class="button-unstyled mobile-nav-button"
|
||||
:title="unseenNotificationsCount ? $t('nav.mobile_notifications_unread_active') : $t('nav.mobile_notifications')"
|
||||
@click.stop.prevent="openMobileNotifications()"
|
||||
>
|
||||
<FAIcon
|
||||
|
@ -39,7 +42,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
<aside
|
||||
v-if="currentUser"
|
||||
class="mobile-notifications-drawer"
|
||||
:class="{ '-closed': !notificationsOpen }"
|
||||
|
@ -48,22 +51,39 @@
|
|||
>
|
||||
<div class="mobile-notifications-header">
|
||||
<span class="title">{{ $t('notifications.notifications') }}</span>
|
||||
<a
|
||||
class="mobile-nav-button"
|
||||
@click.stop.prevent="closeMobileNotifications()"
|
||||
<span class="spacer" />
|
||||
<button
|
||||
v-if="notificationsAtTop"
|
||||
class="button-unstyled mobile-nav-button"
|
||||
:title="$t('general.scroll_to_top')"
|
||||
@click.stop.prevent="scrollMobileNotificationsToTop"
|
||||
>
|
||||
<FALayers class="fa-scale-110 fa-old-padding-layer">
|
||||
<FAIcon icon="arrow-up" />
|
||||
<FAIcon
|
||||
icon="minus"
|
||||
transform="up-7"
|
||||
/>
|
||||
</FALayers>
|
||||
</button>
|
||||
<button
|
||||
class="button-unstyled mobile-nav-button"
|
||||
:title="$t('nav.mobile_notifications_close')"
|
||||
@click.stop.prevent="closeMobileNotifications(true)"
|
||||
>
|
||||
<FAIcon
|
||||
class="fa-scale-110 fa-old-padding"
|
||||
icon="times"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="mobile-notifications"
|
||||
ref="mobileNotifications"
|
||||
class="mobile-notifications"
|
||||
@scroll="onScroll"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<SideDrawer
|
||||
ref="sideDrawer"
|
||||
:logout="logout"
|
||||
|
@ -165,6 +185,10 @@
|
|||
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--topBarShadow);
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.3em;
|
||||
margin-left: 0.6em;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
v-if="isLoggedIn"
|
||||
class="MobilePostButton button-default new-status-button"
|
||||
:class="{ 'hidden': isHidden, 'always-show': isPersistent }"
|
||||
:title="$t('post_status.new_status')"
|
||||
@click="openPostForm"
|
||||
>
|
||||
<FAIcon icon="pen" />
|
||||
|
|
|
@ -41,14 +41,26 @@ const ModerationTools = {
|
|||
tagsSet () {
|
||||
return new Set(this.user.tags)
|
||||
},
|
||||
hasTagPolicy () {
|
||||
return this.$store.state.instance.tagPolicyAvailable
|
||||
canGrantRole () {
|
||||
return this.user.is_local && !this.user.deactivated && this.$store.state.users.currentUser.role === 'admin'
|
||||
},
|
||||
canChangeActivationState () {
|
||||
return this.privileged('users_manage_activation_state')
|
||||
},
|
||||
canDeleteAccount () {
|
||||
return this.privileged('users_delete')
|
||||
},
|
||||
canUseTagPolicy () {
|
||||
return this.$store.state.instance.tagPolicyAvailable && this.privileged('users_manage_tags')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasTag (tagName) {
|
||||
return this.tagsSet.has(tagName)
|
||||
},
|
||||
privileged (privilege) {
|
||||
return this.$store.state.users.currentUser.privileges.includes(privilege)
|
||||
},
|
||||
toggleTag (tag) {
|
||||
const store = this.$store
|
||||
if (this.tagsSet.has(tag)) {
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
>
|
||||
<template #content>
|
||||
<div class="dropdown-menu">
|
||||
<span v-if="user.is_local">
|
||||
<span v-if="canGrantRole">
|
||||
<button
|
||||
class="button-default dropdown-item"
|
||||
@click="toggleRight("admin")"
|
||||
|
@ -24,28 +24,31 @@
|
|||
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="canChangeActivationState || canDeleteAccount"
|
||||
role="separator"
|
||||
class="dropdown-divider"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
v-if="canChangeActivationState"
|
||||
class="button-default dropdown-item"
|
||||
@click="toggleActivationStatus()"
|
||||
>
|
||||
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteAccount"
|
||||
class="button-default dropdown-item"
|
||||
@click="deleteUserDialog(true)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.delete_account') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="hasTagPolicy"
|
||||
v-if="canUseTagPolicy"
|
||||
role="separator"
|
||||
class="dropdown-divider"
|
||||
/>
|
||||
<span v-if="hasTagPolicy">
|
||||
<span v-if="canUseTagPolicy">
|
||||
<button
|
||||
class="button-default dropdown-item"
|
||||
@click="toggleTag(tags.FORCE_NSFW)"
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
@click="toggleCollapse"
|
||||
>
|
||||
<FAIcon
|
||||
class="timelines-chevron"
|
||||
class="navigation-chevron"
|
||||
fixed-width
|
||||
:icon="collapsed ? 'chevron-down' : 'chevron-up'"
|
||||
/>
|
||||
|
@ -146,12 +146,17 @@
|
|||
border: none;
|
||||
}
|
||||
|
||||
.timelines-chevron {
|
||||
.navigation-chevron {
|
||||
margin-left: 0.8em;
|
||||
margin-right: 0.8em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.timelines-chevron {
|
||||
margin-left: 0.8em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.timelines-background {
|
||||
padding: 0 0 0 0.6em;
|
||||
background-color: $fallback--lightBg;
|
||||
|
|
|
@ -39,10 +39,8 @@
|
|||
height: 0.5em;
|
||||
width: 0.5em;
|
||||
position: absolute;
|
||||
right: calc(50% - 0.25em);
|
||||
top: calc(50% - 0.25em);
|
||||
margin-left: 6px;
|
||||
margin-top: -6px;
|
||||
right: calc(50% - 0.75em);
|
||||
top: calc(50% - 0.5em);
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
}
|
||||
|
@ -63,7 +61,7 @@
|
|||
|
||||
&.router-link-active {
|
||||
color: $fallback--text;
|
||||
color: var(--selectedMenuText, $fallback--text);
|
||||
color: var(--panelText, $fallback--text);
|
||||
border-bottom: 4px solid;
|
||||
|
||||
& .svg-inline--fa,
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<template>
|
||||
<Status
|
||||
<article
|
||||
v-if="notification.type === 'mention'"
|
||||
class="Notification"
|
||||
:compact="true"
|
||||
:statusoid="notification.status"
|
||||
/>
|
||||
<div v-else>
|
||||
>
|
||||
<Status
|
||||
class="Notification"
|
||||
:compact="true"
|
||||
:statusoid="notification.status"
|
||||
/>
|
||||
</article>
|
||||
<article v-else>
|
||||
<div
|
||||
v-if="needMute && !unmuted"
|
||||
class="Notification container -muted"
|
||||
|
@ -226,7 +229,7 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="./notification.js"></script>
|
||||
|
|
|
@ -109,22 +109,3 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.NotificationFilters {
|
||||
align-self: stretch;
|
||||
|
||||
> button {
|
||||
line-height: 100%;
|
||||
height: 100%;
|
||||
width: var(--__panel-heading-height-inner);
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -10,10 +10,12 @@ import {
|
|||
} from '../../services/notification_utils/notification_utils.js'
|
||||
import FaviconService from '../../services/favicon_service/favicon_service.js'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCircleNotch, faArrowUp, faMinus } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faCircleNotch
|
||||
faCircleNotch,
|
||||
faArrowUp,
|
||||
faMinus
|
||||
)
|
||||
|
||||
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
|
||||
|
@ -34,6 +36,7 @@ const Notifications = {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
showScrollTop: false,
|
||||
bottomedOut: false,
|
||||
// How many seen notifications to display in the list. The more there are,
|
||||
// the heavier the page becomes. This count is increased when loading
|
||||
|
@ -90,8 +93,20 @@ const Notifications = {
|
|||
notificationsToDisplay () {
|
||||
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
|
||||
},
|
||||
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
|
||||
...mapGetters(['unreadChatCount'])
|
||||
},
|
||||
mounted () {
|
||||
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
|
||||
if (!this.scrollerRef) {
|
||||
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
|
||||
}
|
||||
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
|
||||
},
|
||||
unmounted () {
|
||||
if (!this.scrollerRef) return
|
||||
this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
|
||||
},
|
||||
watch: {
|
||||
unseenCountTitle (count) {
|
||||
if (count > 0) {
|
||||
|
@ -101,9 +116,29 @@ const Notifications = {
|
|||
FaviconService.clearFaviconBadge()
|
||||
this.$store.dispatch('setPageTitle', '')
|
||||
}
|
||||
},
|
||||
teleportTarget () {
|
||||
// handle scroller change
|
||||
this.$nextTick(() => {
|
||||
this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition)
|
||||
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
|
||||
if (!this.scrollerRef) {
|
||||
this.scrollerRef = this.$refs.root.closest('.mobile-notifications')
|
||||
}
|
||||
this.scrollerRef.addEventListener('scroll', this.updateScrollPosition)
|
||||
this.updateScrollPosition()
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrollToTop () {
|
||||
const scrollable = this.scrollerRef
|
||||
scrollable.scrollTo({ top: this.$refs.root.offsetTop })
|
||||
// this.$refs.root.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
},
|
||||
updateScrollPosition () {
|
||||
this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop
|
||||
},
|
||||
markAsSeen () {
|
||||
this.$store.dispatch('markNotificationsAsSeen')
|
||||
this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
:disabled="minimalMode || disableTeleport"
|
||||
:to="teleportTarget"
|
||||
>
|
||||
<div
|
||||
<component
|
||||
:is="noHeading ? 'div' : 'aside'"
|
||||
ref="root"
|
||||
:class="{ minimal: minimalMode }"
|
||||
class="Notifications"
|
||||
>
|
||||
|
@ -19,19 +21,43 @@
|
|||
class="badge badge-notification unseen-count"
|
||||
>{{ unseenCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showScrollTop"
|
||||
class="rightside-button"
|
||||
>
|
||||
<button
|
||||
class="button-unstyled scroll-to-top-button"
|
||||
type="button"
|
||||
:title="$t('general.scroll_to_top')"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<FALayers class="fa-scale-110 fa-old-padding-layer">
|
||||
<FAIcon icon="arrow-up" />
|
||||
<FAIcon
|
||||
icon="minus"
|
||||
transform="up-7"
|
||||
/>
|
||||
</FALayers>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="unseenCount"
|
||||
class="button-default read-button"
|
||||
type="button"
|
||||
@click.prevent="markAsSeen"
|
||||
>
|
||||
{{ $t('notifications.read') }}
|
||||
</button>
|
||||
<NotificationFilters />
|
||||
<NotificationFilters class="rightside-button" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
class="panel-body"
|
||||
role="feed"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notificationsToDisplay"
|
||||
:key="notification.id"
|
||||
role="listitem"
|
||||
class="notification"
|
||||
:class="{unseen: !minimalMode && !notification.seen}"
|
||||
>
|
||||
|
@ -67,7 +93,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -43,7 +43,12 @@ const Popover = {
|
|||
overlayCentersSelector: String,
|
||||
|
||||
// Lets hover popover stay when clicking inside of it
|
||||
stayOnClick: Boolean
|
||||
stayOnClick: Boolean,
|
||||
|
||||
triggerAttrs: {
|
||||
type: Object,
|
||||
default: {}
|
||||
}
|
||||
},
|
||||
inject: ['popoversZLayer'], // override popover z layer
|
||||
data () {
|
||||
|
@ -51,6 +56,10 @@ const Popover = {
|
|||
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
|
||||
// so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
|
||||
// with popovers refusing to be hidden when user wants to interact with something in below popover
|
||||
anchorEl: null,
|
||||
// There's an issue where having teleport enabled by default causes things just...
|
||||
// not render at all, i.e. main post status form and its emoji inputs
|
||||
teleport: false,
|
||||
lockReEntry: false,
|
||||
hidden: true,
|
||||
styles: {},
|
||||
|
@ -59,10 +68,15 @@ const Popover = {
|
|||
// used to avoid blinking if hovered onto popover
|
||||
graceTimeout: null,
|
||||
parentPopover: null,
|
||||
disableClickOutside: false,
|
||||
childrenShown: new Set()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setAnchorEl (el) {
|
||||
this.anchorEl = el
|
||||
this.updateStyles()
|
||||
},
|
||||
containerBoundingClientRect () {
|
||||
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
|
||||
return container.getBoundingClientRect()
|
||||
|
@ -75,7 +89,7 @@ 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 v-slot:trigger.
|
||||
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
|
||||
const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
|
||||
// SVGs don't have offsetWidth/Height, use fallback
|
||||
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
|
||||
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
|
||||
|
@ -226,6 +240,10 @@ const Popover = {
|
|||
},
|
||||
showPopover () {
|
||||
if (this.disabled) return
|
||||
this.disableClickOutside = true
|
||||
setTimeout(() => {
|
||||
this.disableClickOutside = false
|
||||
}, 0)
|
||||
const wasHidden = this.hidden
|
||||
this.hidden = false
|
||||
this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
|
||||
|
@ -286,6 +304,7 @@ const Popover = {
|
|||
}
|
||||
},
|
||||
onClickOutside (e) {
|
||||
if (this.disableClickOutside) return
|
||||
if (this.hidden) return
|
||||
if (this.$refs.content && this.$refs.content.contains(e.target)) return
|
||||
if (this.$el.contains(e.target)) return
|
||||
|
@ -319,6 +338,7 @@ const Popover = {
|
|||
}
|
||||
},
|
||||
mounted () {
|
||||
this.teleport = true
|
||||
let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
|
||||
this.$refs.trigger.closest('.mobile-notifications')
|
||||
if (!scrollable) scrollable = window
|
||||
|
|
|
@ -7,11 +7,15 @@
|
|||
ref="trigger"
|
||||
class="button-unstyled popover-trigger-button"
|
||||
type="button"
|
||||
v-bind="triggerAttrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</button>
|
||||
<teleport to="#popovers">
|
||||
<teleport
|
||||
:disabled="!teleport"
|
||||
to="#popovers"
|
||||
>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="!hidden"
|
||||
|
|
|
@ -502,7 +502,6 @@ const PostStatusForm = {
|
|||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
this.$emit('resize')
|
||||
this.$refs['emoji-input'].resize()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -589,8 +588,6 @@ const PostStatusForm = {
|
|||
} else {
|
||||
scrollerRef.scrollTop = targetScroll
|
||||
}
|
||||
|
||||
this.$refs['emoji-input'].resize()
|
||||
},
|
||||
showEmojiPicker () {
|
||||
this.$refs.textarea.focus()
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
trigger="click"
|
||||
class="QuickFilterSettings"
|
||||
:bound-to="{ x: 'container' }"
|
||||
:trigger-attrs="{ title: $t('timeline.quick_filter_settings') }"
|
||||
>
|
||||
<template #content>
|
||||
<div class="dropdown-menu">
|
||||
|
@ -79,29 +80,9 @@
|
|||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<button class="button-unstyled">
|
||||
<FAIcon icon="filter" />
|
||||
</button>
|
||||
<FAIcon icon="filter" />
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script src="./quick_filter_settings.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.QuickFilterSettings {
|
||||
|
||||
> button {
|
||||
line-height: 100%;
|
||||
height: 100%;
|
||||
width: var(--__panel-heading-height-inner);
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
trigger="click"
|
||||
class="QuickViewSettings"
|
||||
:bound-to="{ x: 'container' }"
|
||||
:trigger-attrs="{ title: $t('timeline.quick_view_settings') }"
|
||||
>
|
||||
<template #content>
|
||||
<div class="dropdown-menu">
|
||||
|
@ -66,29 +67,9 @@
|
|||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<button class="button-unstyled">
|
||||
<FAIcon icon="bars" />
|
||||
</button>
|
||||
<FAIcon icon="bars" />
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script src="./quick_view_settings.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.QuickViewSettings {
|
||||
|
||||
> button {
|
||||
line-height: 100%;
|
||||
height: 100%;
|
||||
width: var(--__panel-heading-height-inner);
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
import { ensureFinalFallback } from '../../i18n/languages.js'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
|
||||
|
@ -48,43 +49,73 @@ const ReactButton = {
|
|||
const input = this.$el.querySelector('input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
},
|
||||
// Vaguely adjusted copypaste from emoji_input and emoji_picker!
|
||||
maybeLocalizedEmojiNamesAndKeywords (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 (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
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
commonEmojis () {
|
||||
return [
|
||||
{ displayText: 'lying', replacement: '🤥' },
|
||||
{ displayText: 'thinking', replacement: '🤔' },
|
||||
{ displayText: 'zany', replacement: '🤪' },
|
||||
{ displayText: 'cartwheeling', replacement: '🤸♂️' },
|
||||
{ displayText: 'pills', replacement: '💊' },
|
||||
{ displayText: 'writing', replacement: '✍️' },
|
||||
{ displayText: 'pencil', replacement: '✏️' },
|
||||
{ displayText: 'chart_up', replacement: '📈' },
|
||||
{ displayText: 'chart_down', replacement: '📉' },
|
||||
{ displayText: 'question', replacement: '❔' },
|
||||
{ displayText: 'x', replacement: '❌' },
|
||||
{ displayText: 'orangutan', replacement: '🦧' },
|
||||
{ displayText: 'owl', replacement: '🦉' },
|
||||
{ displayText: 'bottle', replacement: '🍼' },
|
||||
{ displayText: 'crayon', replacement: '🖍️' },
|
||||
{ displayText: 'wrench', replacement: '🔧' },
|
||||
{ displayText: 'blackula', replacement: '🧛🏿' }
|
||||
]
|
||||
const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥'])
|
||||
return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement))
|
||||
},
|
||||
languages () {
|
||||
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
|
||||
},
|
||||
emojis () {
|
||||
if (this.filterWord !== '') {
|
||||
const filterWordLowercase = trim(this.filterWord.toLowerCase())
|
||||
const keywordLowercase = trim(this.filterWord.toLowerCase())
|
||||
|
||||
const orderedEmojiList = []
|
||||
for (const emoji of this.$store.getters.standardEmojiList) {
|
||||
if (emoji.replacement === this.filterWord) return [emoji]
|
||||
const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji)
|
||||
.keywords
|
||||
.map(k => k.toLowerCase().indexOf(keywordLowercase))
|
||||
.filter(k => k > -1)
|
||||
|
||||
const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
|
||||
if (indexOfFilterWord > -1) {
|
||||
if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) {
|
||||
orderedEmojiList[indexOfFilterWord] = []
|
||||
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
|
||||
|
||||
if (indexOfKeyword > -1) {
|
||||
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
|
||||
orderedEmojiList[indexOfKeyword] = []
|
||||
}
|
||||
orderedEmojiList[indexOfFilterWord].push(emoji)
|
||||
orderedEmojiList[indexOfKeyword].push(emoji)
|
||||
}
|
||||
}
|
||||
return orderedEmojiList.flat()
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
v-for="emoji in commonEmojis"
|
||||
:key="emoji.replacement"
|
||||
class="emoji-button"
|
||||
:title="emoji.displayText"
|
||||
:title="maybeLocalizedEmojiName(emoji)"
|
||||
@click="addReaction($event, emoji.replacement, close, keepReactOpen)"
|
||||
>
|
||||
{{ emoji.replacement }}
|
||||
|
@ -39,7 +39,7 @@
|
|||
v-for="(emoji, key) in emojis"
|
||||
:key="key"
|
||||
class="emoji-button"
|
||||
:title="emoji.displayText"
|
||||
:title="maybeLocalizedEmojiName(emoji)"
|
||||
@click="addReaction($event, emoji.replacement, close, keepReactOpen)"
|
||||
>
|
||||
{{ emoji.replacement }}
|
||||
|
|
|
@ -17,6 +17,9 @@ const ReplyButton = {
|
|||
computed: {
|
||||
loggedIn () {
|
||||
return !!this.$store.state.users.currentUser
|
||||
},
|
||||
remoteInteractionLink () {
|
||||
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,13 +26,19 @@
|
|||
/>
|
||||
</FALayers>
|
||||
</button>
|
||||
<span v-else>
|
||||
<a
|
||||
v-else
|
||||
class="button-unstyled interactive"
|
||||
target="_blank"
|
||||
role="button"
|
||||
:href="remoteInteractionLink"
|
||||
>
|
||||
<FAIcon
|
||||
icon="reply"
|
||||
class="fa-scale-110 fa-old-padding"
|
||||
:title="$t('tool_tip.reply')"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<span
|
||||
v-if="status.replies_count > 0"
|
||||
class="action-counter"
|
||||
|
|
|
@ -36,6 +36,9 @@ const RetweetButton = {
|
|||
computed: {
|
||||
mergedConfig () {
|
||||
return this.$store.getters.mergedConfig
|
||||
},
|
||||
remoteInteractionLink () {
|
||||
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,13 +40,19 @@
|
|||
:title="$t('timeline.no_retweet_hint')"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>
|
||||
<a
|
||||
v-else
|
||||
class="button-unstyled interactive"
|
||||
target="_blank"
|
||||
role="button"
|
||||
:href="remoteInteractionLink"
|
||||
>
|
||||
<FAIcon
|
||||
class="fa-scale-110 fa-old-padding"
|
||||
icon="retweet"
|
||||
:title="$t('tool_tip.repeat')"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<span
|
||||
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
|
||||
class="no-event"
|
||||
|
|
|
@ -150,6 +150,7 @@ export default {
|
|||
if (Array.isArray(item)) {
|
||||
const [opener, children, closer] = item
|
||||
const Tag = getTagName(opener)
|
||||
const fullAttrs = getAttrs(opener, () => true)
|
||||
const attrs = getAttrs(opener)
|
||||
const previouslyMentions = currentMentions !== null
|
||||
/* During grouping of mentions we trim all the empty text elements
|
||||
|
@ -171,7 +172,7 @@ export default {
|
|||
return ['', [mentionsLinePadding, renderImage(opener)], '']
|
||||
case 'a': // replace mentions with MentionLink
|
||||
if (!this.handleLinks) break
|
||||
if (attrs['class'] && attrs['class'].includes('mention')) {
|
||||
if (fullAttrs.class && fullAttrs.class.includes('mention')) {
|
||||
// Handling mentions here
|
||||
return renderMention(attrs, children)
|
||||
} else {
|
||||
|
@ -179,7 +180,7 @@ export default {
|
|||
break
|
||||
}
|
||||
case 'span':
|
||||
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
|
||||
if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) {
|
||||
return ['', children.map(processItem), '']
|
||||
}
|
||||
}
|
||||
|
@ -213,13 +214,14 @@ export default {
|
|||
const [opener, children] = item
|
||||
const Tag = opener === '' ? '' : getTagName(opener)
|
||||
switch (Tag) {
|
||||
case 'a': // replace mentions with MentionLink
|
||||
case 'a': { // replace mentions with MentionLink
|
||||
if (!this.handleLinks) break
|
||||
const attrs = getAttrs(opener)
|
||||
const fullAttrs = getAttrs(opener, () => true)
|
||||
const attrs = getAttrs(opener, () => true)
|
||||
// should only be this
|
||||
if (
|
||||
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
|
||||
(attrs['rel'] === 'tag') // Mastodon style
|
||||
(fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
|
||||
(fullAttrs.rel === 'tag') // Mastodon style
|
||||
) {
|
||||
return renderHashtag(attrs, children, encounteredTextReverse)
|
||||
} else {
|
||||
|
@ -230,6 +232,7 @@ export default {
|
|||
{ newChildren }
|
||||
</a>
|
||||
}
|
||||
}
|
||||
case '':
|
||||
return [...children].reverse().map(processItemReverse).reverse()
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
faCircleNotch,
|
||||
faSearch
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { uniqBy } from 'lodash'
|
||||
|
||||
library.add(
|
||||
faCircleNotch,
|
||||
|
@ -32,7 +33,11 @@ const Search = {
|
|||
userIds: [],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
currenResultTab: 'statuses'
|
||||
currenResultTab: 'statuses',
|
||||
|
||||
statusesOffset: 0,
|
||||
lastStatusFetchCount: 0,
|
||||
lastQuery: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -61,26 +66,42 @@ const Search = {
|
|||
this.$router.push({ name: 'search', query: { query } })
|
||||
this.$refs.searchInput.focus()
|
||||
},
|
||||
search (query) {
|
||||
search (query, searchType = null) {
|
||||
if (!query) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.userIds = []
|
||||
this.statuses = []
|
||||
this.hashtags = []
|
||||
this.$refs.searchInput.blur()
|
||||
if (this.lastQuery !== query) {
|
||||
this.userIds = []
|
||||
this.hashtags = []
|
||||
this.statuses = []
|
||||
|
||||
this.$store.dispatch('search', { q: query, resolve: true })
|
||||
this.statusesOffset = 0
|
||||
this.lastStatusFetchCount = 0
|
||||
}
|
||||
|
||||
this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType })
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
this.userIds = map(data.accounts, 'id')
|
||||
this.statuses = data.statuses
|
||||
this.hashtags = data.hashtags
|
||||
|
||||
const oldLength = this.statuses.length
|
||||
|
||||
// Always append to old results. If new results are empty, this doesn't change anything
|
||||
this.userIds = this.userIds.concat(map(data.accounts, 'id'))
|
||||
this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id')
|
||||
this.hashtags = this.hashtags.concat(data.hashtags)
|
||||
|
||||
this.currenResultTab = this.getActiveTab()
|
||||
this.loaded = true
|
||||
|
||||
// Offset from whatever we already have
|
||||
this.statusesOffset = this.statuses.length
|
||||
// Because the amount of new statuses can actually be zero, compare to old lenght instead
|
||||
this.lastStatusFetchCount = this.statuses.length - oldLength
|
||||
this.lastQuery = query
|
||||
})
|
||||
},
|
||||
resultCount (tabName) {
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
v-if="loading && statusesOffset == 0"
|
||||
class="text-center loading-icon"
|
||||
>
|
||||
<FAIcon
|
||||
|
@ -55,12 +55,6 @@
|
|||
</div>
|
||||
<div class="panel-body">
|
||||
<div v-if="currenResultTab === 'statuses'">
|
||||
<div
|
||||
v-if="visibleStatuses.length === 0 && !loading && loaded"
|
||||
class="search-result-heading"
|
||||
>
|
||||
<h4>{{ $t('search.no_results') }}</h4>
|
||||
</div>
|
||||
<Status
|
||||
v-for="status in visibleStatuses"
|
||||
:key="status.id"
|
||||
|
@ -71,6 +65,33 @@
|
|||
:statusoid="status"
|
||||
:no-heading="false"
|
||||
/>
|
||||
<button
|
||||
v-if="!loading && loaded && lastStatusFetchCount > 0"
|
||||
class="more-statuses-button button-unstyled -link -fullwidth"
|
||||
@click.prevent="search(searchTerm, 'statuses')"
|
||||
>
|
||||
<div class="new-status-notification text-center">
|
||||
{{ $t('search.load_more') }}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-else-if="loading && statusesOffset > 0"
|
||||
class="text-center loading-icon"
|
||||
>
|
||||
<FAIcon
|
||||
icon="circle-notch"
|
||||
spin
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded"
|
||||
class="search-result-heading"
|
||||
>
|
||||
<h4>
|
||||
{{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="currenResultTab === 'people'">
|
||||
<div
|
||||
|
@ -208,6 +229,11 @@
|
|||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-statuses-button {
|
||||
height: 3.5em;
|
||||
line-height: 3.5em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -13,7 +13,7 @@ const StaffPanel = {
|
|||
},
|
||||
computed: {
|
||||
groupedStaffAccounts () {
|
||||
const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _)
|
||||
const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _)
|
||||
const groupedStaffAccounts = groupBy(staffAccounts, 'role')
|
||||
|
||||
return [
|
||||
|
@ -22,7 +22,7 @@ const StaffPanel = {
|
|||
].filter(group => group.users)
|
||||
},
|
||||
...mapGetters([
|
||||
'findUser'
|
||||
'findUserByName'
|
||||
]),
|
||||
...mapState({
|
||||
staffAccounts: state => state.instance.staffAccounts
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="thread-tree">
|
||||
<article class="thread-tree">
|
||||
<status
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
|
@ -113,7 +113,7 @@
|
|||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="./thread_tree.js"></script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Status from '../status/status.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
|
||||
import Conversation from '../conversation/conversation.vue'
|
||||
import TimelineMenu from '../timeline_menu/timeline_menu.vue'
|
||||
|
@ -6,11 +7,15 @@ import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.
|
|||
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
|
||||
import { debounce, throttle, keyBy } from 'lodash'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCircleNotch, faCirclePlus, faCog, faMinus, faArrowUp, faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faCircleNotch,
|
||||
faCog
|
||||
faCog,
|
||||
faMinus,
|
||||
faArrowUp,
|
||||
faCirclePlus,
|
||||
faCheck
|
||||
)
|
||||
|
||||
const Timeline = {
|
||||
|
@ -29,6 +34,7 @@ const Timeline = {
|
|||
],
|
||||
data () {
|
||||
return {
|
||||
showScrollTop: false,
|
||||
paused: false,
|
||||
unfocused: false,
|
||||
bottomedOut: false,
|
||||
|
@ -63,6 +69,13 @@ const Timeline = {
|
|||
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
|
||||
}
|
||||
},
|
||||
mobileLoadButtonString () {
|
||||
if (this.timeline.flushMarker !== 0) {
|
||||
return '+'
|
||||
} else {
|
||||
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
|
||||
}
|
||||
},
|
||||
classes () {
|
||||
let rootClasses = !this.embedded ? ['panel', 'panel-default'] : ['-nonpanel']
|
||||
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
|
||||
|
@ -87,7 +100,10 @@ const Timeline = {
|
|||
},
|
||||
virtualScrollingEnabled () {
|
||||
return this.$store.getters.mergedConfig.virtualScrolling
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
mobileLayout: state => state.interface.layoutType === 'mobile'
|
||||
})
|
||||
},
|
||||
created () {
|
||||
const store = this.$store
|
||||
|
@ -123,6 +139,9 @@ const Timeline = {
|
|||
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
|
||||
},
|
||||
methods: {
|
||||
scrollToTop () {
|
||||
window.scrollTo({ top: this.$el.offsetTop })
|
||||
},
|
||||
stopBlockingClicks: debounce(function () {
|
||||
this.blockingClicks = false
|
||||
}, 1000),
|
||||
|
@ -222,6 +241,7 @@ const Timeline = {
|
|||
}
|
||||
},
|
||||
handleScroll: throttle(function (e) {
|
||||
this.showScrollTop = this.$el.offsetTop < window.scrollY
|
||||
this.determineVisibleStatuses()
|
||||
this.scrollLoad(e)
|
||||
}, 200),
|
||||
|
|
|
@ -1,8 +1,35 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.Timeline {
|
||||
.loadmore-text {
|
||||
opacity: 1;
|
||||
.alert-dot {
|
||||
border-radius: 100%;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
position: absolute;
|
||||
left: calc(50% - 4px);
|
||||
top: calc(50% - 4px);
|
||||
margin-left: 6px;
|
||||
margin-top: -6px;
|
||||
background-color: var(--badgeNeutral);
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
font-size: 0.75em;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
border-radius: var(--tooltipRadius);
|
||||
position: absolute;
|
||||
left: calc(50% - 0.5em);
|
||||
top: calc(50% - 0.4em);
|
||||
padding: 0.2em;
|
||||
margin-left: 0.7em;
|
||||
margin-top: -1em;
|
||||
background-color: var(--badgeNeutral);
|
||||
color: var(--badgeNeutralText);
|
||||
}
|
||||
|
||||
.loadmore-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.-blocked {
|
||||
|
|
|
@ -5,31 +5,92 @@
|
|||
v-if="!embedded"
|
||||
:timeline-name="timelineName"
|
||||
/>
|
||||
<button
|
||||
v-if="showLoadButton"
|
||||
class="button-default loadmore-button"
|
||||
@click.prevent="showNewStatuses"
|
||||
>
|
||||
{{ loadButtonString }}
|
||||
</button>
|
||||
<div
|
||||
v-else-if="!embedded"
|
||||
class="loadmore-text faint"
|
||||
@click.prevent
|
||||
v-if="showScrollTop && !embedded"
|
||||
class="rightside-button"
|
||||
>
|
||||
{{ $t('timeline.up_to_date') }}
|
||||
<button
|
||||
class="button-unstyled scroll-to-top-button"
|
||||
type="button"
|
||||
:title="$t('general.scroll_to_top')"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<FALayers class="fa-scale-110 fa-old-padding-layer">
|
||||
<FAIcon icon="arrow-up" />
|
||||
<FAIcon
|
||||
icon="minus"
|
||||
transform="up-7"
|
||||
/>
|
||||
</FALayers>
|
||||
</button>
|
||||
</div>
|
||||
<QuickFilterSettings v-if="!embedded" />
|
||||
<QuickViewSettings v-if="!embedded" />
|
||||
<template v-if="mobileLayout && !embedded">
|
||||
<div
|
||||
v-if="showLoadButton"
|
||||
class="rightside-button"
|
||||
>
|
||||
<button
|
||||
class="button-unstyled loadmore-button"
|
||||
:title="loadButtonString"
|
||||
@click.prevent="showNewStatuses"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
icon="circle-plus"
|
||||
/>
|
||||
<div class="alert-badge">
|
||||
{{ mobileLoadButtonString }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!embedded"
|
||||
class="loadmore-text faint veryfaint rightside-icon"
|
||||
:title="$t('timeline.up_to_date')"
|
||||
:aria-disabled="true"
|
||||
@click.prevent
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
icon="check"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="showLoadButton"
|
||||
class="button-default loadmore-button"
|
||||
@click.prevent="showNewStatuses"
|
||||
>
|
||||
{{ loadButtonString }}
|
||||
</button>
|
||||
<div
|
||||
v-else-if="!embedded"
|
||||
class="loadmore-text faint"
|
||||
@click.prevent
|
||||
>
|
||||
{{ $t('timeline.up_to_date') }}
|
||||
</div>
|
||||
</template>
|
||||
<QuickFilterSettings
|
||||
v-if="!embedded"
|
||||
class="rightside-button"
|
||||
/>
|
||||
<QuickViewSettings
|
||||
v-if="!embedded"
|
||||
class="rightside-button"
|
||||
/>
|
||||
</div>
|
||||
<div :class="classes.body">
|
||||
<div
|
||||
ref="timeline"
|
||||
class="timeline"
|
||||
role="feed"
|
||||
>
|
||||
<conversation
|
||||
v-for="statusId in filteredPinnedStatusIds"
|
||||
:key="statusId + '-pinned'"
|
||||
role="listitem"
|
||||
class="status-fadein"
|
||||
:status-id="statusId"
|
||||
:collapsable="true"
|
||||
|
@ -40,6 +101,7 @@
|
|||
<conversation
|
||||
v-for="status in filteredVisibleStatuses"
|
||||
:key="status.id"
|
||||
role="listitem"
|
||||
class="status-fadein"
|
||||
:status-id="status.id"
|
||||
:collapsable="true"
|
||||
|
|
|
@ -125,6 +125,10 @@ export default {
|
|||
hideFollowersCount () {
|
||||
return this.isOtherUser && this.user.hide_followers_count
|
||||
},
|
||||
showModerationMenu () {
|
||||
const privileges = this.loggedIn.privileges
|
||||
return this.loggedIn.role === 'admin' || privileges.includes('users_manage_activation_state') || privileges.includes('users_delete') || privileges.includes('users_manage_tags')
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -258,7 +258,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<ModerationTools
|
||||
v-if="loggedIn.role === "admin""
|
||||
v-if="showModerationMenu"
|
||||
:user="user"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div
|
||||
<aside
|
||||
class="user-panel"
|
||||
:class="{ compact: compactUserPanel }"
|
||||
>
|
||||
|
@ -19,7 +19,7 @@
|
|||
v-else
|
||||
key="user-panel"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script src="./user_panel.js"></script>
|
||||
|
|
|
@ -84,6 +84,7 @@
|
|||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peek": "Peek",
|
||||
"scroll_to_top": "Scroll to top",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"moderator": "Moderator"
|
||||
|
@ -157,7 +158,11 @@
|
|||
"lists": "Lists",
|
||||
"edit_nav_mobile": "Customize navigation bar",
|
||||
"edit_pinned": "Edit pinned items",
|
||||
"edit_finish": "Done editing"
|
||||
"edit_finish": "Done editing",
|
||||
"mobile_sidebar": "Toggle mobile sidebar",
|
||||
"mobile_notifications": "Open notifications",
|
||||
"mobile_notifications": "Open notifications (there are unread ones)",
|
||||
"mobile_notifications_close": "Close notifications"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Unknown status, searching for it…",
|
||||
|
@ -814,7 +819,9 @@
|
|||
"no_more_statuses": "No more statuses",
|
||||
"no_statuses": "No statuses",
|
||||
"socket_reconnected": "Realtime connection established",
|
||||
"socket_broke": "Realtime connection lost: CloseEvent code {0}"
|
||||
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
|
||||
"quick_view_settings": "Quick view settings",
|
||||
"quick_filter_settings": "Quick filter settings"
|
||||
},
|
||||
"status": {
|
||||
"favorites": "Favorites",
|
||||
|
@ -987,7 +994,9 @@
|
|||
"hashtags": "Hashtags",
|
||||
"person_talking": "{count} person talking",
|
||||
"people_talking": "{count} people talking",
|
||||
"no_results": "No results"
|
||||
"no_results": "No results",
|
||||
"no_more_results": "No more results",
|
||||
"load_more": "Load more results"
|
||||
},
|
||||
"password_reset": {
|
||||
"forgot_password": "Forgot password?",
|
||||
|
|
|
@ -216,7 +216,7 @@ const api = {
|
|||
if (!fetcher) return
|
||||
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
|
||||
},
|
||||
fetchTimeline (store, timeline, { ...rest }) {
|
||||
fetchTimeline (store, { timeline, ...rest }) {
|
||||
store.state.backendInteractor.fetchTimeline({
|
||||
store,
|
||||
timeline,
|
||||
|
|
|
@ -87,8 +87,8 @@ export const defaultState = {
|
|||
useContainFit: true,
|
||||
disableStickyHeaders: false,
|
||||
showScrollbars: false,
|
||||
userPopoverAvatarAction: 'close',
|
||||
userPopoverOverlay: true,
|
||||
userPopoverAvatarAction: 'open',
|
||||
userPopoverOverlay: false,
|
||||
sidebarColumnWidth: '25rem',
|
||||
contentColumnWidth: '45rem',
|
||||
notifsColumnWidth: '25rem',
|
||||
|
|
|
@ -36,6 +36,8 @@ const REGIONAL_INDICATORS = (() => {
|
|||
return res
|
||||
})()
|
||||
|
||||
const REMOTE_INTERACTION_URL = '/main/ostatus'
|
||||
|
||||
const defaultState = {
|
||||
// Stuff from apiConfig
|
||||
name: 'Pleroma FE',
|
||||
|
@ -214,6 +216,18 @@ const instance = {
|
|||
},
|
||||
instanceDomain (state) {
|
||||
return new URL(state.server).hostname
|
||||
},
|
||||
remoteInteractionLink (state) {
|
||||
const server = state.server.endsWith('/') ? state.server.slice(0, -1) : state.server
|
||||
const link = server + REMOTE_INTERACTION_URL
|
||||
|
||||
return ({ statusId, nickname }) => {
|
||||
if (statusId) {
|
||||
return `${link}?status_id=${statusId}`
|
||||
} else {
|
||||
return `${link}?nickname=${nickname}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { toRaw } from 'vue'
|
||||
import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight } from 'lodash'
|
||||
import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight, uniqWith } from 'lodash'
|
||||
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
|
||||
|
||||
export const VERSION = 1
|
||||
|
@ -149,12 +149,21 @@ const _mergeJournal = (...journals) => {
|
|||
if (path.startsWith('collections')) {
|
||||
const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection')
|
||||
// everything before last remove is unimportant
|
||||
let remainder
|
||||
if (lastRemoveIndex > 0) {
|
||||
return journal.slice(lastRemoveIndex)
|
||||
remainder = journal.slice(lastRemoveIndex)
|
||||
} else {
|
||||
// everything else doesn't need trimming
|
||||
return journal
|
||||
remainder = journal
|
||||
}
|
||||
return uniqWith(remainder, (a, b) => {
|
||||
if (a.path !== b.path) { return false }
|
||||
if (a.operation !== b.operation) { return false }
|
||||
if (a.operation === 'addToCollection') {
|
||||
return a.args[0] === b.args[0]
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else if (path.startsWith('simple')) {
|
||||
// Only the last record is important
|
||||
return takeRight(journal)
|
||||
|
|
|
@ -769,8 +769,8 @@ const statuses = {
|
|||
rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
|
||||
.then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }))
|
||||
},
|
||||
search (store, { q, resolve, limit, offset, following }) {
|
||||
return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following })
|
||||
search (store, { q, resolve, limit, offset, following, type }) {
|
||||
return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following, type })
|
||||
.then((data) => {
|
||||
store.commit('addNewUsers', data.accounts)
|
||||
store.commit('addNewStatuses', { statuses: data.statuses })
|
||||
|
|
|
@ -594,7 +594,7 @@ const users = {
|
|||
}
|
||||
|
||||
if (store.getters.mergedConfig.useStreamingApi) {
|
||||
store.dispatch('fetchTimeline', 'friends', { since: null })
|
||||
store.dispatch('fetchTimeline', { timeline: 'friends', since: null })
|
||||
store.dispatch('fetchNotifications', { since: null })
|
||||
store.dispatch('enableMastoSockets', true).catch((error) => {
|
||||
console.error('Failed initializing MastoAPI Streaming socket', error)
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
.panel-heading,
|
||||
.panel-footer {
|
||||
--panel-heading-height-padding: 0.6em;
|
||||
--__panel-heading-gap: 0.5em;
|
||||
--__panel-heading-height: 3.2em;
|
||||
--__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
|
||||
|
||||
|
@ -54,7 +55,7 @@
|
|||
grid-auto-flow: column;
|
||||
grid-template-columns: minmax(50%, 1fr);
|
||||
grid-auto-columns: auto;
|
||||
grid-column-gap: 0.5em;
|
||||
grid-column-gap: var(--__panel-heading-gap);
|
||||
flex: none;
|
||||
background-size: cover;
|
||||
padding: var(--panel-heading-height-padding);
|
||||
|
@ -195,6 +196,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightside-button {
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
width: var(--__panel-heading-height);
|
||||
height: var(--__panel-heading-height);
|
||||
margin: calc(-1 * var(--panel-heading-height-padding)) 0;
|
||||
margin-right: calc(-1 * var(--__panel-heading-gap));
|
||||
|
||||
> button {
|
||||
box-sizing: border-box;
|
||||
padding: calc(1 * var(--panel-heading-height-padding)) 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightside-icon {
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
width: var(--__panel-heading-height);
|
||||
margin-right: calc(-1 * var(--__panel-heading-gap));
|
||||
|
||||
svg {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
|
|
|
@ -1278,7 +1278,7 @@ const searchUsers = ({ credentials, query }) => {
|
|||
.then((data) => data.map(parseUser))
|
||||
}
|
||||
|
||||
const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
|
||||
const search2 = ({ credentials, q, resolve, limit, offset, following, type }) => {
|
||||
let url = MASTODON_SEARCH_2
|
||||
const params = []
|
||||
|
||||
|
@ -1302,6 +1302,10 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
|
|||
params.push(['following', true])
|
||||
}
|
||||
|
||||
if (type) {
|
||||
params.push(['following', type])
|
||||
}
|
||||
|
||||
params.push(['with_relationships', true])
|
||||
|
||||
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
|
||||
|
|
|
@ -124,6 +124,34 @@ export const parseUser = (data) => {
|
|||
} else {
|
||||
output.role = 'member'
|
||||
}
|
||||
|
||||
if (data.pleroma.privileges) {
|
||||
output.privileges = data.pleroma.privileges
|
||||
} else if (data.pleroma.is_admin) {
|
||||
output.privileges = [
|
||||
'users_read',
|
||||
'users_manage_invites',
|
||||
'users_manage_activation_state',
|
||||
'users_manage_tags',
|
||||
'users_manage_credentials',
|
||||
'users_delete',
|
||||
'messages_read',
|
||||
'messages_delete',
|
||||
'instances_delete',
|
||||
'reports_manage_reports',
|
||||
'moderation_log_read',
|
||||
'announcements_manage_announcements',
|
||||
'emoji_manage_emoji',
|
||||
'statistics_read'
|
||||
]
|
||||
} else if (data.pleroma.is_moderator) {
|
||||
output.privileges = [
|
||||
'messages_delete',
|
||||
'reports_manage_reports'
|
||||
]
|
||||
} else {
|
||||
output.privileges = []
|
||||
}
|
||||
}
|
||||
|
||||
if (data.source) {
|
||||
|
|
|
@ -16,7 +16,7 @@ export const getTagName = (tag) => {
|
|||
* @return {Object} - map of attributes key = attribute name, value = attribute value
|
||||
* attributes without values represented as boolean true
|
||||
*/
|
||||
export const getAttrs = tag => {
|
||||
export const getAttrs = (tag, filter) => {
|
||||
const innertag = tag
|
||||
.substring(1, tag.length - 1)
|
||||
.replace(new RegExp('^' + getTagName(tag)), '')
|
||||
|
@ -28,7 +28,15 @@ export const getAttrs = tag => {
|
|||
if (!v) return [k, true]
|
||||
return [k, v.substring(1, v.length - 1)]
|
||||
})
|
||||
return Object.fromEntries(attrs)
|
||||
const defaultFilter = ([k, v]) => {
|
||||
const attrKey = k.toLowerCase()
|
||||
if (attrKey === 'style') return false
|
||||
if (attrKey === 'class') {
|
||||
return v === 'greentext' || v === 'cyantext'
|
||||
}
|
||||
return true
|
||||
}
|
||||
return Object.fromEntries(attrs.filter(filter || defaultFilter))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -709,6 +709,14 @@ export const SLOT_INHERITANCE = {
|
|||
textColor: 'bw'
|
||||
},
|
||||
|
||||
badgeNeutral: '--cGreen',
|
||||
badgeNeutralText: {
|
||||
depends: ['text', 'badgeNeutral'],
|
||||
layer: 'badge',
|
||||
variant: 'badgeNeutral',
|
||||
textColor: 'bw'
|
||||
},
|
||||
|
||||
chatBg: {
|
||||
depends: ['bg']
|
||||
},
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
"bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
|
||||
"ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
|
||||
"monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ],
|
||||
|
||||
"redmond-xx": "/static/themes/redmond-xx.json",
|
||||
"redmond-xx-se": "/static/themes/redmond-xx-se.json",
|
||||
"redmond-xxi": "/static/themes/redmond-xxi.json",
|
||||
|
|
|
@ -19,9 +19,11 @@ const global = {
|
|||
}
|
||||
}
|
||||
|
||||
const makeMention = (who) => {
|
||||
const makeMention = (who, noClass) => {
|
||||
attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
|
||||
return `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
|
||||
return noClass
|
||||
? `<span><a href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
|
||||
: `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
|
||||
}
|
||||
const p = (...data) => `<p>${data.join('')}</p>`
|
||||
const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>`
|
||||
|
@ -142,6 +144,17 @@ describe('RichContent', () => {
|
|||
makeMention('Josh'), makeMention('Jeremy')
|
||||
].join('')
|
||||
].join('\n')
|
||||
const strippedHtml = [
|
||||
[
|
||||
makeMention('Jack', true),
|
||||
'let\'s meet up with ',
|
||||
makeMention('Janet', true)
|
||||
].join(''),
|
||||
[
|
||||
makeMention('John', true),
|
||||
makeMention('Josh', true), makeMention('Jeremy', true)
|
||||
].join('')
|
||||
].join('\n')
|
||||
|
||||
const wrapper = shallowMount(RichContent, {
|
||||
global,
|
||||
|
@ -154,7 +167,7 @@ describe('RichContent', () => {
|
|||
}
|
||||
})
|
||||
|
||||
expect(wrapper.html()).to.eql(compwrap(html))
|
||||
expect(wrapper.html()).to.eql(compwrap(strippedHtml))
|
||||
})
|
||||
|
||||
it('Adds greentext and cyantext to the post', () => {
|
||||
|
@ -412,7 +425,7 @@ describe('RichContent', () => {
|
|||
'Testing'
|
||||
].join('')
|
||||
const expected = [
|
||||
'<span class="poast-style">',
|
||||
'<span>',
|
||||
'<span class="MentionsLine">',
|
||||
'<span class="MentionLink mention-link">',
|
||||
'<a href="lol" class="original" target="_blank">',
|
||||
|
|
|
@ -148,6 +148,18 @@ describe('The serverSideStorage module', () => {
|
|||
timestamp: state.prefsStorage._journal[1].timestamp
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove duplicate entries from journal', () => {
|
||||
const state = cloneDeep(defaultState)
|
||||
setPreference(state, { path: 'simple.testing', value: 1 })
|
||||
setPreference(state, { path: 'simple.testing', value: 1 })
|
||||
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
|
||||
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
|
||||
updateCache(state, { username: 'test' })
|
||||
expect(state.prefsStorage.simple.testing).to.eql(1)
|
||||
expect(state.prefsStorage.collections.testing).to.eql([2])
|
||||
expect(state.prefsStorage._journal.length).to.eql(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue