Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into froth
continuous-integration/drone/push Build was killed Details

This commit is contained in:
Sam Therapy 2022-11-29 16:52:23 +01:00
commit f40c9d69e8
Signed by: sam
GPG Key ID: 4D8B07C18F31ACBD
70 changed files with 1687 additions and 1003 deletions

View 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

View 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

View File

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

View 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

View File

@ -1 +1 @@
16.16.0
16.18.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

@ -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(&quot;admin&quot;)"
@ -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)"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,6 +36,9 @@ const RetweetButton = {
computed: {
mergedConfig () {
return this.$store.getters.mergedConfig
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('&')

View File

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

View File

@ -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))
}
/**

View File

@ -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']
},

View File

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

View File

@ -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">',

View File

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

895
yarn.lock

File diff suppressed because it is too large Load Diff