Merge remote-tracking branch 'upstream/develop' into neckbeard

remove-mods-files
Your New SJW Waifu 2 years ago
commit 5cfd544689

@ -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

@ -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

@ -0,0 +1,7 @@
<!--
please use one of the templates if applicable, otherwise - type out here
in free-form
-->
/label ~needs-triage

@ -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

@ -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 - Enabled users to zoom and pan images in media viewer with mouse and touch
- Timelines/panels and conversations have sticky headers now - Timelines/panels and conversations have sticky headers now
- Added frontend ui for account migration - Added frontend ui for account migration
- Implemented remote interaction with statuses
## [2.4.2] - 2022-01-09 ## [2.4.2] - 2022-01-09

@ -430,5 +430,6 @@
The moral of the story is in order to access Bae.st, you need to enable JavaScript.</noscript> The moral of the story is in order to access Bae.st, you need to enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<div id="popovers" />
</body> </body>
</html> </html>

@ -74,7 +74,6 @@
<UpdateNotification /> <UpdateNotification />
<div id="modal" /> <div id="modal" />
<GlobalNoticeList /> <GlobalNoticeList />
<div id="popovers" />
</div> </div>
</template> </template>

@ -27,16 +27,16 @@
&.nativeColor { &.nativeColor {
flex: 0 0 2em; flex: 0 0 2em;
min-width: 2em; min-width: 2em;
align-self: center; align-self: stretch;
height: 100%; min-height: 100%;
} }
} }
.computedIndicator, .computedIndicator,
.transparentIndicator { .transparentIndicator {
flex: 0 0 2em; flex: 0 0 2em;
min-width: 2em; min-width: 2em;
align-self: center; align-self: stretch;
height: 100%; min-height: 100%;
} }
.transparentIndicator { .transparentIndicator {
// forgot to install counter-strike source, ooops // forgot to install counter-strike source, ooops

@ -1,5 +1,6 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue' 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 UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -109,18 +110,20 @@ const EmojiInput = {
data () { data () {
return { return {
input: undefined, input: undefined,
caretEl: undefined,
highlighted: 0, highlighted: 0,
caret: 0, caret: 0,
focused: false, focused: false,
blurTimeout: null, blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false, temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false, disableClickOutside: false,
suggestions: [] suggestions: [],
overlayStyle: {},
pickerShown: false
} }
}, },
components: { components: {
Popover,
EmojiPicker, EmojiPicker,
UnicodeDomainIndicator UnicodeDomainIndicator
}, },
@ -128,15 +131,21 @@ const EmojiInput = {
padEmoji () { padEmoji () {
return this.$store.getters.mergedConfig.padEmoji return this.$store.getters.mergedConfig.padEmoji
}, },
preText () {
return this.modelValue.slice(0, this.caret)
},
postText () {
return this.modelValue.slice(this.caret)
},
showSuggestions () { showSuggestions () {
return this.focused && return this.focused &&
this.suggestions && this.suggestions &&
this.suggestions.length > 0 && this.suggestions.length > 0 &&
!this.showPicker && !this.pickerShown &&
!this.temporarilyHideSuggestions !this.temporarilyHideSuggestions
}, },
textAtCaret () { textAtCaret () {
return (this.wordAtCaret || {}).word || '' return this.wordAtCaret?.word
}, },
wordAtCaret () { wordAtCaret () {
if (this.modelValue && this.caret) { if (this.modelValue && this.caret) {
@ -188,13 +197,35 @@ const EmojiInput = {
return emoji.displayText return emoji.displayText
} }
},
onInputScroll () {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
} }
}, },
mounted () { mounted () {
const { root } = this.$refs const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return if (!input) return
this.input = input 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() this.resize()
input.addEventListener('blur', this.onBlur) input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus) input.addEventListener('focus', this.onFocus)
@ -204,6 +235,7 @@ const EmojiInput = {
input.addEventListener('click', this.onClickInput) input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition) input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput) input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
}, },
unmounted () { unmounted () {
const { input } = this const { input } = this
@ -216,45 +248,43 @@ const EmojiInput = {
input.removeEventListener('click', this.onClickInput) input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition) input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput) input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
} }
}, },
watch: { watch: {
showSuggestions: function (newValue) { showSuggestions: function (newValue, oldValue) {
this.$emit('shown', newValue) this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
}, },
textAtCaret: async function (newWord) { textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0) const firstchar = newWord.charAt(0)
this.suggestions = [] if (newWord === firstchar) {
if (newWord === firstchar) return this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait // Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
if (matchedSuggestions.length <= 0) return this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5) this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({ .map(({ imageUrl, ...rest }) => ({
...rest, ...rest,
img: imageUrl || '' img: imageUrl || ''
})) }))
},
suggestions: {
handler (newValue) {
this.$nextTick(this.resize)
},
deep: true
} }
}, },
methods: { methods: {
focusPickerInput () {
const pickerEl = this.$refs.picker.$el
if (!pickerEl) return
const pickerInput = pickerEl.querySelector('input')
if (pickerInput) pickerInput.focus()
},
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput()
}) })
// This temporarily disables "click outside" handler // This temporarily disables "click outside" handler
// since external trigger also means click originates // since external trigger also means click originates
@ -266,11 +296,12 @@ const EmojiInput = {
}, },
togglePicker () { togglePicker () {
this.input.focus() this.input.focus()
this.showPicker = !this.showPicker if (!this.pickerShown) {
if (this.showPicker) {
this.scrollIntoView() this.scrollIntoView()
this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad() this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput) } else {
this.$refs.picker.hidePicker()
} }
}, },
replace (replacement) { replace (replacement) {
@ -307,7 +338,6 @@ const EmojiInput = {
spaceAfter, spaceAfter,
after after
].join('') ].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue) this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) { if (!keepOpen) {
@ -407,8 +437,11 @@ const EmojiInput = {
} }
}) })
}, },
onTransition (e) { onPickerShown () {
this.resize() this.pickerShown = true
},
onPickerClosed () {
this.pickerShown = false
}, },
onBlur (e) { onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete, // Clicking on any suggestion removes focus from autocomplete,
@ -416,7 +449,6 @@ const EmojiInput = {
this.blurTimeout = setTimeout(() => { this.blurTimeout = setTimeout(() => {
this.focused = false this.focused = false
this.setCaret(e) this.setCaret(e)
this.resize()
}, 200) }, 200)
}, },
onClick (e, suggestion) { onClick (e, suggestion) {
@ -428,18 +460,13 @@ const EmojiInput = {
this.blurTimeout = null this.blurTimeout = null
} }
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true this.focused = true
this.setCaret(e) this.setCaret(e)
this.resize()
this.temporarilyHideSuggestions = false this.temporarilyHideSuggestions = false
}, },
onKeyUp (e) { onKeyUp (e) {
const { key } = e const { key } = e
this.setCaret(e) this.setCaret(e)
this.resize()
// Setting hider in keyUp to prevent suggestions from blinking // Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot // when moving away from suggested spot
@ -451,7 +478,6 @@ const EmojiInput = {
}, },
onPaste (e) { onPaste (e) {
this.setCaret(e) this.setCaret(e)
this.resize()
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
@ -496,58 +522,24 @@ const EmojiInput = {
this.input.focus() this.input.focus()
} }
} }
this.showPicker = false
this.resize()
}, },
onInput (e) { onInput (e) {
this.showPicker = false
this.setCaret(e) this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value) this.$emit('update:modelValue', e.target.value)
}, },
onClickInput (e) {
this.showPicker = false
},
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
onStickerUploadFailed (e) { onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e) this.$emit('sticker-upload-Failed', e)
}, },
setCaret ({ target: { selectionStart } }) { setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover.updateStyles()
})
}, },
resize () { 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> <template>
<div <div
ref="root" ref="root"
v-click-outside="onClickOutside"
class="emoji-input" class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }" :class="{ 'with-picker': !hideEmojiButton }"
> >
<slot /> <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"> <template v-if="enableEmojiPicker">
<button <button
v-if="!hideEmojiButton" v-if="!hideEmojiButton"
@ -18,59 +30,61 @@
<EmojiPicker <EmojiPicker
v-if="enableEmojiPicker" v-if="enableEmojiPicker"
ref="picker" ref="picker"
:class="{ hide: !showPicker }"
:showing="showPicker"
:enable-sticker-picker="enableStickerPicker" :enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel" class="emoji-picker-panel"
@emoji="insert" @emoji="insert"
@sticker-uploaded="onStickerUploaded" @sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed" @sticker-upload-failed="onStickerUploadFailed"
@show="onPickerShown"
@close="onPickerClosed"
/> />
</template> </template>
<div <Popover
ref="panel" ref="suggestorPopover"
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" placement="bottom"
> >
<div <template #content>
ref="panel-body"
class="autocomplete-panel-body"
>
<div <div
v-for="(suggestion, index) in suggestions" ref="panel-body"
:key="index" class="autocomplete-panel-body"
class="autocomplete-item"
:class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
> >
<span class="image"> <div
<img v-for="(suggestion, index) in suggestions"
v-if="suggestion.img" :key="index"
:src="suggestion.img" class="autocomplete-item"
> :class="{ highlighted: index === highlighted }"
<span v-else>{{ suggestion.replacement }}</span> @click.stop.prevent="onClick($event, suggestion)"
</span> >
<div class="label"> <span class="image">
<span <img
v-if="suggestion.user" v-if="suggestion.img"
class="displayText" :src="suggestion.img"
> >
{{ suggestion.displayText }}<UnicodeDomainIndicator <span v-else>{{ suggestion.replacement }}</span>
:user="suggestion.user"
:at="false"
/>
</span> </span>
<span <div class="label">
v-if="!suggestion.user" <span
class="displayText" v-if="suggestion.user"
> class="displayText"
{{ maybeLocalizedEmojiName(suggestion) }} >
</span> {{ suggestion.displayText }}<UnicodeDomainIndicator
<span class="detailText">{{ suggestion.detailText }}</span> :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>
</div> </template>
</div> </Popover>
</div> </div>
</template> </template>
@ -102,6 +116,7 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.emoji-picker-panel { .emoji-picker-panel {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
@ -112,89 +127,83 @@
} }
} }
.autocomplete { input, textarea {
&-panel { flex: 1 0 auto;
position: absolute; }
z-index: 20;
margin-top: 2px;
&.hide {
display: none
}
&-body { .hidden-overlay {
margin: 0 0.5em 0 0.5em; opacity: 0;
border-radius: $fallback--tooltipRadius; pointer-events: none;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); position: absolute;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); top: 0;
box-shadow: var(--popupShadow); bottom: 0;
min-width: 75%; right: 0;
background-color: $fallback--bg; left: 0;
background-color: var(--popover, $fallback--bg); overflow: hidden;
color: $fallback--link; /* DEBUG STUFF */
color: var(--popoverText, $fallback--link); color: red;
--faint: var(--popoverFaintText, $fallback--faint); /* set opacity to non-zero to see the overlay */
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText); .caret {
--postLink: var(--popoverPostLink, $fallback--link); width: 0;
--postFaintLink: var(--popoverPostFaintLink, $fallback--link); margin-right: calc(-1ch - 1px);
--icon: var(--popoverIcon, $fallback--icon); border: 1px solid red;
}
} }
}
}
.autocomplete {
&-panel {
position: absolute;
}
&-item { &-item {
display: flex; display: flex;
cursor: pointer; cursor: pointer;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4); border-bottom: 1px solid rgba(0, 0, 0, 0.4);
height: 32px;
.image {
width: 32px;
height: 32px; height: 32px;
line-height: 32px;
text-align: center;
font-size: 32px;
.image { margin-right: 4px;
img {
width: 32px; width: 32px;
height: 32px; height: 32px;
line-height: 32px; object-fit: contain;
text-align: center;
font-size: 32px;
margin-right: 4px;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
} }
}
.label { .label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
margin: 0 0.1em 0 0.2em; margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText { .displayText {
font-size: 9px; line-height: 1.5;
line-height: 9px;
}
} }
&.highlighted { .detailText {
background-color: $fallback--fg; font-size: 9px;
background-color: var(--selectedMenuPopover, $fallback--fg); line-height: 9px;
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 { &.highlighted {
flex: 1 0 auto; 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> </style>

@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import StillImage from '../still-image/still-image.vue' import StillImage from '../still-image/still-image.vue'
import { ensureFinalFallback } from '../../i18n/languages.js' import { ensureFinalFallback } from '../../i18n/languages.js'
import lozad from 'lozad' import lozad from 'lozad'
@ -87,10 +88,6 @@ const EmojiPicker = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
showing: {
required: true,
type: Boolean
} }
}, },
data () { data () {
@ -111,15 +108,32 @@ const EmojiPicker = {
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox, Checkbox,
StillImage StillImage,
Popover
}, },
methods: { methods: {
showPicker () {
this.$refs.popover.showPopover()
this.onShowing()
},
hidePicker () {
this.$refs.popover.hidePopover()
},
setAnchorEl (el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef (name) { setGroupRef (name) {
return el => { this.groupRefs[name] = el } return el => { this.groupRefs[name] = el }
}, },
setEmojiRef (name) { setEmojiRef (name) {
return el => { this.emojiRefs[name] = el } return el => { this.emojiRefs[name] = el }
}, },
onPopoverShown () {
this.$emit('show')
},
onPopoverClosed () {
this.$emit('close')
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
@ -128,6 +142,9 @@ const EmojiPicker = {
}, },
onEmoji (emoji) { onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
}, },
onScroll (e) { onScroll (e) {
@ -223,6 +240,9 @@ const EmojiPicker = {
}, },
onShowing () { onShowing () {
const oldContentLoaded = this.contentLoaded const oldContentLoaded = this.contentLoaded
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true this.contentLoaded = true
this.waitForDomAndInitializeLazyLoad() this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups() this.filteredEmojiGroups = this.getFilteredEmojiGroups()
@ -251,16 +271,6 @@ const EmojiPicker = {
allCustomGroups () { allCustomGroups () {
this.waitForDomAndInitializeLazyLoad() this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups() this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
showing (val) {
if (val) {
this.onShowing()
}
}
},
mounted () {
if (this.showing) {
this.onShowing()
} }
}, },
destroyed () { destroyed () {

@ -6,14 +6,10 @@ $emoji-picker-header-picture-height: 32px;
$emoji-picker-emoji-size: 32px; $emoji-picker-emoji-size: 32px;
.emoji-picker { .emoji-picker {
width: 25em;
max-width: 100vw;
display: flex; display: flex;
flex-direction: column; 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: $fallback--bg;
background-color: var(--popover, $fallback--bg); background-color: var(--popover, $fallback--bg);
color: $fallback--link; color: $fallback--link;

@ -1,129 +1,136 @@
<template> <template>
<div <Popover
class="emoji-picker panel panel-default panel-body" ref="popover"
trigger="click"
popover-class="emoji-picker popover-default"
@show="onPopoverShown"
@close="onPopoverClosed"
> >
<div class="heading"> <template #content>
<span <div class="heading">
ref="header"
class="emoji-tabs"
>
<span <span
v-for="group in filteredEmojiGroups" ref="header"
:ref="setGroupRef('group-header-' + group.id)" class="emoji-tabs"
:key="group.id"
class="emoji-tabs-item"
:class="{
active: activeGroupView === group.id
}"
:title="group.text"
@click.prevent="highlight(group.id)"
> >
<span <span
v-if="group.image" v-for="group in filteredEmojiGroups"
class="emoji-picker-header-image" :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)"
> >
<still-image <span
:alt="group.text" v-if="group.image"
:src="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>
<FAIcon
v-else
:icon="group.icon"
fixed-width
/>
</span> </span>
</span>
<span
v-if="stickerPickerEnabled"
class="additional-tabs"
>
<span <span
class="stickers-tab-icon additional-tabs-item" v-if="stickerPickerEnabled"
:class="{active: showingStickers}" class="additional-tabs"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
> >
<FAIcon <span
icon="sticky-note" class="stickers-tab-icon additional-tabs-item"
fixed-width :class="{active: showingStickers}"
/> :title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
<FAIcon
icon="sticky-note"
fixed-width
/>
</span>
</span> </span>
</span> </div>
</div>
<div
v-if="contentLoaded"
class="content"
>
<div <div
class="emoji-content" v-if="contentLoaded"
:class="{hidden: showingStickers}" class="content"
> >
<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 <div
ref="emoji-groups" class="emoji-content"
class="emoji-groups" :class="{hidden: showingStickers}"
:class="groupsScrolledClass"
@scroll="onScroll"
> >
<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 <div
v-for="group in filteredEmojiGroups" ref="emoji-groups"
:key="group.id" class="emoji-groups"
class="emoji-group" :class="groupsScrolledClass"
@scroll="onScroll"
> >
<h6 <div
:ref="setGroupRef('group-' + group.id)" v-for="group in filteredEmojiGroups"
class="emoji-group-title" :key="group.id"
> class="emoji-group"
{{ 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)"
> >
<h6
:ref="setGroupRef('group-' + group.id)"
class="emoji-group-title"
>
{{ group.text }}
</h6>
<span <span
v-if="!emoji.imageUrl" v-for="emoji in group.emojis"
class="emoji-picker-emoji -unicode" :key="group.id + emoji.displayText"
>{{ emoji.replacement }}</span> :title="maybeLocalizedEmojiName(emoji)"
<still-image class="emoji-item"
v-else @click.stop.prevent="onEmoji(emoji)"
:ref="setEmojiRef(group.id + emoji.displayText)" >
class="emoji-picker-emoji -custom" <span
:data-src="emoji.imageUrl" v-if="!emoji.imageUrl"
:data-emoji-name="group.id + emoji.displayText" class="emoji-picker-emoji -unicode"
/> >{{ emoji.replacement }}</span>
</span> <still-image
<span :ref="setGroupRef('group-end-' + group.id)" /> 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> </div>
<div class="keep-open"> <div
<Checkbox v-model="keepOpen"> v-if="showingStickers"
{{ $t('emoji.keep_open') }} class="stickers-content"
</Checkbox> >
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div> </div>
</div> </div>
<div </template>
v-if="showingStickers" </Popover>
class="stickers-content"
>
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div>
</div>
</div>
</template> </template>
<script src="./emoji_picker.js"></script> <script src="./emoji_picker.js"></script>

@ -1,5 +1,5 @@
<template> <template>
<div class="emoji-reactions"> <div class="EmojiReactions">
<UserListPopover <UserListPopover
v-for="(reaction) in emojiReactions" v-for="(reaction) in emojiReactions"
:key="reaction.name" :key="reaction.name"
@ -7,7 +7,7 @@
> >
<button <button
class="emoji-reaction btn button-default" 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)" @click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()" @mouseenter="fetchEmojiReactionsByIfMissing()"
> >
@ -30,53 +30,55 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.emoji-reactions { .EmojiReactions {
display: flex; display: flex;
margin-top: 0.25em; margin-top: 0.25em;
flex-wrap: wrap; flex-wrap: wrap;
}
.emoji-reaction { .emoji-reaction {
padding: 0 0.5em; padding: 0 0.5em;
margin-right: 0.5em; margin-right: 0.5em;
margin-top: 0.5em; margin-top: 0.5em;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
.reaction-emoji {
width: 1.25em;
margin-right: 0.25em;
}
&:focus {
outline: none;
}
&.not-clickable { .reaction-emoji {
cursor: default; width: 1.25em;
&:hover { margin-right: 0.25em;
box-shadow: $fallback--buttonShadow; }
box-shadow: var(--buttonShadow);
&: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 { .emoji-reaction-expand {
padding: 0 0.5em; padding: 0 0.5em;
margin-right: 0.5em; margin-right: 0.5em;
margin-top: 0.5em; margin-top: 0.5em;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:hover { &:hover {
text-decoration: underline; 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> </style>

@ -113,8 +113,7 @@ const ExtraButtons = {
currentUser () { return this.$store.state.users.currentUser }, currentUser () { return this.$store.state.users.currentUser },
canDelete () { canDelete () {
if (!this.currentUser) { return } if (!this.currentUser) { return }
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
return superuser || this.status.user.id === this.currentUser.id
}, },
ownStatus () { ownStatus () {
return this.status.user.id === this.currentUser.id return this.status.user.id === this.currentUser.id

@ -39,7 +39,10 @@ const FavoriteButton = {
} }
}, },
computed: { computed: {
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig']),
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
} }
} }

@ -33,13 +33,19 @@
/> />
</FALayers> </FALayers>
</button> </button>
<span v-else> <a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')" :title="$t('tool_tip.favorite')"
:icon="['far', 'star']" :icon="['far', 'star']"
/> />
</span> </a>
<span <span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0" v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter" class="action-counter"

@ -15,7 +15,7 @@ const Interactions = {
return { return {
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
filterMode: tabModeDict.mentions, 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: { methods: {

@ -11,7 +11,7 @@
<button <button
class="button-unstyled mobile-nav-button" class="button-unstyled mobile-nav-button"
:title="$t('nav.mobile_sidebar')" :title="$t('nav.mobile_sidebar')"
:aria-expanaded="this.$refs.sideDrawer && !this.$refs.sideDrawer.closed" :aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed"
@click.stop.prevent="toggleMobileSidebar()" @click.stop.prevent="toggleMobileSidebar()"
> >
<FAIcon <FAIcon
@ -51,7 +51,7 @@
> >
<div class="mobile-notifications-header"> <div class="mobile-notifications-header">
<span class="title">{{ $t('notifications.notifications') }}</span> <span class="title">{{ $t('notifications.notifications') }}</span>
<span class="spacer"/> <span class="spacer" />
<button <button
v-if="notificationsAtTop" v-if="notificationsAtTop"
class="button-unstyled mobile-nav-button" class="button-unstyled mobile-nav-button"
@ -79,8 +79,8 @@
</div> </div>
<div <div
id="mobile-notifications" id="mobile-notifications"
class="mobile-notifications"
ref="mobileNotifications" ref="mobileNotifications"
class="mobile-notifications"
@scroll="onScroll" @scroll="onScroll"
/> />
</aside> </aside>

@ -41,14 +41,26 @@ const ModerationTools = {
tagsSet () { tagsSet () {
return new Set(this.user.tags) return new Set(this.user.tags)
}, },
hasTagPolicy () { canGrantRole () {
return this.$store.state.instance.tagPolicyAvailable 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: { methods: {
hasTag (tagName) { hasTag (tagName) {
return this.tagsSet.has(tagName) return this.tagsSet.has(tagName)
}, },
privileged (privilege) {
return this.$store.state.users.currentUser.privileges.includes(privilege)
},
toggleTag (tag) { toggleTag (tag) {
const store = this.$store const store = this.$store
if (this.tagsSet.has(tag)) { if (this.tagsSet.has(tag)) {

@ -10,7 +10,7 @@
> >
<template #content> <template #content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<span v-if="user.is_local"> <span v-if="canGrantRole">
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleRight(&quot;admin&quot;)" @click="toggleRight(&quot;admin&quot;)"
@ -24,28 +24,31 @@
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button> </button>
<div <div
v-if="canChangeActivationState || canDeleteAccount"
role="separator" role="separator"
class="dropdown-divider" class="dropdown-divider"
/> />
</span> </span>
<button <button
v-if="canChangeActivationState"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleActivationStatus()" @click="toggleActivationStatus()"
> >
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button> </button>
<button <button
v-if="canDeleteAccount"
class="button-default dropdown-item" class="button-default dropdown-item"
@click="deleteUserDialog(true)" @click="deleteUserDialog(true)"
> >
{{ $t('user_card.admin_menu.delete_account') }} {{ $t('user_card.admin_menu.delete_account') }}
</button> </button>
<div <div
v-if="hasTagPolicy" v-if="canUseTagPolicy"
role="separator" role="separator"
class="dropdown-divider" class="dropdown-divider"
/> />
<span v-if="hasTagPolicy"> <span v-if="canUseTagPolicy">
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@click="toggleTag(tags.FORCE_NSFW)" @click="toggleTag(tags.FORCE_NSFW)"

@ -61,7 +61,7 @@
&.router-link-active { &.router-link-active {
color: $fallback--text; color: $fallback--text;
color: var(--selectedMenuText, $fallback--text); color: var(--panelText, $fallback--text);
border-bottom: 4px solid; border-bottom: 4px solid;
& .svg-inline--fa, & .svg-inline--fa,

@ -22,8 +22,8 @@
>{{ unseenCount }}</span> >{{ unseenCount }}</span>
</div> </div>
<div <div
class="rightside-button"
v-if="showScrollTop" v-if="showScrollTop"
class="rightside-button"
> >
<button <button
class="button-unstyled scroll-to-top-button" class="button-unstyled scroll-to-top-button"

@ -56,6 +56,10 @@ const Popover = {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content // 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 // 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 // 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, lockReEntry: false,
hidden: true, hidden: true,
styles: {}, styles: {},
@ -64,10 +68,15 @@ const Popover = {
// used to avoid blinking if hovered onto popover // used to avoid blinking if hovered onto popover
graceTimeout: null, graceTimeout: null,
parentPopover: null, parentPopover: null,
disableClickOutside: false,
childrenShown: new Set() childrenShown: new Set()
} }
}, },
methods: { methods: {
setAnchorEl (el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect () { containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect() return container.getBoundingClientRect()
@ -80,7 +89,7 @@ const Popover = {
// Popover will be anchored around this element, trigger ref is the container, so // 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. // 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 // SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
@ -231,6 +240,10 @@ const Popover = {
}, },
showPopover () { showPopover () {
if (this.disabled) return if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden const wasHidden = this.hidden
this.hidden = false this.hidden = false
this.parentPopover && this.parentPopover.onChildPopoverState(this, true) this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
@ -291,6 +304,7 @@ const Popover = {
} }
}, },
onClickOutside (e) { onClickOutside (e) {
if (this.disableClickOutside) return
if (this.hidden) return if (this.hidden) return
if (this.$refs.content && this.$refs.content.contains(e.target)) return if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return if (this.$el.contains(e.target)) return
@ -324,6 +338,7 @@ const Popover = {
} }
}, },
mounted () { mounted () {
this.teleport = true
let scrollable = this.$refs.trigger.closest('.column.-scrollable') || let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications') this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window if (!scrollable) scrollable = window

@ -12,7 +12,10 @@
> >
<slot name="trigger" /> <slot name="trigger" />
</button> </button>
<teleport to="#popovers"> <teleport
:disabled="!teleport"
to="#popovers"
>
<transition name="fade"> <transition name="fade">
<div <div
v-if="!hidden" v-if="!hidden"

@ -501,7 +501,6 @@ const PostStatusForm = {
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize') this.$emit('resize')
this.$refs['emoji-input'].resize()
return return
} }
@ -588,8 +587,6 @@ const PostStatusForm = {
} else { } else {
scrollerRef.scrollTop = targetScroll scrollerRef.scrollTop = targetScroll
} }
this.$refs['emoji-input'].resize()
}, },
showEmojiPicker () { showEmojiPicker () {
this.$refs.textarea.focus() this.$refs.textarea.focus()

@ -3,7 +3,7 @@
trigger="click" trigger="click"
class="QuickFilterSettings" class="QuickFilterSettings"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
:triggerAttrs="{ title: $t('timeline.quick_filter_settings') }" :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }"
> >
<template #content> <template #content>
<div class="dropdown-menu"> <div class="dropdown-menu">

@ -3,7 +3,7 @@
trigger="click" trigger="click"
class="QuickViewSettings" class="QuickViewSettings"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
:triggerAttrs="{ title: $t('timeline.quick_view_settings') }" :trigger-attrs="{ title: $t('timeline.quick_view_settings') }"
> >
<template #content> <template #content>
<div class="dropdown-menu"> <div class="dropdown-menu">

@ -1,4 +1,5 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
@ -43,31 +44,73 @@ const ReactButton = {
const input = this.$el.querySelector('input') const input = this.$el.querySelector('input')
if (input) input.focus() 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: { computed: {
commonEmojis () { commonEmojis () {
return [ const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥'])
{ displayText: 'thumbsup', replacement: '👍' }, return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement))
{ displayText: 'angry', replacement: '😠' }, },
{ displayText: 'eyes', replacement: '👀' }, languages () {
{ displayText: 'joy', replacement: '😂' }, return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
{ displayText: 'fire', replacement: '🔥' }
]
}, },
emojis () { emojis () {
if (this.filterWord !== '') { if (this.filterWord !== '') {
const filterWordLowercase = trim(this.filterWord.toLowerCase()) const keywordLowercase = trim(this.filterWord.toLowerCase())
const orderedEmojiList = [] const orderedEmojiList = []
for (const emoji of this.$store.getters.standardEmojiList) { 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 indexOfKeyword = indices.length ? Math.min(...indices) : -1
const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) if (indexOfKeyword > -1) {
if (indexOfFilterWord > -1) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) { orderedEmojiList[indexOfKeyword] = []
orderedEmojiList[indexOfFilterWord] = []
} }
orderedEmojiList[indexOfFilterWord].push(emoji) orderedEmojiList[indexOfKeyword].push(emoji)
} }
} }
return orderedEmojiList.flat() return orderedEmojiList.flat()

@ -24,7 +24,7 @@
v-for="emoji in commonEmojis" v-for="emoji in commonEmojis"
:key="emoji.replacement" :key="emoji.replacement"
class="emoji-button" class="emoji-button"
:title="emoji.displayText" :title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)" @click="addReaction($event, emoji.replacement, close)"
> >
{{ emoji.replacement }} {{ emoji.replacement }}
@ -34,7 +34,7 @@
v-for="(emoji, key) in emojis" v-for="(emoji, key) in emojis"
:key="key" :key="key"
class="emoji-button" class="emoji-button"
:title="emoji.displayText" :title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)" @click="addReaction($event, emoji.replacement, close)"
> >
{{ emoji.replacement }} {{ emoji.replacement }}

@ -17,6 +17,9 @@ const ReplyButton = {
computed: { computed: {
loggedIn () { loggedIn () {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
} }
} }
} }

@ -26,13 +26,19 @@
/> />
</FALayers> </FALayers>
</button> </button>
<span v-else> <a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon <FAIcon
icon="reply" icon="reply"
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.reply')" :title="$t('tool_tip.reply')"
/> />
</span> </a>
<span <span
v-if="status.replies_count > 0" v-if="status.replies_count > 0"
class="action-counter" class="action-counter"

@ -36,6 +36,9 @@ const RetweetButton = {
computed: { computed: {
mergedConfig () { mergedConfig () {
return this.$store.getters.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')" :title="$t('timeline.no_retweet_hint')"
/> />
</span> </span>
<span v-else> <a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
icon="retweet" icon="retweet"
:title="$t('tool_tip.repeat')" :title="$t('tool_tip.repeat')"
/> />
</span> </a>
<span <span
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0" v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
class="no-event" class="no-event"

@ -8,6 +8,7 @@ import {
faCircleNotch, faCircleNotch,
faSearch faSearch
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { uniqBy } from 'lodash'
library.add( library.add(
faCircleNotch, faCircleNotch,
@ -32,7 +33,11 @@ const Search = {
userIds: [], userIds: [],
statuses: [], statuses: [],
hashtags: [], hashtags: [],
currenResultTab: 'statuses' currenResultTab: 'statuses',
statusesOffset: 0,
lastStatusFetchCount: 0,
lastQuery: ''
} }
}, },
computed: { computed: {
@ -61,26 +66,42 @@ const Search = {
this.$router.push({ name: 'search', query: { query } }) this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus() this.$refs.searchInput.focus()
}, },
search (query) { search (query, searchType = null) {
if (!query) { if (!query) {
this.loading = false this.loading = false
return return
} }
this.loading = true this.loading = true
this.userIds = []
this.statuses = []
this.hashtags = []
this.$refs.searchInput.blur() this.$refs.searchInput.blur()
if (this.lastQuery !== query) {
this.userIds = []
this.hashtags = []
this.statuses = []
this.statusesOffset = 0
this.lastStatusFetchCount = 0
}
this.$store.dispatch('search', { q: query, resolve: true }) this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType })
.then(data => { .then(data => {
this.loading = false this.loading = false
this.userIds = map(data.accounts, 'id')
this.statuses = data.statuses const oldLength = this.statuses.length
this.hashtags = data.hashtags
// 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.currenResultTab = this.getActiveTab()
this.loaded = true 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) { resultCount (tabName) {

@ -22,7 +22,7 @@
</button> </button>
</div> </div>
<div <div
v-if="loading" v-if="loading && statusesOffset == 0"
class="text-center loading-icon" class="text-center loading-icon"
> >
<FAIcon <FAIcon
@ -55,12 +55,6 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div v-if="currenResultTab === 'statuses'"> <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 <Status
v-for="status in visibleStatuses" v-for="status in visibleStatuses"
:key="status.id" :key="status.id"
@ -71,6 +65,33 @@
:statusoid="status" :statusoid="status"
:no-heading="false" :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>
<div v-else-if="currenResultTab === 'people'"> <div v-else-if="currenResultTab === 'people'">
<div <div
@ -208,6 +229,11 @@
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.more-statuses-button {
height: 3.5em;
line-height: 3.5em;
}
</style> </style>

@ -13,7 +13,7 @@ const StaffPanel = {
}, },
computed: { computed: {
groupedStaffAccounts () { groupedStaffAccounts () {
const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _) const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _)
const groupedStaffAccounts = groupBy(staffAccounts, 'role') const groupedStaffAccounts = groupBy(staffAccounts, 'role')
return [ return [
@ -22,7 +22,7 @@ const StaffPanel = {
].filter(group => group.users) ].filter(group => group.users)
}, },
...mapGetters([ ...mapGetters([
'findUser' 'findUserByName'
]), ]),
...mapState({ ...mapState({
staffAccounts: state => state.instance.staffAccounts staffAccounts: state => state.instance.staffAccounts

@ -6,8 +6,8 @@
:timeline-name="timelineName" :timeline-name="timelineName"
/> />
<div <div
class="rightside-button"
v-if="showScrollTop && !embedded" v-if="showScrollTop && !embedded"
class="rightside-button"
> >
<button <button
class="button-unstyled scroll-to-top-button" class="button-unstyled scroll-to-top-button"
@ -26,8 +26,8 @@
</div> </div>
<template v-if="mobileLayout && !embedded"> <template v-if="mobileLayout && !embedded">
<div <div
class="rightside-button"
v-if="showLoadButton" v-if="showLoadButton"
class="rightside-button"
> >
<button <button
class="button-unstyled loadmore-button" class="button-unstyled loadmore-button"
@ -72,8 +72,14 @@
{{ $t('timeline.up_to_date') }} {{ $t('timeline.up_to_date') }}
</div> </div>
</template> </template>
<QuickFilterSettings v-if="!embedded" class="rightside-button"/> <QuickFilterSettings
<QuickViewSettings v-if="!embedded" class="rightside-button"/> v-if="!embedded"
class="rightside-button"
/>
<QuickViewSettings
v-if="!embedded"
class="rightside-button"
/>
</div> </div>
<div :class="classes.body"> <div :class="classes.body">
<div <div

@ -125,6 +125,10 @@ export default {
hideFollowersCount () { hideFollowersCount () {
return this.isOtherUser && this.user.hide_followers_count 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']) ...mapGetters(['mergedConfig'])
}, },
components: { components: {

@ -258,7 +258,7 @@
</button> </button>
</div> </div>
<ModerationTools <ModerationTools
v-if="loggedIn.role === &quot;admin&quot;" v-if="showModerationMenu"
:user="user" :user="user"
/> />
</div> </div>

@ -986,7 +986,9 @@
"hashtags": "Hashtags", "hashtags": "Hashtags",
"person_talking": "{count} person talking", "person_talking": "{count} person talking",
"people_talking": "{count} people 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": { "password_reset": {
"forgot_password": "Forgot password?", "forgot_password": "Forgot password?",

@ -36,6 +36,8 @@ const REGIONAL_INDICATORS = (() => {
return res return res
})() })()
const REMOTE_INTERACTION_URL = '/main/ostatus'
const defaultState = { const defaultState = {
// Stuff from apiConfig // Stuff from apiConfig
name: 'Pleroma FE', name: 'Pleroma FE',
@ -214,6 +216,18 @@ const instance = {
}, },
instanceDomain (state) { instanceDomain (state) {
return new URL(state.server).hostname 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: { actions: {

@ -761,8 +761,8 @@ const statuses = {
rootState.api.backendInteractor.fetchRebloggedByUsers({ id }) rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
.then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })) .then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }))
}, },
search (store, { q, resolve, limit, offset, following }) { search (store, { q, resolve, limit, offset, following, type }) {
return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following }) return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following, type })
.then((data) => { .then((data) => {
store.commit('addNewUsers', data.accounts) store.commit('addNewUsers', data.accounts)
store.commit('addNewStatuses', { statuses: data.statuses }) store.commit('addNewStatuses', { statuses: data.statuses })

@ -1278,7 +1278,7 @@ const searchUsers = ({ credentials, query }) => {
.then((data) => data.map(parseUser)) .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 let url = MASTODON_SEARCH_2
const params = [] const params = []
@ -1302,6 +1302,10 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
params.push(['following', true]) params.push(['following', true])
} }
if (type) {
params.push(['following', type])
}
params.push(['with_relationships', true]) params.push(['with_relationships', true])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')

@ -124,6 +124,34 @@ export const parseUser = (data) => {
} else { } else {
output.role = 'member' 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) { if (data.source) {

Loading…
Cancel
Save