...
 
Commits (138)
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Ability to hide/show repeats from user
- User profile button clutter organized into a menu
- User profile button clutter organized into a menu
- Emoji picker
- Started changelog anew
- Ability to change user's email
- About page
### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed
......
......@@ -45,7 +45,7 @@ export default {
}),
created () {
// Load the locale from the storage
this.$i18n.locale = this.$store.state.config.interfaceLanguage
this.$i18n.locale = this.$store.getters.mergedConfig.interfaceLanguage
window.addEventListener('resize', this.updateMobileState)
},
destroyed () {
......@@ -93,7 +93,7 @@ export default {
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.state.config.hideISP &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
......
......@@ -39,10 +39,13 @@ h4 {
text-align: center;
}
html {
font-size: 14px;
}
body {
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
font-size: 14px;
margin: 0;
color: $fallback--text;
color: var(--text, $fallback--text);
......@@ -658,6 +661,18 @@ nav {
color: var(--alertErrorPanelText, $fallback--text);
}
}
&.warning {
background-color: $fallback--alertWarning;
background-color: var(--alertWarning, $fallback--alertWarning);
color: $fallback--text;
color: var(--alertWarningText, $fallback--text);
.panel-heading & {
color: $fallback--text;
color: var(--alertWarningPanelText, $fallback--text);
}
}
}
.faint {
......@@ -705,31 +720,6 @@ nav {
}
}
@keyframes modal-background-fadein {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
}
.button-icon {
font-size: 1.2em;
}
......
......@@ -17,6 +17,7 @@ $fallback--cGreen: #0fa00f;
$fallback--cOrange: orange;
$fallback--alertError: rgba(211,16,20,.5);
$fallback--alertWarning: rgba(111,111,20,.5);
$fallback--panelRadius: 10px;
$fallback--checkboxRadius: 2px;
......
......@@ -173,58 +173,6 @@ const getStickers = async ({ store }) => {
}
}
const getStaticEmoji = async ({ store }) => {
try {
const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return {
displayText: key,
imageUrl: false,
replacement: values[key]
}
}).sort((a, b) => a.displayText - b.displayText)
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
}
// This is also used to indicate if we have a 'pleroma backend' or not.
// Somewhat weird, should probably be somewhere else.
const getCustomEmoji = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = value.image_url
return {
displayText: key,
imageUrl: imageUrl ? store.state.instance.server + imageUrl : value,
tags: imageUrl ? value.tags.sort((a, b) => a > b ? 1 : 0) : ['utf'],
replacement: `:${key}: `
}
// Technically could use tags but those are kinda useless right now, should have been "pack" field, that would be more useful
}).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : 0)
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
} else {
throw (res)
}
} catch (e) {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
console.warn("Can't load custom emojis, maybe not a Pleroma instance?")
console.warn(e)
}
}
const getAppSecret = async ({ store }) => {
const { state, commit } = store
const { oauth, instance } = state
......@@ -236,6 +184,15 @@ const getAppSecret = async ({ store }) => {
})
}
const resolveStaffAccounts = async ({ store, accounts }) => {
const backendInteractor = store.state.api.backendInteractor
let nicknames = accounts.map(uri => uri.split('/').pop())
.map(id => backendInteractor.fetchUser({ id }))
nicknames = await Promise.all(nicknames)
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
const getNodeInfo = async ({ store }) => {
try {
const res = await window.fetch('/nodeinfo/2.0.json')
......@@ -259,10 +216,17 @@ const getNodeInfo = async ({ store }) => {
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') })
const federation = metadata.federation
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
const accounts = metadata.staffAccounts
await resolveStaffAccounts({ store, accounts })
} else {
throw (res)
}
......@@ -315,8 +279,6 @@ const afterStoreSetup = async ({ store, i18n }) => {
getTOS({ store }),
getInstancePanel({ store }),
getStickers({ store }),
getStaticEmoji({ store }),
getCustomEmoji({ store }),
getNodeInfo({ store })
])
......
......@@ -18,6 +18,7 @@ import AuthForm from 'components/auth_form/auth_form.js'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
......@@ -42,6 +43,16 @@ export default (store) => {
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
......
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
const About = {
components: {
InstanceSpecificPanel,
FeaturesPanel,
TermsOfServicePanel
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel
},
computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
}
}
}
......
<template>
<div class="sidebar">
<instance-specific-panel />
<features-panel v-if="showFeaturesPanel" />
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
<MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" />
</div>
</template>
......
......@@ -10,13 +10,14 @@ const Attachment = {
'statusId',
'size',
'allowPlay',
'setMedia'
'setMedia',
'naturalSizeLoad'
],
data () {
return {
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.state.config.hideNsfw,
preloadImage: this.$store.state.config.preloadImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
......@@ -57,7 +58,7 @@ const Attachment = {
}
},
openModal (event) {
const modalTypes = this.$store.state.config.playVideosInModal
const modalTypes = this.$store.getters.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
......@@ -70,7 +71,7 @@ const Attachment = {
}
},
toggleHidden (event) {
if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
if (this.$store.getters.mergedConfig.useOneClickNsfw && !this.showHidden) {
this.openModal(event)
return
}
......@@ -88,6 +89,11 @@ const Attachment = {
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
}
}
}
......
......@@ -58,6 +58,7 @@
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
/>
</a>
......
<template>
<label class="checkbox">
<label
class="checkbox"
:class="{ disabled, indeterminate }"
>
<input
type="checkbox"
:disabled="disabled"
:checked="checked"
:indeterminate.prop="indeterminate"
@change="$emit('change', $event.target.checked)"
>
<i class="checkbox-indicator" />
<span v-if="!!$slots.default"><slot /></span>
<span
v-if="!!$slots.default"
class="label"
>
<slot />
</span>
</label>
</template>
......@@ -17,7 +26,11 @@ export default {
prop: 'checked',
event: 'change'
},
props: ['checked', 'indeterminate']
props: [
'checked',
'indeterminate',
'disabled'
]
}
</script>
......@@ -27,12 +40,16 @@ export default {
.checkbox {
position: relative;
display: inline-block;
padding-left: 1.2em;
min-height: 1.2em;
&-indicator {
position: relative;
padding-left: 1.2em;
}
&-indicator::before {
position: absolute;
left: 0;
right: 0;
top: 0;
display: block;
content: '✔';
......@@ -54,6 +71,17 @@ export default {
box-sizing: border-box;
}
&.disabled {
.checkbox-indicator::before,
.label {
opacity: .5;
}
.label {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
input[type=checkbox] {
display: none;
......@@ -68,9 +96,6 @@ export default {
color: var(--text, $fallback--text);
}
&:disabled + .checkbox-indicator::before {
opacity: .5;
}
}
& > span {
......
......@@ -99,7 +99,7 @@ const EmojiInput = {
},
computed: {
padEmoji () {
return this.$store.state.config.padEmoji
return this.$store.getters.mergedConfig.padEmoji
},
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
......@@ -165,6 +165,7 @@ const EmojiInput = {
methods: {
triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
})
......@@ -181,6 +182,7 @@ const EmojiInput = {
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
}
},
replace (replacement) {
......@@ -306,6 +308,16 @@ const EmojiInput = {
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input.elm
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onTransition (e) {
this.resize()
......@@ -419,11 +431,14 @@ const EmojiInput = {
this.caret = selectionStart
},
resize () {
const { panel } = this.$refs
const { panel, picker } = this.$refs
if (!panel) return
const { offsetHeight, offsetTop } = this.input.elm
this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
this.$refs.picker.$el.style.top = (offsetTop + offsetHeight) + 'px'
const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px'
picker.$el.style.top = offsetBottom + 'px'
picker.$el.style.bottom = 'auto'
}
}
}
......
......@@ -2,6 +2,7 @@
<div
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
<slot />
<template v-if="enableEmojiPicker">
......@@ -63,6 +64,10 @@
flex-direction: column;
position: relative;
&.with-picker input {
padding-right: 30px;
}
.emoji-picker-icon {
position: absolute;
top: 0;
......
import Checkbox from '../checkbox/checkbox.vue'
// At widest, approximately 20 emoji are visible in a row,
// loading 3 rows, could be overkill for narrow picker
const LOAD_EMOJI_BY = 60
// When to start loading new batch emoji, in pixels
const LOAD_EMOJI_MARGIN = 64
const filterByKeyword = (list, keyword = '') => {
return list.filter(x => x.displayText.includes(keyword))
......@@ -13,22 +21,37 @@ const EmojiPicker = {
},
data () {
return {
labelKey: String(Math.random() * 100000),
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false
keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false
}
},
components: {
StickerPicker: () => import('../sticker_picker/sticker_picker.vue')
StickerPicker: () => import('../sticker_picker/sticker_picker.vue'),
Checkbox
},
methods: {
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
},
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
this.triggerLoadMore(target)
},
highlight (key) {
const ref = this.$refs['group-' + key]
const top = ref[0].offsetTop
......@@ -38,9 +61,7 @@ const EmojiPicker = {
this.$refs['emoji-groups'].scrollTop = top + 1
})
},
scrolledGroup (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
const top = target.scrollTop + 5
updateScrolledClass (target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
......@@ -48,6 +69,28 @@ const EmojiPicker = {
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom'][0]
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
const scrollerBottom = target.scrollTop + target.clientHeight
const scrollerTop = target.scrollTop
const scrollerMax = target.scrollHeight
// Loads more emoji when they come into view
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
// Always load when at the very top in case there's no scroll space yet
const atTop = scrollerTop < 5
// Don't load when looking at unicode category or at the very bottom
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
if (!bottomAboveViewport && (approachingBottom || atTop)) {
this.loadEmoji()
}
},
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
......@@ -57,22 +100,41 @@ const EmojiPicker = {
})
})
},
loadEmoji () {
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
if (allLoaded) {
return
}
this.customEmojiBufferSlice += LOAD_EMOJI_BY
},
startEmojiLoad (forceUpdate = false) {
if (!forceUpdate) {
this.keyword = ''
}
this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = 0
})
const bufferSize = this.customEmojiBuffer.length
const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
if (bufferPrefilledAll && !forceUpdate) {
return
}
this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
toggleStickers () {
this.showingStickers = !this.showingStickers
},
setShowStickers (value) {
this.showingStickers = value
},
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
this.$emit('sticker-upload-failed', e)
}
},
watch: {
keyword () {
this.scrolledGroup()
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: {
......@@ -85,15 +147,25 @@ const EmojiPicker = {
}
return 0
},
filteredEmoji () {
return filterByKeyword(
this.$store.state.instance.customEmoji || [],
this.keyword
)
},
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () {
const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.$store.state.instance.customEmoji || []
const customEmojis = this.customEmojiBuffer
return [
{
id: 'custom',
text: this.$t('emoji.custom'),
icon: 'icon-smile',
emojis: filterByKeyword(customEmojis, this.keyword)
emojis: customEmojis
},
{
id: 'standard',
......
......@@ -6,14 +6,20 @@
position: absolute;
right: 0;
left: 0;
height: 320px;
margin: 0 !important;
z-index: 1;
.keep-open {
.keep-open,
.too-many-emoji {
padding: 7px;
line-height: normal;
}
.too-many-emoji {
display: flex;
flex-direction: column;
}
.keep-open-label {
padding: 0 7px;
display: flex;
......@@ -28,7 +34,7 @@
.content {
display: flex;
flex-direction: column;
flex: 1 1 0;
flex: 1 1 auto;
min-height: 0px;
}
......@@ -36,12 +42,16 @@
flex-grow: 1;
}
.emoji-groups {
min-height: 200px;
}
.additional-tabs {
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
padding-left: 7px;
flex: 0 0 0;
flex: 0 0 auto;
}
.additional-tabs,
......@@ -72,7 +82,7 @@
}
.sticker-picker {
flex: 1 1 0
flex: 1 1 auto
}
.stickers,
......@@ -80,7 +90,7 @@
&-content {
display: flex;
flex-direction: column;
flex: 1 1 0;
flex: 1 1 auto;
min-height: 0;
&.hidden {
......@@ -94,7 +104,7 @@
.emoji {
&-search {
padding: 5px;
flex: 0 0 0;
flex: 0 0 auto;
input {
width: 100%;
......
......@@ -47,7 +47,7 @@
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
@scroll="scrolledGroup"
@scroll="onScroll"
>
<div
v-for="group in emojisView"
......@@ -73,24 +73,13 @@
:src="emoji.imageUrl"
>
</span>
<span :ref="'group-end-' + group.id" />
</div>
</div>
<div
class="keep-open"
>
<input
:id="labelKey + 'keep-open'"
v-model="keepOpen"
type="checkbox"
>
<label
class="keep-open-label"
:for="labelKey + 'keep-open'"
>
<div class="keep-open-label-text">
{{ $t('emoji.keep_open') }}
</div>
</label>
<div class="keep-open">
<Checkbox v-model="keepOpen">
{{ $t('emoji.keep_open') }}
</Checkbox>
</div>
</div>
<div
......
......@@ -4,8 +4,6 @@
trigger="click"
placement="top"
class="extra-button-popover"
:offset="5"
:container="false"
>
<div slot="popover">
<div class="dropdown-menu">
......
import { mapGetters } from 'vuex'
const FavoriteButton = {
props: ['status', 'loggedIn'],
data () {
return {
hidePostStatsLocal: typeof this.$store.state.config.hidePostStats === 'undefined'
? this.$store.state.instance.hidePostStats
: this.$store.state.config.hidePostStats,
animated: false
}
},
......@@ -28,7 +27,8 @@ const FavoriteButton = {
'icon-star': this.status.favorited,
'animate-spin': this.animated
}
}
},
...mapGetters(['mergedConfig'])
}
}
......
......@@ -6,7 +6,7 @@
:title="$t('tool_tip.favorite')"
@click.prevent="favorite()"
/>
<span v-if="!hidePostStatsLocal && status.fave_num > 0">{{ status.fave_num }}</span>
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
</div>
<div v-else>
<i
......@@ -14,7 +14,7 @@
class="button-icon favorite-button"
:title="$t('tool_tip.favorite')"
/>
<span v-if="!hidePostStatsLocal && status.fave_num > 0">{{ status.fave_num }}</span>
<span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
</div>
</template>
......
import Attachment from '../attachment/attachment.vue'
import { chunk, last, dropRight } from 'lodash'
import { chunk, last, dropRight, sumBy } from 'lodash'
const Gallery = {
data: () => ({
width: 500
}),
props: [
'attachments',
'nsfw',
'setMedia'
],
components: { Attachment },
mounted () {
this.resize()
window.addEventListener('resize', this.resize)
},
destroyed () {
window.removeEventListener('resize', this.resize)
data () {
return {
sizes: {}
}
},
components: { Attachment },
computed: {
rows () {
if (!this.attachments) {
......@@ -33,21 +28,24 @@ const Gallery = {
}
return rows
},
rowHeight () {
return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` })
},
useContainFit () {
return this.$store.state.config.useContainFit
return this.$store.getters.mergedConfig.useContainFit
}
},
methods: {
resize () {
// Quick optimization to make resizing not always trigger state change,
// only update attachment size in 10px steps
const width = Math.floor(this.$el.getBoundingClientRect().width / 10) * 10
if (this.width !== width) {
this.width = width
}
onNaturalSizeLoad (id, size) {
this.$set(this.sizes, id, size)
},
rowStyle (itemsPerRow) {
return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
},
itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id))
return { flex: `${this.getAspectRatio(id) / total} 1 0%` }
},
getAspectRatio (id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
}
}
}
......
......@@ -7,17 +7,21 @@
v-for="(row, index) in rows"
:key="index"
class="gallery-row"
:style="rowHeight(row.length)"
:style="rowStyle(row.length)"
:class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
>
<attachment
v-for="attachment in row"
:key="attachment.id"
:set-media="setMedia"
:nsfw="nsfw"
:attachment="attachment"
:allow-play="false"
/>
<div class="gallery-row-inner">
<attachment
v-for="attachment in row"
:key="attachment.id"
:set-media="setMedia"
:nsfw="nsfw"
:attachment="attachment"
:allow-play="false"
:natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
:style="itemStyle(attachment.id, row)"
/>
</div>
</div>
</div>
</template>
......@@ -28,15 +32,24 @@
@import '../../_variables.scss';
.gallery-row {
height: 200px;
position: relative;
height: 0;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
flex-grow: 1;
margin-top: 0.5em;
.gallery-row-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: stretch;
}
// FIXME: specificity problem with this and .attachments.attachment
// we shouldn't have the need for .image here
.attachment.image {
......
......@@ -40,7 +40,7 @@ export default {
},
language: {
get: function () { return this.$store.state.config.interfaceLanguage },
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
this.$i18n.locale = val
......
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import GestureService from '../../services/gesture_service/gesture_service'
const MediaModal = {
components: {
StillImage,
VideoAttachment
VideoAttachment,
Modal
},
computed: {
showing () {
......@@ -27,7 +30,27 @@ const MediaModal = {
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
}
},
created () {
this.mediaSwipeGestureRight = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
this.goPrev,
50
)
this.mediaSwipeGestureLeft = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.goNext,
50
)
},
methods: {
mediaTouchStart (e) {
GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
},
mediaTouchMove (e) {
GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
},
hide () {
this.$store.dispatch('closeMediaViewer')
},
......
<template>
<div
<Modal
v-if="showing"
v-body-scroll-lock="showing"
class="modal-view media-modal-view"
@click.prevent="hide"
class="media-modal-view"
@backdropClicked="hide"
>
<img
v-if="type === 'image'"
class="modal-image"
:src="currentMedia.url"
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
:attachment="currentMedia"
:controls="true"
@click.stop.native=""
/>
<button
v-if="canNavigate"
......@@ -33,33 +34,25 @@
>
<i class="icon-right-open arrow-icon" />
</button>
</div>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.media-modal-view {
.modal-view.media-modal-view {
z-index: 1001;
body:not(.scroll-locked) & {
display: none;
}
&:hover {
.modal-view-button-arrow {
opacity: 0.75;
.modal-view-button-arrow {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
}
......@@ -114,5 +107,4 @@
}
}
}
</style>
......@@ -6,16 +6,16 @@
@drop="fileDrop"
>
<label
class="btn btn-default"
class="label"
:title="$t('tool_tip.media_upload')"
>
<i
v-if="uploading"
class="icon-spin4 animate-spin"
class="progress-icon icon-spin4 animate-spin"
/>
<i
v-if="!uploading"
class="icon-upload"
class="new-icon icon-upload"
/>
<input
v-if="uploadReady"
......@@ -30,15 +30,24 @@
<script src="./media_upload.js" ></script>
<style>
<style lang="scss">
.media-upload {
.icon-upload {
.label {
display: inline-block;
}
.new-icon {
cursor: pointer;
}
label {
display: block;
width: 100%;
.progress-icon {
display: inline-block;
line-height: 0;
&::before {
/* Overriding fontello to achieve the perfect speeeen */
margin: 0;
line-height: 0;
}
}
}
</style>
</style>
......@@ -63,7 +63,7 @@ const MobileNav = {
this.$refs.notifications.markAsSeen()
},
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
if (this.$store.state.config.autoLoad && scrollTop + clientHeight >= scrollHeight) {
if (this.$store.getters.mergedConfig.autoLoad && scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications()
}
}
......
......@@ -30,7 +30,7 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
autohideFloatingPostButton () {
return !!this.$store.state.config.autohideFloatingPostButton
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
}
},
watch: {
......
<template>
<div
v-show="isOpen"
v-body-scroll-lock="isOpen"
class="modal-view"
@click.self="$emit('backdropClicked')"
>
<slot />
</div>
</template>
<script>
export default {
props: {
isOpen: {
type: Boolean,
default: true
}
}
}
</script>
<style lang="scss">
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
body:not(.scroll-locked) & {
opacity: 0;
}
}
@keyframes modal-background-fadein {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
</style>
......@@ -3,9 +3,7 @@
<v-popover
trigger="click"
class="moderation-tools-popover"
:container="false"
placement="bottom-end"
:offset="5"
@show="showDropDown = true"
@hide="showDropDown = false"
>
......
import { mapState } from 'vuex'
const MRFTransparencyPanel = {
computed: mapState({
federationPolicy: state => state.instance.federationPolicy,
mrfPolicies: state => state.instance.federationPolicy.mrf_policies,
acceptInstances: state => state.instance.federationPolicy.mrf_simple.accept,
rejectInstances: state => state.instance.federationPolicy.mrf_simple.reject,
quarantineInstances: state => state.instance.federationPolicy.quarantined_instances,
ftlRemovalInstances: state => state.instance.federationPolicy.mrf_simple.federated_timeline_removal,
mediaNsfwInstances: state => state.instance.federationPolicy.mrf_simple.media_nsfw,
mediaRemovalInstances: state => state.instance.federationPolicy.mrf_simple.media_removal
})
}
export default MRFTransparencyPanel
<template>
<div
v-if="federationPolicy"
class="mrf-transparency-panel"
>
<div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background">
<div class="title">
{{ $t("about.federation") }}
</div>
</div>
<div class="panel-body">
<div class="mrf-section">
<h2>{{ $t("about.mrf_policies") }}</h2>
<p>{{ $t("about.mrf_policies_desc") }}</p>
<ul>
<li
v-for="policy in mrfPolicies"
:key="policy"
v-text="policy"
/>
</ul>
<h2>{{ $t("about.mrf_policy_simple") }}</h2>
<div v-if="acceptInstances.length">
<h4>{{ $t("about.mrf_policy_simple_accept") }}</h4>
<p>{{ $t("about.mrf_policy_simple_accept_desc") }}</p>
<ul>
<li
v-for="instance in acceptInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
<div v-if="rejectInstances.length">
<h4>{{ $t("about.mrf_policy_simple_reject") }}</h4>
<p>{{ $t("about.mrf_policy_simple_reject_desc") }}</p>
<ul>
<li
v-for="instance in rejectInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
<div v-if="quarantineInstances.length">
<h4>{{ $t("about.mrf_policy_simple_quarantine") }}</h4>
<p>{{ $t("about.mrf_policy_simple_quarantine_desc") }}</p>
<ul>
<li
v-for="instance in quarantineInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
<div v-if="ftlRemovalInstances.length">
<h4>{{ $t("about.mrf_policy_simple_ftl_removal") }}</h4>
<p>{{ $t("about.mrf_policy_simple_ftl_removal_desc") }}</p>
<ul>
<li
v-for="instance in ftlRemovalInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
<div v-if="mediaNsfwInstances.length">
<h4>{{ $t("about.mrf_policy_simple_media_nsfw") }}</h4>
<p>{{ $t("about.mrf_policy_simple_media_nsfw_desc") }}</p>
<ul>
<li
v-for="instance in mediaNsfwInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
<div v-if="mediaRemovalInstances.length">
<h4>{{ $t("about.mrf_policy_simple_media_removal") }}</h4>
<p>{{ $t("about.mrf_policy_simple_media_removal_desc") }}</p>
<ul>
<li
v-for="instance in mediaRemovalInstances"
:key="instance"
v-text="instance"
/>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./mrf_transparency_panel.js"></script>
<style lang="scss">
.mrf-section {
margin: 1em;
}
</style>
......@@ -38,6 +38,11 @@
{{ $t("nav.twkn") }}
</router-link>
</li>
<li>
<router-link :to="{ name: 'about' }">
{{ $t("nav.about") }}
</router-link>
</li>
</ul>
</div>
</div>
......
......@@ -39,7 +39,7 @@ const Notification = {
return highlightClass(this.notification.from_profile)
},
userStyle () {
const highlight = this.$store.state.config.highlight
const highlight = this.$store.getters.mergedConfig.highlight
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
......