Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
297 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index f666a4ef02..bfc41ac4e2 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -1,12 +1,14 @@
Contributors of this project.
- Constance Variable (lambadalambda@social.heldscal.la): Code
- Coco Snuss (cocosnuss@social.heldscal.la): Code
- wakarimasen (wakarimasen@shitposter.club): NSFW hiding image
- eris (eris@disqordia.space): Code
- dtluna (dtluna@social.heldscal.la): Code
- sonyam (sonyam@social.heldscal.la): Background images
- hakui (hakui@freezepeach.xyz): CSS and styling
- shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code
+- Sean King (seanking@freespeechextremist.com): Code
+- Tusooa Zhu (tusooa@kazv.moe): Code
diff --git a/src/App.js b/src/App.js
index 18dd61f2c2..b7eb2f72e1 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,130 +1,135 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal,
PostStatusModal,
+ EditStatusModal,
+ StatusHistoryModal,
GlobalNoticeList
},
data: () => ({
mobileActivePanel: 'timeline'
}),
created () {
// Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
},
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
},
computed: {
classes () {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown
},
'-' + this.layoutType
]
},
navClasses () {
const { navbarColumnStretch } = this.$store.getters.mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : [])
]
},
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
return this.mergedConfig.hideInstanceWallpaper
? null
: this.$store.state.instance.background
},
background () { return this.userBackground || this.instanceBackground },
bgStyle () {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`
}
}
},
shout () { return this.$store.state.shout.joined },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit () {
return this.$route.name === 'lists-edit'
},
newPostButtonShown () {
if (this.isChats) return false
if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+ editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
hideShoutbox () {
return this.$store.getters.mergedConfig.hideShoutbox
},
layoutType () { return this.$store.state.interface.layoutType },
privateMode () { return this.$store.state.instance.private },
reverseLayout () {
const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting
}
},
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
...mapGetters(['mergedConfig'])
},
methods: {
updateMobileState () {
this.$store.dispatch('setLayoutWidth', windowWidth())
this.$store.dispatch('setLayoutHeight', windowHeight())
}
}
}
diff --git a/src/App.vue b/src/App.vue
index a993c2381e..e0d709f75e 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,79 +1,81 @@
<template>
<div
id="app-loaded"
:style="bgStyle"
>
<div
id="app_bg_wrapper"
class="app-bg-wrapper"
/>
<MobileNav v-if="layoutType === 'mobile'" />
<DesktopNav
v-else
:class="navClasses"
/>
<Notifications v-if="currentUser" />
<div
id="content"
class="app-layout container"
:class="classes"
>
<div class="underlay" />
<div
id="sidebar"
class="column -scrollable"
:class="{ '-show-scrollbar': showScrollbars }"
>
<user-panel />
<template v-if="layoutType !== 'mobile'">
<nav-panel />
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<features-panel v-if="!currentUser && showFeaturesPanel" />
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
<div id="notifs-sidebar" />
</template>
</div>
<div
id="main-scroller"
class="column main"
:class="{ '-full-height': isChats || isListEdit }"
>
<div
v-if="!currentUser"
class="login-hint panel panel-default"
>
<router-link
:to="{ name: 'login' }"
class="panel-body"
>
{{ $t("login.hint") }}
</router-link>
</div>
<router-view />
</div>
<div
id="notifs-column"
class="column -scrollable"
:class="{ '-show-scrollbar': showScrollbars }"
/>
</div>
<MediaModal />
<shout-panel
v-if="currentUser && shout && !hideShoutbox"
:floating="true"
class="floating-shout mobile-hidden"
:class="{ '-left': shoutboxPosition }"
/>
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
+ <EditStatusModal v-if="editingAvailable" />
+ <StatusHistoryModal v-if="editingAvailable" />
<SettingsModal />
<UpdateNotification />
<div id="modal" />
<GlobalNoticeList />
<div id="popovers" />
</div>
</template>
<script src="./App.js"></script>
<style lang="scss" src="./App.scss"></style>
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 38b5f38e8c..886d52f276 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,410 +1,411 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import App from '../App.vue'
import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
return staticInitialResults
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
} else {
throw (res)
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
}
const getBackendProvidedConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config.theme)
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
console.warn(e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load stickers")
console.warn(e)
}
}
const getAppSecret = async ({ store }) => {
const { state, commit } = store
const { oauth, instance } = state
return getOrCreateApp({ ...oauth, instance: instance.server, commit })
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
const getNodeInfo = async ({ store }) => {
try {
const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
+ store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
const federation = metadata.federation
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', {
name: 'federating',
value: typeof federation.enabled === 'undefined'
? true
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
}
const checkOAuthToken = async ({ store }) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) {
try {
await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) {
console.error(e)
}
}
resolve()
})
}
const afterStoreSetup = async ({ store, i18n }) => {
store.dispatch('setLayoutWidth', windowWidth())
store.dispatch('setLayoutHeight', windowHeight())
FaviconService.initFaviconService()
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
const { theme } = store.state.instance
const customThemePresent = customThemeSource || customTheme
if (customThemePresent) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
}
} else if (theme) {
// do nothing, it will load asynchronously
} else {
console.error('Failed to load any theme!')
}
applyConfig(store.state.config)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store })
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
}
})
const app = createApp(App)
app.use(router)
app.use(store)
app.use(i18n)
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index d62a4adc21..5dc5047556 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,206 +1,209 @@
import StillImage from '../still-image/still-image.vue'
import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
)
const Attachment = {
props: [
'attachment',
'description',
'hideDescription',
'nsfw',
'size',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit'
],
data () {
return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
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,
showHidden: false,
flashLoaded: false,
showDescription: false
}
},
components: {
Flash,
StillImage,
VideoAttachment
},
computed: {
classNames () {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined
},
'-type-' + this.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
]
},
usePlaceholder () {
return this.size === 'hide'
},
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
},
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass () {
if (this.type === 'image') return 'image'
if (this.type === 'video') return 'video'
if (this.type === 'audio') return 'music'
return 'file'
},
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed)
},
useModal () {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.type)
},
videoTag () {
return this.useModal ? 'button' : 'span'
},
...mapGetters(['mergedConfig'])
},
watch: {
+ 'attachment.description' (newVal) {
+ this.localDescription = newVal
+ },
localDescription (newVal) {
this.onEdit(newVal)
}
},
methods: {
linkClicked ({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal (event) {
if (this.useModal) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
} else if (this.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce (event) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
},
onEdit (event) {
this.edit && this.edit(this.attachment, event)
},
onRemove () {
this.remove && this.remove(this.attachment)
},
onShiftUp () {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn () {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash () {
this.$refs.flash.closePlayer()
},
setFlashLoaded (event) {
this.flashLoaded = event
},
toggleDescription () {
this.showDescription = !this.showDescription
},
toggleHidden (event) {
if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
}
}
}
export default Attachment
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 712e2a2c7c..85e6d8ad5f 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,542 +1,556 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById)
}
const conversation = {
data () {
return {
highlight: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null
}
},
props: [
'statusId',
'collapsable',
'isPage',
'pinnedStatusIdsObject',
'inProfile',
'profileUserId',
'virtualHidden'
],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault () {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
},
isTreeView () {
return !this.isLinearView
},
treeViewIsSimple () {
return !this.$store.getters.mergedConfig.conversationTreeAdvanced
},
isLinearView () {
return this.displayStyle === 'linear'
},
shouldFadeAncestors () {
return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition () {
return this.$store.getters.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus () {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus () {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable () {
if (this.isTreeView) {
return Object.entries(this.statusContentProperties)
.every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
}
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.$refs.statusComponent.every(s => s.suspendable)
} else {
return true
}
},
hideStatus () {
return this.virtualHidden && this.suspendable
},
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId () {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId () {
return this.getConversationId(this.statusId)
},
conversation () {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap () {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree () {
const reverseLookupTable = this.conversation.reduce((table, status, index) => {
table[status.id] = index
return table
}, {})
const threads = this.conversation.reduce((a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id)
.map(s => s.id)
return a
}, {
forest: {}
})
const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
if (processed[id]) {
return []
}
processed[id] = true
return [{
status: this.conversation[reverseLookupTable[id]],
id,
depth
}, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
}).reduce((a, b) => a.concat(b), [])
const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
return linearized
},
replyIds () {
return this.conversation.map(k => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map(k => k.id)
return res
}, {})
},
totalReplyCount () {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map(k => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth () {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
return depths[id]
}
this.conversation.map(k => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths () {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel () {
const topLevel = this.conversation.reduce((tl, cur) =>
tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
return topLevel
},
otherTopLevelCount () {
return this.topLevel.length - 1
},
showingTopLevel () {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot () {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth () {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode () {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton () {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
},
shouldShowAncestors () {
return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
},
replies () {
let i = 1
// eslint-disable-next-line camelcase
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id
})
}
i++
return result
}, {})
},
isExpanded () {
return !!(this.expanded || this.isPage)
},
hiddenStyle () {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus () {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
statusContentProperties () {
return this.conversation.reduce((a, k) => {
const id = k.id
const props = (() => {
const def = {
showingTall: false,
expandingSubject: false,
showingLongSubject: false,
isReplying: false,
mediaPlaying: []
}
if (this.statusContentPropertiesObject[id]) {
return {
...def,
...this.statusContentPropertiesObject[id]
}
}
return def
})()
a[id] = props
return a
}, {})
},
canDive () {
return this.isTreeView && this.isExpanded
},
focused () {
return (id) => {
return (this.isExpanded) && id === this.highlight
}
},
maybeHighlight () {
return this.isExpanded ? this.highlight : null
- }
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+ })
},
components: {
Status,
ThreadTree,
QuickFilterSettings,
QuickViewSettings
},
watch: {
statusId (newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded (value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden (value) {
this.$store.dispatch(
'setVirtualHeight',
{ statusId: this.statusId, height: `${this.$el.clientHeight}px` }
)
}
},
methods: {
fetchConversation () {
if (this.status) {
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
.then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setHighlight(this.originalStatusId)
})
} else {
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
}
},
getReplies (id) {
return this.replies[id] || []
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
setHighlight (id) {
if (!id) return
this.highlight = id
+
+ if (!this.streamingEnabled) {
+ this.$store.dispatch('fetchStatus', id)
+ }
+
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded () {
this.expanded = !this.expanded
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
},
setThreadDisplay (id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus
}
},
toggleThreadDisplay (id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively (id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively (id) {
this.setThreadDisplayRecursively(id, 'showing')
},
setStatusContentProperty (id, name, value) {
this.statusContentPropertiesObject = {
...this.statusContentPropertiesObject,
[id]: {
...this.statusContentPropertiesObject[id],
[name]: value
}
}
},
toggleStatusContentProperty (id, name) {
this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
},
leastVisibleAncestor (id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus (id, preventScroll) {
this.tryScrollTo(id)
},
diveToTopLevel () {
this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
},
// only used when we are not on a page
undive () {
this.inlineDivePosition = null
this.setHighlight(this.statusId)
},
tryScrollTo (id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `highlight` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `highlight` watcher.
this.$nextTick(() => {
this.setHighlight(id)
})
},
goToCurrent () {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById (id) {
return this.statusMap[id]
},
parentOf (id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf (id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf (id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId (id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState () {
this.undive()
this.threadDisplayStatusObject = {}
}
}
}
export default conversation
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
new file mode 100644
index 0000000000..75adfea755
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+ components: {
+ PostStatusForm,
+ Modal
+ },
+ data () {
+ return {
+ resettingForm: false
+ }
+ },
+ computed: {
+ isLoggedIn () {
+ return !!this.$store.state.users.currentUser
+ },
+ modalActivated () {
+ return this.$store.state.editStatus.modalActivated
+ },
+ isFormVisible () {
+ return this.isLoggedIn && !this.resettingForm && this.modalActivated
+ },
+ params () {
+ return this.$store.state.editStatus.params || {}
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+ this.resettingForm = true
+ this.$nextTick(() => {
+ this.resettingForm = false
+ })
+ }
+ },
+ isFormVisible (val) {
+ if (val) {
+ this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+ }
+ }
+ },
+ methods: {
+ doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+ const params = {
+ store: this.$store,
+ statusId: this.$store.state.editStatus.params.statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media,
+ contentType
+ }
+
+ return statusPosterService.editStatus(params)
+ .then((data) => {
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeEditStatusModal')
+ }
+ }
+}
+
+export default EditStatusModal
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
new file mode 100644
index 0000000000..1dbacaab46
--- /dev/null
+++ b/src/components/edit_status_modal/edit_status_modal.vue
@@ -0,0 +1,48 @@
+<template>
+ <Modal
+ v-if="isFormVisible"
+ class="edit-form-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="edit-form-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('post_status.edit_status') }}
+ </div>
+ <PostStatusForm
+ class="panel-body"
+ v-bind="params"
+ :post-handler="doEditStatus"
+ :disable-polls="true"
+ :disable-visibility-selector="true"
+ @posted="closeModal"
+ />
+ </div>
+ </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+ align-items: flex-start;
+}
+.edit-form-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+
+ .form-bottom-left {
+ max-width: 6.5em;
+
+ .emoji-icon {
+ justify-content: right;
+ }
+ }
+}
+</style>
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 68fa66adc4..2e495423eb 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -1,116 +1,141 @@
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH,
faBookmark,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
+ faHistory,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
faFlag
} from '@fortawesome/free-regular-svg-icons'
library.add(
faEllipsisH,
faBookmark,
faBookmarkReg,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faFlag,
+ faHistory,
faPlus,
faTimes
)
const ExtraButtons = {
props: ['status'],
components: { Popover },
data () {
return {
expanded: false
}
},
methods: {
onShow () {
this.expanded = true
},
onClose () {
this.expanded = false
},
deleteStatus () {
const confirmed = window.confirm(this.$t('status.delete_confirm'))
if (confirmed) {
this.$store.dispatch('deleteStatus', { id: this.status.id })
}
},
pinStatus () {
this.$store.dispatch('pinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unpinStatus () {
this.$store.dispatch('unpinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
muteConversation () {
this.$store.dispatch('muteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unmuteConversation () {
this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+ },
+ editStatus () {
+ this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+ .then(data => this.$store.dispatch('openEditStatusModal', {
+ statusId: this.status.id,
+ subject: data.spoiler_text,
+ statusText: data.text,
+ statusIsSensitive: this.status.nsfw,
+ statusPoll: this.status.poll,
+ statusFiles: [...this.status.attachments],
+ visibility: this.status.visibility,
+ statusContentType: data.content_type
+ }))
+ },
+ showStatusHistory () {
+ const originalStatus = { ...this.status }
+ const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+ stripFieldsList.forEach(p => delete originalStatus[p])
+ this.$store.dispatch('openStatusHistoryModal', originalStatus)
}
},
computed: {
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
},
ownStatus () {
return this.status.user.id === this.currentUser.id
},
canPin () {
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
},
canMute () {
return !!this.currentUser
},
canBookmark () {
return !!this.currentUser
},
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
- }
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () { return this.$store.state.instance.editingAvailable }
}
}
export default ExtraButtons
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index 011dff9b7a..b2fad1c952 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -1,188 +1,210 @@
<template>
<Popover
class="ExtraButtons"
trigger="click"
placement="top"
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
@show="onShow"
@close="onClose"
>
<template #content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="muteConversation"
>
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t("status.mute_conversation") }}</span>
</button>
<button
v-if="canMute && status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation"
>
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t("status.unmute_conversation") }}</span>
</button>
<button
v-if="!status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="pinStatus"
@click="close"
>
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t("status.pin") }}</span>
</button>
<button
v-if="status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus"
@click="close"
>
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span>
</button>
<template v-if="canBookmark">
<button
v-if="!status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
<FAIcon
fixed-width
:icon="['far', 'bookmark']"
/><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
<FAIcon
fixed-width
icon="bookmark"
/><span>{{ $t("status.unbookmark") }}</span>
</button>
</template>
+ <button
+ v-if="ownStatus && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="editStatus"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="pen"
+ /><span>{{ $t("status.edit") }}</span>
+ </button>
+ <button
+ v-if="isEdited && editingAvailable"
+ class="button-default dropdown-item dropdown-item-icon"
+ @click.prevent="showStatusHistory"
+ @click="close"
+ >
+ <FAIcon
+ fixed-width
+ icon="history"
+ /><span>{{ $t("status.status_history") }}</span>
+ </button>
<button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
@click="close"
>
<FAIcon
fixed-width
icon="times"
/><span>{{ $t("status.delete") }}</span>
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="copyLink"
@click="close"
>
<FAIcon
fixed-width
icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span>
</button>
<a
v-if="!status.is_local"
class="button-default dropdown-item dropdown-item-icon"
title="Source"
:href="status.external_url"
target="_blank"
>
<FAIcon
fixed-width
icon="external-link-alt"
/><span>{{ $t("status.external_source") }}</span>
</a>
<button
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="reportStatus"
@click="close"
>
<FAIcon
fixed-width
:icon="['far', 'flag']"
/><span>{{ $t("user_card.report") }}</span>
</button>
</div>
</template>
<template #trigger>
<span class="button-unstyled popover-trigger">
<FALayers class="fa-old-padding-layer">
<FAIcon
class="fa-scale-110 "
icon="ellipsis-h"
/>
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="times"
/>
</FALayers>
</span>
</template>
</Popover>
</template>
<script src="./extra_buttons.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import '../../_mixins.scss';
.ExtraButtons {
/* override of popover internal stuff */
.popover-trigger-button {
width: auto;
}
.popover-trigger {
position: static;
padding: 10px;
margin: -10px;
&:hover .svg-inline--fa {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.popover-trigger-button {
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
}
}
</style>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c0d80b20e7..77f73d0467 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,611 +1,639 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
import Select from '../select/select.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam,
faPollH,
faUpload,
faBan,
faTimes,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faBan,
faTimes,
faCircleNotch
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const PostStatusForm = {
props: [
+ 'statusId',
+ 'statusText',
+ 'statusIsSensitive',
+ 'statusPoll',
+ 'statusFiles',
+ 'statusMediaDescriptions',
+ 'statusScope',
+ 'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
+ 'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting'
],
emits: [
'posted',
'resize',
'mediaplay',
'mediapause'
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery
},
mounted () {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
data () {
const preset = this.$route.query.message
let statusText = preset || ''
const { scopeCopy } = this.$store.getters.mergedConfig
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
+ let statusParams = {
+ spoilerText: this.subject || '',
+ status: statusText,
+ nsfw: !!sensitiveByDefault,
+ files: [],
+ poll: {},
+ mediaDescriptions: {},
+ visibility: scope,
+ contentType
+ }
+
+ if (this.statusId) {
+ const statusContentType = this.statusContentType || contentType
+ statusParams = {
+ spoilerText: this.subject || '',
+ status: this.statusText || '',
+ nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+ files: this.statusFiles || [],
+ poll: this.statusPoll || {},
+ mediaDescriptions: this.statusMediaDescriptions || {},
+ visibility: this.statusScope || scope,
+ contentType: statusContentType
+ }
+ }
+
return {
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
- newStatus: {
- spoilerText: this.subject || '',
- status: statusText,
- nsfw: !!sensitiveByDefault,
- files: [],
- poll: {},
- mediaDescriptions: {},
- visibility: scope,
- contentType
- },
+ newStatus: statusParams,
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: ''
}
},
computed: {
users () {
return this.$store.state.users.users
},
userDefaultScope () {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes () {
return !this.mergedConfig.minimalScopesMode
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
]
})
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
},
statusLength () {
return this.newStatus.status.length
},
spoilerTextLength () {
return this.newStatus.spoilerText.length
},
statusLengthLimit () {
return this.$store.state.instance.textlimit
},
hasStatusLengthLimit () {
return this.statusLengthLimit > 0
},
charactersLeft () {
return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
},
isOverLengthLimit () {
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
},
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
alwaysShowSubject () {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
safeDMEnabled () {
return this.$store.state.instance.safeDM
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2 &&
this.disablePolls !== true
},
hideScopeNotice () {
return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
},
showPreview () {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
+ isEdit () {
+ return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+ },
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
})
},
watch: {
newStatus: {
deep: true,
handler () {
this.statusChanged()
}
}
},
methods: {
statusChanged () {
this.autoPreview()
this.updateIdempotencyKey()
},
clearStatus () {
const newStatus = this.newStatus
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
mediaDescriptions: {}
}
this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
},
async postStatus (event, newStatus, opts = {}) {
if (this.posting && !this.optimisticPosting) { return }
if (this.disableSubmit) { return }
if (this.emojiInputShown) { return }
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = this.pollFormVisible ? this.newStatus.poll : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch (e) {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey
}
const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
this.clearStatus()
this.$emit('posted', data)
} else {
this.error = data.error
}
this.posting = false
})
},
previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll: {},
preview: true
}).then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
if (!data.error) {
this.preview = data
} else {
this.preview = { error: data.error }
}
}).catch((error) => {
this.preview = { error }
}).finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
autoPreview () {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview () {
this.preview = null
this.previewLoading = false
},
togglePreview () {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment (fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile (fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile (fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles () {
this.uploadingFiles = true
},
finishedUploadingFiles () {
this.$emit('resize')
this.uploadingFiles = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop (e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop (e) {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag (e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput (e) {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
this.$refs['emoji-input'].resize()
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef = this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll = scrollerRef === window
? scrollerRef.scrollY
: scrollerRef.scrollTop
const scrollerHeight = scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder = bottomRef.offsetHeight + findOffset(bottomRef, scrollerRef).top + bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom = isBottomObstructed &&
!(isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !== this.$refs.textarea.value.length)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$refs['emoji-input'].resize()
},
showEmojiPicker () {
this.$refs.textarea.focus()
this.$refs['emoji-input'].triggerShowPicker()
},
clearError () {
this.error = null
},
changeVis (visibility) {
this.newStatus.visibility = visibility
},
togglePollForm () {
this.pollFormVisible = !this.pollFormVisible
},
setPoll (poll) {
this.newStatus.poll = poll
},
clearPollForm () {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
},
setMediaDescription (id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({ store: this.$store, id, description })
},
setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id)))
},
handleEmojiInputShow (value) {
this.emojiInputShown = value
},
updateIdempotencyKey () {
this.idempotencyKey = Date.now().toString()
},
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
}
}
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 62613bd1cc..f65058f4bc 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,575 +1,593 @@
<template>
<div
ref="form"
class="post-status-form"
>
<form
autocomplete="off"
@submit.prevent
@dragover.prevent="fileDrag"
>
<div class="form-group">
<i18n-t
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
keypath="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice"
scope="global"
>
<button
class="button-unstyled -link"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
</button>
</i18n-t>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="newStatus.visibility === 'direct'"
class="visibility-notice"
>
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
+ <div
+ v-if="isEdit"
+ class="visibility-notice edit-warning"
+ >
+ <p>{{ $t('post_status.edit_remote_warning') }}</p>
+ <p>{{ $t('post_status.edit_unsupported_warning') }}</p>
+ </div>
<div
v-if="!disablePreview"
class="preview-heading faint"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<div
v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="form-control"
>
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
size="1"
class="form-post-subject"
>
</EmojiInput>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="form-control main-input"
enable-emoji-picker
hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</EmojiInput>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector
+ v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:initial-scope="newStatus.visibility"
:on-scope-change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<Select
id="post-content-type"
v-model="newStatus.contentType"
class="form-control"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
</option>
</Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<poll-form
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
@update-poll="setPoll"
/>
<div
ref="bottom"
class="form-bottom"
>
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles"
:disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
</button>
<button
v-if="pollsAvailable"
class="poll-icon button-unstyled"
:class="{ selected: pollFormVisible }"
:title="$t('polls.add_poll')"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
</button>
</div>
<button
v-if="posting"
disabled
class="btn button-default"
>
{{ $t('post_status.posting') }}
</button>
<button
v-else-if="isOverLengthLimit"
disabled
class="btn button-default"
>
{{ $t('post_status.post') }}
</button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<button
v-else
:disabled="uploadingFiles || disableSubmit"
class="btn button-default"
@touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('post_status.post') }}
</button>
</div>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
>
<FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
</div>
<div
v-if="error"
class="alert error"
>
Error: {{ error }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<gallery
v-if="newStatus.files && newStatus.files.length > 0"
class="attachments"
:grid="true"
:nsfw="false"
:attachments="newStatus.files"
:descriptions="newStatus.mediaDescriptions"
:set-media="() => $store.dispatch('setMedia', newStatus.files)"
:editable="true"
:edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
{{ $t('post_status.attachments_sensitive') }}
</Checkbox>
</div>
</form>
</div>
</template>
<script src="./post_status_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.post-status-form {
position: relative;
.attachments {
margin-bottom: 0.5em;
}
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
height: 2.5em;
button {
width: 10em;
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.form-bottom-left {
display: flex;
flex: 1;
padding-right: 7px;
margin-right: 7px;
max-width: 10em;
}
.preview-heading {
display: flex;
padding-left: 0.5em;
}
.preview-toggle {
flex: 1;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
svg, i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
.preview-status {
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
padding: 0.5em;
margin: 0;
}
.text-format {
.only-format {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
align-items: baseline;
}
+ .visibility-notice.edit-warning {
+ > :first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
display: flex;
align-items: center;
&.selected, &:hover {
// needs to be specific to override icon default color
svg, i, label {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&.disabled {
svg, i {
cursor: not-allowed;
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
&:hover {
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
}
}
}
}
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
justify-content: left;
}
.emoji-icon {
order: 2;
justify-content: center;
}
.poll-icon {
order: 3;
justify-content: right;
}
.error {
text-align: center;
}
.media-upload-wrapper {
margin-right: .2em;
margin-bottom: .5em;
width: 18em;
img, video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
}
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
margin: 0.6em;
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
line-height: 1.85;
}
.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
&.scrollable-form {
overflow-y: auto;
}
}
.main-input {
position: relative;
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
.btn[disabled] {
cursor: not-allowed;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
border: 2px dashed $fallback--text;
border: 2px dashed var(--text, $fallback--text);
}
}
</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 19356664c1..9a9bca7aa0 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,490 +1,496 @@
import ReplyButton from '../reply_button/reply_button.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
import UserPopover from '../user_popover/user_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import UserLink from '../user_link/user_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { unescape, uniqBy } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEnvelope,
faLock,
faLockOpen,
faGlobe,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faSmileBeam,
faEllipsisH,
faStar,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight
)
const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const controlledName = `controlled${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
res[name] = function () {
return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName]
}
return res
}, {})
const controlledOrUncontrolledToggle = (obj, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[toggle]) {
obj[toggle]()
} else {
obj[uncontrolledName] = !obj[uncontrolledName]
}
}
const controlledOrUncontrolledSet = (obj, name, val) => {
const camelized = camelCase(name)
const set = `controlledSet${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[set]) {
obj[set](val)
} else {
obj[uncontrolledName] = val
}
}
const Status = {
name: 'Status',
components: {
ReplyButton,
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
RichContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink
},
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
'profileUserId',
'simpleTree',
'controlledThreadDisplayStatus',
'controlledToggleThreadDisplay',
'showOtherRepliesAsButton',
'controlledShowingTall',
'controlledToggleShowingTall',
'controlledExpandingSubject',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
'controlledReplying',
'controlledToggleReplying',
'controlledMediaPlaying',
'controlledSetMediaPlaying',
'dive'
],
data () {
return {
uncontrolledReplying: false,
unmuted: false,
userExpanded: false,
uncontrolledMediaPlaying: [],
suspendable: true,
error: null,
headTailLinks: null
}
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
},
showReasonMutedThread () {
return (
this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)
) && !this.inConversation
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
replyProfileLink () {
if (this.isReply) {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeterUser () { return this.statusoid.user },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository () {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
rtBotStatus () {
return this.statusoid.user.bot
},
botStatus () {
return this.status.user.bot
},
botIndicator () {
return this.botStatus && !this.hideBotIndication
},
rtBotIndicator () {
return this.rtBotStatus && !this.hideBotIndication
},
mentionsLine () {
if (!this.headTailLinks) return []
const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
return this.status.attentions.filter(attn => {
// no reply user
return attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
}).map(attn => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id
}))
},
hasMentionsLine () {
return this.mentionsLine.length > 0
},
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const reasonsToMute = this.userIsMuted ||
// Thread is muted
status.thread_muted ||
// Wordfiltered
this.muteWordHits.length > 0 ||
// bot status
(this.muteBotStatuses && this.botStatus && !this.compact)
return !this.unmuted && !this.shouldNotMute && reasonsToMute
},
userIsMuted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
return status.muted ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting)
},
shouldNotMute () {
const { status } = this
const { reblog } = status
return (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
(!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId)
)
) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
},
hideMutedUsers () {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads () {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts () {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus () {
return (!this.shouldNotMute) && (
(this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
)
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name_ui
}
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy
)
return uniqBy(combinedUsers, 'id')
},
tags () {
// eslint-disable-next-line no-prototype-builtins
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return this.mergedConfig.hidePostStats
},
muteBotStatuses () {
return this.mergedConfig.muteBotStatuses
},
hideBotIndication () {
return this.mergedConfig.hideBotIndication
},
currentUser () {
return this.$store.state.users.currentUser
},
betterShadow () {
return this.$store.state.interface.browserSupport.cssFilter
},
mergedConfig () {
return this.$store.getters.mergedConfig
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest () {
return !!this.controlledThreadDisplayStatus
},
threadShowing () {
return this.controlledThreadDisplayStatus === 'showing'
},
visibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
+ },
+ isEdited () {
+ return this.status.edited_at !== null
+ },
+ editingAvailable () {
+ return this.$store.state.instance.editingAvailable
}
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
default:
return 'globe'
}
},
showError (error) {
this.error = error
},
clearError () {
this.error = undefined
},
toggleReplying () {
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
addMediaPlaying (id) {
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
},
removeMediaPlaying (id) {
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay () {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted (highlightId) {
const id = highlightId
if (this.status.id === id) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
},
watch: {
highlight: function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (val) {
this.suspendable = val
}
}
}
export default Status
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index b3ad3818c6..ada9841e41 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,424 +1,425 @@
@import '../../_variables.scss';
.Status {
min-width: 0;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
&.-focused {
background-color: $fallback--lightBg;
background-color: var(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
}
.gravestone {
padding: var(--status-margin, $status-margin);
color: $fallback--faint;
color: var(--faint, $fallback--faint);
display: flex;
.deleted-text {
margin: 0.5em 0;
align-items: center;
}
}
.status-container {
display: flex;
padding: var(--status-margin, $status-margin);
> * {
min-width: 0;
}
&.-repeat {
padding-top: 0;
}
}
.pin {
padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
._misclick-prevention & {
pointer-events: none;
.attachments {
pointer-events: initial;
cursor: initial;
}
}
.left-side {
margin-right: var(--status-margin, $status-margin);
}
.right-side {
flex: 1;
min-width: 0;
}
.usercard {
margin-bottom: var(--status-margin, $status-margin);
}
.status-username {
white-space: nowrap;
overflow: hidden;
max-width: 85%;
font-weight: bold;
flex-shrink: 1;
margin-right: 0.4em;
text-overflow: ellipsis;
--_still_image-label-scale: 0.25;
--emoji-size: 14px;
}
.status-favicon {
height: 18px;
width: 18px;
margin-right: 0.4em;
}
.status-heading {
margin-bottom: 0.5em;
}
.heading-name-row {
display: flex;
justify-content: space-between;
line-height: 1.3;
a {
display: inline-block;
word-break: break-all;
}
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
.heading-left {
display: flex;
min-width: 0;
}
.heading-right {
display: flex;
flex-shrink: 0;
.button-unstyled {
padding: 5px;
margin: -5px;
&:hover svg {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
.svg-inline--fa {
margin-left: 0.25em;
}
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
.timeago {
margin-right: 0.2em;
}
- & .heading-reply-row {
+ & .heading-reply-row,
+ & .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
margin-top: 0.2em;
line-height: 130%;
max-width: 100%;
align-items: stretch;
}
& .reply-to-popover,
& .reply-to-no-popover,
& .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
.reply-glued-label {
margin-right: 0.5em;
}
.reply-to-popover {
.reply-to:hover::before {
content: '';
display: block;
position: absolute;
bottom: 0;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
.faint-link:hover {
// override default
text-decoration: none;
}
&.-strikethrough {
.reply-to::after {
content: '';
display: block;
position: absolute;
top: 50%;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
}
}
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
}
& .mentions-text,
& .reply-to-text {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 1.3;
font-size: 0.85em;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
.repeat-info {
padding: 0.4em var(--status-margin, $status-margin);
.repeat-icon {
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
}
.repeater-avatar {
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.repeater-name {
text-overflow: ellipsis;
margin-right: 0;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain;
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: var(--status-margin, $status-margin);
> * {
max-width: 4em;
flex: 1;
}
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
& .status-username,
& .mute-thread,
& .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
& .status-username,
& .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: 0.2em;
&::before {
content: ' ';
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.reply-form {
padding-top: 0;
padding-bottom: 0;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: var(--status-margin, $status-margin);
}
.stats {
width: 100%;
display: flex;
line-height: 1em;
}
.avatar-row {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--faint, $fallback--faint);
}
}
.stat-count {
margin-right: var(--status-margin, $status-margin);
user-select: none;
.stat-title {
color: var(--faint, $fallback--faint);
font-size: 0.85em;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 1.1em;
line-height: 1em;
}
&:hover .stat-title {
text-decoration: underline;
}
}
@media all and (max-width: 800px) {
.repeater-avatar {
margin-left: 20px;
}
.post-avatar {
width: 40px;
height: 40px;
// TODO define those other way somehow?
// stylelint-disable rscss/class-format
&.-compact {
width: 32px;
height: 32px;
}
}
}
}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 91f5ffface..82eb7ac616 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,496 +1,514 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<span
class="fa-scale-110 fa-old-padding"
@click="clearError"
>
<FAIcon icon="times" />
</span>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && retweet"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<user-link
:user="status.user"
:at="false"
/>
</small>
<small
v-if="showReasonMutedThread"
class="mute-thread"
>
{{ $t('status.thread_muted') }}
</small>
<small
v-if="showReasonMutedThread && muteWordHits.length > 0"
class="mute-thread"
>
{{ $t('status.thread_muted_and_words') }}
</small>
<small
class="mute-words"
:title="muteWordHits.join(', ')"
>
{{ muteWordHits.join(', ') }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:bot="rtBotIndicator"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="right-side faint">
<span
class="status-username repeater-name"
:title="retweeter"
>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
{{ ' ' }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
v-if="!deleted"
:class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="status.user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:bot="botIndicator"
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</UserPopover>
</a>
</div>
<div class="right-side">
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div class="heading-left">
<h4
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
/>
</h4>
<h4
v-else
class="status-username"
:title="status.user.name"
>
{{ status.user.name }}
</h4>
<user-link
class="account-name"
:title="status.user.screen_name_ui"
:user="status.user"
:at="false"
/>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div>
<span class="heading-right">
<router-link
class="timeago faint-link"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="status.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
v-if="expandable && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies && replies.length && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="dive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="dive"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<StatusPopover
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
{{ ' ' }}
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
+ <div
+ v-if="isEdited && editingAvailable && !isPreview"
+ class="heading-edited-row"
+ >
+ <i18n-t
+ keypath="status.edited_at"
+ tag="span"
+ >
+ <template #time>
+ <Timeago
+ template-key="time.in_past"
+ :time="status.edited_at"
+ :auto-update="60"
+ :long-format="true"
+ />
+ </template>
+ </i18n-t>
+ </div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:controlled-showing-tall="controlledShowingTall"
:controlled-expanding-subject="controlledExpandingSubject"
:controlled-showing-long-subject="controlledShowingLongSubject"
:controlled-toggle-showing-tall="controlledToggleShowingTall"
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
/>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<button
v-if="showOtherRepliesAsButton && replies.length > 1"
class="button-unstyled -link faint"
:title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
@click.prevent="dive"
>
{{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
:users="statusFromGlobalRepository.rebloggedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
:users="statusFromGlobalRepository.favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
</UserListPopover>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div
v-if="!noHeading && !isPreview"
class="status-actions"
>
<reply-button
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
/>
<ReactButton
v-if="loggedIn"
:status="status"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
:bot="botIndicator"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
<reply-button
v-if="replying"
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js
new file mode 100644
index 0000000000..3941a56f89
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.js
@@ -0,0 +1,60 @@
+import { get } from 'lodash'
+import Modal from '../modal/modal.vue'
+import Status from '../status/status.vue'
+
+const StatusHistoryModal = {
+ components: {
+ Modal,
+ Status
+ },
+ data () {
+ return {
+ statuses: []
+ }
+ },
+ computed: {
+ modalActivated () {
+ return this.$store.state.statusHistory.modalActivated
+ },
+ params () {
+ return this.$store.state.statusHistory.params
+ },
+ statusId () {
+ return this.params.id
+ },
+ historyCount () {
+ return this.statuses.length
+ },
+ history () {
+ return this.statuses
+ }
+ },
+ watch: {
+ params (newVal, oldVal) {
+ const newStatusId = get(newVal, 'id') !== get(oldVal, 'id')
+ if (newStatusId) {
+ this.resetHistory()
+ }
+
+ if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) {
+ this.fetchStatusHistory()
+ }
+ }
+ },
+ methods: {
+ resetHistory () {
+ this.statuses = []
+ },
+ fetchStatusHistory () {
+ this.$store.dispatch('fetchStatusHistory', this.params)
+ .then(data => {
+ this.statuses = data
+ })
+ },
+ closeModal () {
+ this.$store.dispatch('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default StatusHistoryModal
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
new file mode 100644
index 0000000000..990be35bc9
--- /dev/null
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -0,0 +1,46 @@
+<template>
+ <Modal
+ v-if="modalActivated"
+ class="status-history-modal-view"
+ @backdropClicked="closeModal"
+ >
+ <div class="status-history-modal-panel panel">
+ <div class="panel-heading">
+ {{ $t('status.status_history') }} ({{ historyCount }})
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="historyCount > 0"
+ class="history-body"
+ >
+ <status
+ v-for="status in history"
+ :key="status.id"
+ :statusoid="status"
+ :is-preview="true"
+ class="conversation-status status-fadein panel-body"
+ />
+ </div>
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<script src="./status_history_modal.js"></script>
+
+<style lang="scss">
+.modal-view.status-history-modal-view {
+ align-items: flex-start;
+}
+.status-history-modal-panel {
+ flex-shrink: 0;
+ margin-top: 25%;
+ margin-bottom: 2em;
+ width: 100%;
+ max-width: 700px;
+
+ @media (orientation: landscape) {
+ margin-top: 8%;
+ }
+}
+</style>
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 2b487dfdda..b5f495154d 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -1,53 +1,70 @@
<template>
<time
:datetime="time"
:title="localeDateString"
>
- {{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }}
+ {{ relativeTimeString }}
</time>
</template>
<script>
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
- props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'],
+ props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data () {
return {
relativeTime: { key: 'time.now', num: 0 },
interval: null
}
},
computed: {
localeDateString () {
const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale)
return typeof this.time === 'string'
? new Date(Date.parse(this.time)).toLocaleString(browserLocale)
: this.time.toLocaleString(browserLocale)
+ },
+ relativeTimeString () {
+ const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num])
+
+ if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') {
+ return this.$i18n.t(this.templateKey, [timeString])
+ }
+
+ return timeString
+ }
+ },
+ watch: {
+ time (newVal, oldVal) {
+ if (oldVal !== newVal) {
+ clearTimeout(this.interval)
+ this.refreshRelativeTimeObject()
+ }
}
},
created () {
this.refreshRelativeTimeObject()
},
unmounted () {
clearTimeout(this.interval)
},
methods: {
refreshRelativeTimeObject () {
const nowThreshold = typeof this.nowThreshold === 'number' ? this.nowThreshold : 1
this.relativeTime = this.longFormat
? DateUtils.relativeTime(this.time, nowThreshold)
: DateUtils.relativeTimeShort(this.time, nowThreshold)
if (this.autoUpdate) {
this.interval = setTimeout(
this.refreshRelativeTimeObject,
1000 * this.autoUpdate
)
}
}
}
}
</script>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index ffa4c6b0f1..1d494d4a2d 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,1032 +1,1038 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search",
"search": "Search",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"not_enough_options": "Too few unique options in poll"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"unicode": "Unicode emoji",
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions"
},
"post_status": {
+ "edit_status": "Edit status",
"new_status": "Post new status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode"
},
"content_warning": "Subject (optional)",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
+ "edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
+ "edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
}
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password"
},
"email_language": "In which language do you want to receive emails from the server?"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"security": "Security",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"mention_links": "Mention links",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_bot_indication": "Hide bot indication in posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"file_export_import": {
"backup_restore": "Settings backup",
"backup_settings": "Backup settings to file",
"backup_settings_theme": "Backup settings and theme to file",
"restore_settings": "Restore settings from file",
"errors": {
"invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
"file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
"file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
"file_slightly_new": "File minor version is different, some settings might not load"
}
},
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
"name": "Label",
"value": "Content"
},
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"posts": "Posts",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_follows": "Follows",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"fun": "Fun",
"greentext": "Meme arrows",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_filters": "Filters",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"more_settings": "More settings",
"style": {
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"postCode": "Monospaced text in a post (rich text)"
},
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"years": "{0} year | {0} years",
"years_short": "{0}y"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_broke": "Realtime connection lost: CloseEvent code {0}"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"delete": "Delete status",
+ "edit": "Edit status",
+ "edited_at": "(last edited {time})",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"external_source": "External source",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
- "show_only_conversation_under_this": "Only show replies to this status"
+ "show_only_conversation_under_this": "Only show replies to this status",
+ "status_history": "Status history"
},
"user_card": {
"approve": "Approve",
"block": "Block",
"blocked": "Blocked!",
"deactivated": "Deactivated",
"deny": "Deny",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
"remote_follow": "Remote follow",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate account",
"deactivate_account": "Deactivate account",
"delete_account": "Delete account",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
"delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
},
"highlight": {
"disabled": "No highlight",
"solid": "Solid bg",
"striped": "Striped bg",
"side": "Side stripe"
}
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"repeat": "Repeat",
"reply": "Reply",
"favorite": "Favorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark"
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
}
}
diff --git a/src/main.js b/src/main.js
index 0a050b04ad..6aa9cbb7b2 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,105 +1,110 @@
import { createStore } from 'vuex'
import 'custom-event-polyfill'
import './lib/event_target_polyfill.js'
import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js'
import listsModule from './modules/lists.js'
import usersModule from './modules/users.js'
import apiModule from './modules/api.js'
import configModule from './modules/config.js'
import serverSideConfigModule from './modules/serverSideConfig.js'
import serverSideStorageModule from './modules/serverSideStorage.js'
import shoutModule from './modules/shout.js'
import oauthModule from './modules/oauth.js'
import authFlowModule from './modules/auth_flow.js'
import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js'
+import editStatusModule from './modules/editStatus.js'
+import statusHistoryModule from './modules/statusHistory.js'
+
import chatsModule from './modules/chats.js'
import { createI18n } from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js'
import afterStoreSetup from './boot/after_store.js'
const currentLocale = (window.navigator.language || 'en').split('-')[0]
const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages: messages.default
})
messages.setLanguage(i18n, currentLocale)
const persistedStateOptions = {
paths: [
'serverSideStorage.cache',
'config',
'users.lastLoginName',
'oauth'
]
};
(async () => {
let storageError = false
const plugins = [pushNotifications]
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)
} catch (e) {
console.error(e)
storageError = true
}
const store = createStore({
modules: {
i18n: {
getters: {
i18n: () => i18n.global
}
},
interface: interfaceModule,
instance: instanceModule,
// TODO refactor users/statuses modules, they depend on each other
users: usersModule,
statuses: statusesModule,
lists: listsModule,
api: apiModule,
config: configModule,
serverSideConfig: serverSideConfigModule,
serverSideStorage: serverSideStorageModule,
shout: shoutModule,
oauth: oauthModule,
authFlow: authFlowModule,
mediaViewer: mediaViewerModule,
oauthTokens: oauthTokensModule,
reports: reportsModule,
polls: pollsModule,
postStatus: postStatusModule,
+ editStatus: editStatusModule,
+ statusHistory: statusHistoryModule,
chats: chatsModule
},
plugins,
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
if (storageError) {
store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
}
afterStoreSetup({ store, i18n })
})()
// These are inlined by webpack's DefinePlugin
/* eslint-disable */
window.___pleromafe_mode = process.env
window.___pleromafe_commit_hash = COMMIT_HASH
window.___pleromafe_dev_overrides = DEV_OVERRIDES
diff --git a/src/modules/api.js b/src/modules/api.js
index b714c9ee5f..0acc03f1ff 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,289 +1,296 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { WSConnectionStatus } from '../services/api/api.service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { Socket } from 'phoenix'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
backendInteractor: backendInteractorService(),
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: []
},
getters: {
followRequestCount: state => state.followRequests.length
},
mutations: {
setBackendInteractor (state, backendInteractor) {
state.backendInteractor = backendInteractor
},
addFetcher (state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher (state, { fetcherName, fetcher }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken (state, token) {
state.wsToken = token
},
setSocket (state, socket) {
state.socket = socket
},
setFollowRequests (state, value) {
state.followRequests = value
},
setMastoUserSocketStatus (state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier (state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier (state) {
state.retryMultiplier = 1
}
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets (store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![
WebSocket.CLOSED,
WebSocket.CLOSING
].includes(state.mastoUserSocket.getState())
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets (store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
// MastoAPI 'User' sockets
startMastoUserSocket (store) {
return new Promise((resolve, reject) => {
try {
const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
state.mastoUserSocket.addEventListener(
'message',
({ detail: message }) => {
if (!message) return // pings
if (message.event === 'notification') {
dispatch('addNewNotifications', {
notifications: [message.notification],
older: false
})
} else if (message.event === 'update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends'
})
+ } else if (message.event === 'status.update') {
+ dispatch('addNewStatuses', {
+ statuses: [message.status],
+ userId: false,
+ showImmediately: message.status.id in timelineData.visibleStatusesObject,
+ timeline: 'friends'
+ })
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage]
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
}
)
state.mastoUserSocket.addEventListener('open', () => {
// Do not show notification when we just opened up the page
if (state.mastoUserSocketStatus !== WSConnectionStatus.STARTING_INITIAL) {
dispatch('pushGlobalNotice', {
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000
})
}
// Stop polling if we were errored or disabled
if (new Set([
WSConnectionStatus.ERROR,
WSConnectionStatus.DISABLED
]).has(state.mastoUserSocketStatus)) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
// TODO is this needed?
dispatch('clearOpenedChats')
})
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001 // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(`Not restarting socket becasue of closure code ${code} is in ignore list`)
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
} else {
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
setTimeout(() => {
dispatch('startMastoUserSocket')
}, retryTimeout(state.retryMultiplier))
commit('incrementRetryMultiplier')
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
dispatch('pushGlobalNotice', {
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000
})
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
dispatch('clearOpenedChats')
})
resolve()
} catch (e) {
reject(e)
}
})
},
stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
// Timelines
startFetchingTimeline (store, {
timeline = 'friends',
tag = false,
userId = false,
listId = false
}) {
if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetchingTimeline({
timeline, store, userId, listId, tag
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
stopFetchingTimeline (store, timeline) {
const fetcher = store.state.fetchers[timeline]
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
fetchTimeline (store, timeline, { ...rest }) {
store.state.backendInteractor.fetchTimeline({
store,
timeline,
...rest
})
},
// Notifications
startFetchingNotifications (store) {
if (store.state.fetchers.notifications) return
const fetcher = store.state.backendInteractor.startFetchingNotifications({ store })
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications (store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
fetchNotifications (store, { ...rest }) {
store.state.backendInteractor.fetchNotifications({
store,
...rest
})
},
// Follow requests
startFetchingFollowRequests (store) {
if (store.state.fetchers.followRequests) return
const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store })
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests (store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
removeFollowRequest (store, request) {
const requests = store.state.followRequests.filter((it) => it !== request)
store.commit('setFollowRequests', requests)
},
// Lists
startFetchingLists (store) {
if (store.state.fetchers.lists) return
const fetcher = store.state.backendInteractor.startFetchingLists({ store })
store.commit('addFetcher', { fetcherName: 'lists', fetcher })
},
stopFetchingLists (store) {
const fetcher = store.state.fetchers.lists
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'lists', fetcher })
},
// Pleroma websocket
setWsToken (store, token) {
store.commit('setWsToken', token)
},
initializeSocket ({ dispatch, commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (rootState.instance.shoutAvailable && typeof token !== 'undefined' && state.socket === null) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
dispatch('initializeShout', socket)
}
},
disconnectFromSocket ({ commit, state }) {
state.socket && state.socket.disconnect()
commit('setSocket', null)
}
}
}
export default api
diff --git a/src/modules/editStatus.js b/src/modules/editStatus.js
new file mode 100644
index 0000000000..fd31651919
--- /dev/null
+++ b/src/modules/editStatus.js
@@ -0,0 +1,25 @@
+const editStatus = {
+ state: {
+ params: null,
+ modalActivated: false
+ },
+ mutations: {
+ openEditStatusModal (state, params) {
+ state.params = params
+ state.modalActivated = true
+ },
+ closeEditStatusModal (state) {
+ state.modalActivated = false
+ }
+ },
+ actions: {
+ openEditStatusModal ({ commit }, params) {
+ commit('openEditStatusModal', params)
+ },
+ closeEditStatusModal ({ commit }) {
+ commit('closeEditStatusModal')
+ }
+ }
+}
+
+export default editStatus
diff --git a/src/modules/statusHistory.js b/src/modules/statusHistory.js
new file mode 100644
index 0000000000..db3d6d4bef
--- /dev/null
+++ b/src/modules/statusHistory.js
@@ -0,0 +1,25 @@
+const statusHistory = {
+ state: {
+ params: {},
+ modalActivated: false
+ },
+ mutations: {
+ openStatusHistoryModal (state, params) {
+ state.params = params
+ state.modalActivated = true
+ },
+ closeStatusHistoryModal (state) {
+ state.modalActivated = false
+ }
+ },
+ actions: {
+ openStatusHistoryModal ({ commit }, params) {
+ commit('openStatusHistoryModal', params)
+ },
+ closeStatusHistoryModal ({ commit }) {
+ commit('closeStatusHistoryModal')
+ }
+ }
+}
+
+export default statusHistory
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 4aa0468309..803d7019c5 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,770 +1,779 @@
import {
remove,
slice,
each,
findIndex,
find,
maxBy,
minBy,
merge,
first,
last,
isArray,
omitBy
} from 'lodash'
import {
isStatusNotification,
isValidNotification,
maybeShowNotification
} from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0
})
const emptyNotifications = () => ({
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
data: [],
idStore: {},
loading: false
})
export const defaultState = () => ({
allStatuses: [],
allStatusesObject: {},
conversationsObject: {},
maxId: 0,
notifications: emptyNotifications(),
favorites: new Set(),
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl()
}
})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(oldItem, omitBy(item, (v, k) => v === null || k === 'user'))
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
return timeline
}
// Add status to the global storages (arrays and objects maintaining statuses) except timelines
const addStatusToGlobalStorage = (state, data) => {
const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data)
if (result.new) {
// Add to conversation
const status = result.item
const conversationsObject = state.conversationsObject
const conversationId = status.statusnet_conversation_id
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
conversationsObject[conversationId] = [status]
}
}
return result
}
// Remove status from the global storages (arrays and objects maintaining statuses) except timelines
const removeStatusFromGlobalStorage = (state, status) => {
remove(state.allStatuses, { id: status.id })
// TODO: Need to remove from allStatusesObject?
// Remove possible notification
remove(state.notifications.data, ({ action: { id } }) => id === status.id)
// Remove from conversation
const conversationId = status.statusnet_conversation_id
if (state.conversationsObject[conversationId]) {
remove(state.conversationsObject[conversationId], { id: status.id })
}
}
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
// Sanity check
if (!isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if ((timeline === 'user' || timeline === 'media') && timelineObject.userId !== userId) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = addStatusToGlobalStorage(state, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (status.type === 'status' && find(status.attentions, { id: user.id })) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(timelineObject.statuses, timelineObject.statusesObject, status)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(timelineObject.visibleStatuses, timelineObject.visibleStatusesObject, status)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
return status
}
const favoriteStatus = (favorite, counter) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
+ edit: (status) => {
+ addStatus(status, showImmediately)
+ },
retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (timeline && find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return s.id === retweetedStatus.id || s.retweeted_status.id === retweetedStatus.id
} else {
return s.id === retweetedStatus.id
}
})) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
deletion: (deletion) => {
const uri = deletion.uri
const status = find(allStatuses, { uri })
if (!status) {
return
}
removeStatusFromGlobalStorage(state, status)
if (timeline) {
remove(timelineObject.statuses, { uri })
remove(timelineObject.visibleStatuses, { uri })
}
},
follow: (follow) => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.log('unknown status type')
console.log(unknown)
}
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors.default
processor(status)
})
// Keep the visible statuses sorted
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
const updateNotificationsMinMaxId = (state, notification) => {
state.notifications.maxId = notification.id > state.notifications.maxId
? notification.id
: state.notifications.maxId
state.notifications.minId = notification.id < state.notifications.minId
? notification.id
: state.notifications.minId
}
const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters, newNotificationSideEffects }) => {
each(notifications, (notification) => {
// If invalid notification, update ids but don't add it to store
if (!isValidNotification(notification)) {
console.error('Invalid notification:', notification)
updateNotificationsMinMaxId(state, notification)
return
}
if (isStatusNotification(notification.type)) {
notification.action = addStatusToGlobalStorage(state, notification.action).item
notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
}
if (notification.type === 'pleroma:report') {
dispatch('addReport', notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
dispatch('fetchEmojiReactionsBy', notification.status.id)
}
// Only add a new notification if we don't have one for the same action
// eslint-disable-next-line no-prototype-builtins
if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
updateNotificationsMinMaxId(state, notification)
state.notifications.data.push(notification)
state.notifications.idStore[notification.id] = notification
newNotificationSideEffects(notification)
} else if (notification.seen) {
state.notifications.idStore[notification.id].seen = true
}
})
}
const removeStatus = (state, { timeline, userId }) => {
const timelineObject = state.timelines[timeline]
if (userId) {
remove(timelineObject.statuses, { user: { id: userId } })
remove(timelineObject.visibleStatuses, { user: { id: userId } })
timelineObject.minVisibleId = timelineObject.visibleStatuses.length > 0 ? last(timelineObject.visibleStatuses).id : 0
timelineObject.maxId = timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
}
}
export const mutations = {
addNewStatuses,
addNewNotifications,
removeStatus,
showNewStatuses (state, { timeline }) {
const oldTimeline = (state.timelines[timeline])
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
},
resetStatuses (state) {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
state[key] = value
})
},
clearTimeline (state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.timelines[timeline] = emptyTl(userId)
},
clearNotifications (state) {
state.notifications = emptyNotifications()
},
setFavorited (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
setFavoritedConfirm (state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
const index = findIndex(newStatus.favoritedBy, { id: user.id })
if (index !== -1 && !newStatus.favorited) {
newStatus.favoritedBy.splice(index, 1)
} else if (index === -1 && newStatus.favorited) {
newStatus.favoritedBy.push(user)
}
},
setMutedStatus (state, status) {
const newStatus = state.allStatusesObject[status.id]
newStatus.thread_muted = status.thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversationsObject[newStatus.statusnet_conversation_id].forEach(status => { status.thread_muted = newStatus.thread_muted })
}
},
setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
setRetweetedConfirm (state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = status.repeated
newStatus.repeat_num = status.repeat_num
const index = findIndex(newStatus.rebloggedBy, { id: user.id })
if (index !== -1 && !newStatus.repeated) {
newStatus.rebloggedBy.splice(index, 1)
} else if (index === -1 && newStatus.repeated) {
newStatus.rebloggedBy.push(user)
}
},
setBookmarked (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
},
setBookmarkedConfirm (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
},
setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus) newStatus.deleted = true
},
setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => {
if (condition(status)) {
status.deleted = true
}
})
},
setLoading (state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw (state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
setNotificationsLoading (state, { value }) {
state.notifications.loading = value
},
setNotificationsSilence (state, { value }) {
state.notifications.desktopNotificationSilence = value
},
markNotificationsAsSeen (state) {
each(state.notifications.data, (notification) => {
notification.seen = true
})
},
markSingleNotificationAsSeen (state, { id }) {
const notification = find(state.notifications.data, n => n.id === id)
if (notification) notification.seen = true
},
dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== id)
},
dismissNotifications (state, { finder }) {
state.notifications.data = state.notifications.data.filter(n => finder)
},
updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification)
},
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll (state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(({ id }) => currentUser.id === id)
},
addFavs (state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.favoritedBy = favoritedByUsers.filter(_ => _)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id)
},
addEmojiReactionsBy (state, { id, emojiReactions, currentUser }) {
const status = state.allStatusesObject[id]
status.emoji_reactions = emojiReactions
},
addOwnReaction (state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
const reaction = status.emoji_reactions[reactionIndex] || { name: emoji, count: 0, accounts: [] }
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [
...reaction.accounts,
currentUser
]
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction (state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter(acc => acc.id !== currentUser.id)
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(r => r.name !== emoji)
}
},
updateStatusWithPoll (state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
},
setVirtualHeight (state, { statusId, height }) {
state.allStatusesObject[statusId].virtualHeight = height
}
}
const statuses = {
state: defaultState(),
actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
},
addNewNotifications (store, { notifications, older }) {
const { commit, dispatch, rootGetters } = store
const newNotificationSideEffects = (notification) => {
maybeShowNotification(store, notification)
}
commit('addNewNotifications', { dispatch, notifications, older, rootGetters, newNotificationSideEffects })
},
setNotificationsLoading ({ rootState, commit }, { value }) {
commit('setNotificationsLoading', { value })
},
setNotificationsSilence ({ rootState, commit }, { value }) {
commit('setNotificationsSilence', { value })
},
fetchStatus ({ rootState, dispatch }, id) {
return rootState.api.backendInteractor.fetchStatus({ id })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
+ fetchStatusSource ({ rootState, dispatch }, status) {
+ return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials })
+ },
+ fetchStatusHistory ({ rootState, dispatch }, status) {
+ return apiService.fetchStatusHistory({ status })
+ },
deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
},
deleteStatusById ({ rootState, commit }, id) {
const status = rootState.statuses.allStatusesObject[id]
commit('setDeleted', { status })
},
markStatusesAsDeleted ({ commit }, condition) {
commit('setManyDeleted', condition)
},
favorite ({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
rootState.api.backendInteractor.favorite({ id: status.id })
.then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser }))
},
unfavorite ({ rootState, commit }, status) {
// Optimistic unfavoriting...
commit('setFavorited', { status, value: false })
rootState.api.backendInteractor.unfavorite({ id: status.id })
.then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser }))
},
fetchPinnedStatuses ({ rootState, dispatch }, userId) {
rootState.api.backendInteractor.fetchPinnedStatuses({ id: userId })
.then(statuses => dispatch('addNewStatuses', { statuses, timeline: 'user', userId, showImmediately: true, noIdUpdate: true }))
},
pinStatus ({ rootState, dispatch }, statusId) {
return rootState.api.backendInteractor.pinOwnStatus({ id: statusId })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
unpinStatus ({ rootState, dispatch }, statusId) {
rootState.api.backendInteractor.unpinOwnStatus({ id: statusId })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
muteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.muteConversation({ id: statusId })
.then((status) => commit('setMutedStatus', status))
},
unmuteConversation ({ rootState, commit }, statusId) {
return rootState.api.backendInteractor.unmuteConversation({ id: statusId })
.then((status) => commit('setMutedStatus', status))
},
retweet ({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
rootState.api.backendInteractor.retweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status: status.retweeted_status, user: rootState.users.currentUser }))
},
unretweet ({ rootState, commit }, status) {
// Optimistic unretweeting...
commit('setRetweeted', { status, value: false })
rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
},
bookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
queueFlushAll ({ rootState, commit }) {
commit('queueFlushAll')
},
markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({
id: rootState.statuses.notifications.maxId,
credentials: rootState.users.currentUser.credentials
})
},
markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
commit('markSingleNotificationAsSeen', { id })
apiService.markNotificationsAsSeen({
single: true,
id,
credentials: rootState.users.currentUser.credentials
})
},
dismissNotificationLocal ({ rootState, commit }, { id }) {
commit('dismissNotification', { id })
},
dismissNotification ({ rootState, commit }, { id }) {
commit('dismissNotification', { id })
rootState.api.backendInteractor.dismissNotification({ id })
},
updateNotification ({ rootState, commit }, { id, updater }) {
commit('updateNotification', { id, updater })
},
fetchFavsAndRepeats ({ rootState, commit }, id) {
Promise.all([
rootState.api.backendInteractor.fetchFavoritedByUsers({ id }),
rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser })
commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })
})
},
reactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('addOwnReaction', { id, emoji, currentUser })
rootState.api.backendInteractor.reactWithEmoji({ id, emoji }).then(
ok => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
rootState.api.backendInteractor.unreactWithEmoji({ id, emoji }).then(
ok => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
fetchEmojiReactionsBy ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchEmojiReactions({ id }).then(
emojiReactions => {
commit('addEmojiReactionsBy', { id, emojiReactions, currentUser: rootState.users.currentUser })
}
)
},
fetchFavs ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchFavoritedByUsers({ id })
.then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser }))
},
fetchRepeats ({ rootState, commit }, id) {
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 })
.then((data) => {
store.commit('addNewUsers', data.accounts)
store.commit('addNewStatuses', { statuses: data.statuses })
return data
})
},
setVirtualHeight ({ commit }, { statusId, height }) {
commit('setVirtualHeight', { statusId, height })
}
},
mutations
}
export default statuses
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index dd85b281d5..841376d187 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,1599 +1,1680 @@
import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}`
const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
const ADMIN_USERS_URL = '/api/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
const MASTODON_DISMISS_NOTIFICATION_URL = id => `/api/v1/notifications/${id}/dismiss`
const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = id => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = id => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = id => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = id => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOWING_URL = id => `/api/v1/accounts/${id}/following`
const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = id => `/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = id => `/api/v1/follow_requests/${id}/reject`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = '/api/v1/timelines/direct'
const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
+const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source`
+const MASTODON_STATUS_HISTORY_URL = id => `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_USER_IN_LISTS = id => `/api/v1/accounts/${id}/lists`
const MASTODON_LIST_URL = id => `/api/v1/lists/${id}`
const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}`
const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
const MASTODON_POLL_URL = id => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = id => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
const MASTODON_SEARCH_2 = '/api/v2/search'
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_LISTS_URL = '/api/v1/lists'
const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const PLEROMA_ADMIN_REPORTS = '/api/pleroma/admin/reports'
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const oldfetch = window.fetch
const fetch = (url, options) => {
options = options || {}
const baseUrl = ''
const fullUrl = baseUrl + url
options.credentials = 'same-origin'
return oldfetch(fullUrl, options)
}
const promisedRequest = ({ method, url, params, payload, credentials, headers = {} }) => {
const options = {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...headers
}
}
if (params) {
url += '?' + Object.entries(params)
.map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
.join('&')
}
if (payload) {
options.body = JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials)
}
}
return fetch(url, options)
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
return resolve(json)
})
.catch((error) => {
return reject(new StatusCodeError(response.status, error, { url, options }, response))
})
)
})
}
const updateNotificationSettings = ({ credentials, settings }) => {
const form = new FormData()
each(settings, (value, key) => {
form.append(key, value)
})
return fetch(NOTIFICATION_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'PUT',
body: form
}).then((data) => data.json())
}
const updateProfileImages = ({ credentials, avatar = null, avatarName = null, banner = null, background = null }) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
form.append('avatar', avatar, avatarName)
} else {
form.append('avatar', avatar)
}
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
})
.then((data) => data.json())
.then((data) => {
if (data.error) {
throw new Error(data.error)
}
return parseUser(data)
})
}
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
method: 'PATCH',
payload: params,
credentials
}).then((data) => parseUser(data))
}
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return fetch(MASTODON_REGISTRATION_URL, {
method: 'POST',
headers: {
...authHeaders(credentials),
'Content-Type': 'application/json'
},
body: JSON.stringify({
nickname,
locale: 'en_US',
agreement: true,
...rest
})
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return response.json().then((error) => { throw new RegistrationError(error) })
}
})
}
const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json())
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return { }
}
}
const followUser = ({ id, credentials, ...options }) => {
const url = MASTODON_FOLLOW_URL(id)
const form = {}
if (options.reblogs !== undefined) { form.reblogs = options.reblogs }
return fetch(url, {
body: JSON.stringify(form),
headers: {
...authHeaders(credentials),
'Content-Type': 'application/json'
},
method: 'POST'
}).then((data) => data.json())
}
const unfollowUser = ({ id, credentials }) => {
const url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const fetchUserInLists = ({ id, credentials }) => {
const url = MASTODON_USER_IN_LISTS(id)
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const pinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const unpinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNPIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const muteConversation = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_MUTE_CONVERSATION(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const unmuteConversation = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNMUTE_CONVERSATION(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const blockUser = ({ id, credentials }) => {
return fetch(MASTODON_BLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const unblockUser = ({ id, credentials }) => {
return fetch(MASTODON_UNBLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const approveUser = ({ id, credentials }) => {
const url = MASTODON_APPROVE_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const denyUser = ({ id, credentials }) => {
const url = MASTODON_DENY_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const fetchUser = ({ id, credentials }) => {
const url = `${MASTODON_USER_URL}/${id}`
return promisedRequest({ url, credentials })
.then((data) => parseUser(data))
}
const fetchUserByName = ({ name, credentials }) => {
return promisedRequest({
url: MASTODON_USER_LOOKUP_URL,
credentials,
params: { acct: name }
})
.then(data => data.id)
.catch(error => {
if (error && error.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then(id => fetchUser({ id, credentials }))
}
const fetchUserRelationship = ({ id, credentials }) => {
const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url }, response))
}
return resolve(json)
}))
})
}
const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWING_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
'with_relationships=true'
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const exportFriends = ({ id, credentials }) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({ id, maxId, credentials })
friends = concat(friends, users)
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWERS_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
'with_relationships=true'
].filter(_ => _).join('&')
url += args ? '?' + args : ''
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchFollowRequests = ({ credentials }) => {
const url = MASTODON_FOLLOW_REQUESTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchLists = ({ credentials }) => {
const url = MASTODON_LISTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const createList = ({ title, credentials }) => {
const url = MASTODON_LISTS_URL
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ title })
}).then((data) => data.json())
}
const getList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const updateList = ({ listId, title, credentials }) => {
const url = MASTODON_LIST_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'PUT',
body: JSON.stringify({ title })
})
}
const getListAccounts = ({ listId, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(({ id }) => id))
}
const addAccountsToList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ account_ids: accountIds })
})
}
const removeAccountsFromList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'DELETE',
body: JSON.stringify({ account_ids: accountIds })
})
}
const deleteList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials)
})
}
const fetchConversation = ({ id, credentials }) => {
const urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then(({ ancestors, descendants }) => ({
ancestors: ancestors.map(parseStatus),
descendants: descendants.map(parseStatus)
}))
}
const fetchStatus = ({ id, credentials }) => {
const url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then((data) => parseStatus(data))
}
+const fetchStatusSource = ({ id, credentials }) => {
+ const url = MASTODON_STATUS_SOURCE_URL(id)
+ return fetch(url, { headers: authHeaders(credentials) })
+ .then((data) => {
+ if (data.ok) {
+ return data
+ }
+ throw new Error('Error fetching source', data)
+ })
+ .then((data) => data.json())
+ .then((data) => parseSource(data))
+}
+
+const fetchStatusHistory = ({ status, credentials }) => {
+ const url = MASTODON_STATUS_HISTORY_URL(status.id)
+ return promisedRequest({ url, credentials })
+ .then((data) => {
+ data.reverse()
+ return data.map((item) => {
+ item.originalStatus = status
+ return parseStatus(item)
+ })
+ })
+}
+
const tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const form = {
nicknames: [screenName],
tags: [tag]
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'PUT',
headers,
body: JSON.stringify(form)
})
}
const untagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const body = {
nicknames: [screenName],
tags: [tag]
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'DELETE',
headers,
body: JSON.stringify(body)
})
}
const addRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'POST',
headers: authHeaders(credentials),
body: {}
})
}
const deleteRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'DELETE',
headers: authHeaders(credentials),
body: {}
})
}
const activateUser = ({ credentials, user: { screen_name: nickname } }) => {
return promisedRequest({
url: ACTIVATE_USER_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname]
}
}).then(response => get(response, 'users.0'))
}
const deactivateUser = ({ credentials, user: { screen_name: nickname } }) => {
return promisedRequest({
url: DEACTIVATE_USER_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname]
}
}).then(response => get(response, 'users.0'))
}
const deleteUser = ({ credentials, user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, {
method: 'DELETE',
headers
})
}
const fetchTimeline = ({
timeline,
credentials,
since = false,
until = false,
userId = false,
listId = false,
tag = false,
withMuted = false,
replyVisibility = 'all',
includeTypes = []
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
friends: MASTODON_USER_HOME_TIMELINE_URL,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
publicAndExternal: MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
}
const isNotifications = timeline === 'notifications'
const params = []
let url = timelineUrls[timeline]
if (timeline === 'user' || timeline === 'media') {
url = url(userId)
}
if (timeline === 'list') {
url = url(listId)
}
if (since) {
params.push(['since_id', since])
}
if (until) {
params.push(['max_id', until])
}
if (tag) {
url = url(tag)
}
if (timeline === 'media') {
params.push(['only_media', 1])
}
if (timeline === 'public') {
params.push(['local', true])
}
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted])
}
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
if (includeTypes.length > 0) {
includeTypes.forEach(type => {
params.push(['include_types[]', type])
})
}
params.push(['limit', 20])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
let status = ''
let statusText = ''
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
status = data.status
statusText = data.statusText
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
})
return data
})
.then((data) => data.json())
.then((data) => {
if (!data.errors) {
return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else {
data.status = status
data.statusText = statusText
return data
}
})
}
const fetchPinnedStatuses = ({ id, credentials }) => {
const url = MASTODON_USER_TIMELINE_URL(id) + '?pinned=true'
return promisedRequest({ url, credentials })
.then((data) => data.map(parseStatus))
}
const verifyCredentials = (user) => {
return fetch(MASTODON_LOGIN_URL, {
headers: authHeaders(user)
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response
}
}
})
.then((data) => data.error ? data : parseUser(data))
}
const favorite = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_FAVORITE_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const unfavorite = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNFAVORITE_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const retweet = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const unretweet = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNRETWEET_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const bookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const unbookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
contentType,
preview,
idempotencyKey
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach(val => {
form.append('media_ids[]', val)
})
if (pollOptions.some(option => option !== '')) {
const normalizedPoll = {
expires_in: poll.expiresIn,
multiple: poll.multiple
}
Object.keys(normalizedPoll).forEach(key => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach(option => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
if (preview) {
form.append('preview', 'true')
}
const postHeaders = authHeaders(credentials)
if (idempotencyKey) {
postHeaders['idempotency-key'] = idempotencyKey
}
return fetch(MASTODON_POST_STATUS_URL, {
body: form,
method: 'POST',
headers: postHeaders
})
.then((response) => {
return response.json()
})
.then((data) => data.error ? data : parseStatus(data))
}
+const editStatus = ({
+ id,
+ credentials,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ mediaIds = [],
+ contentType
+}) => {
+ const form = new FormData()
+ const pollOptions = poll.options || []
+
+ form.append('status', status)
+ if (spoilerText) form.append('spoiler_text', spoilerText)
+ if (sensitive) form.append('sensitive', sensitive)
+ if (contentType) form.append('content_type', contentType)
+ mediaIds.forEach(val => {
+ form.append('media_ids[]', val)
+ })
+
+ if (pollOptions.some(option => option !== '')) {
+ const normalizedPoll = {
+ expires_in: poll.expiresIn,
+ multiple: poll.multiple
+ }
+ Object.keys(normalizedPoll).forEach(key => {
+ form.append(`poll[${key}]`, normalizedPoll[key])
+ })
+
+ pollOptions.forEach(option => {
+ form.append('poll[options][]', option)
+ })
+ }
+
+ const putHeaders = authHeaders(credentials)
+
+ return fetch(MASTODON_STATUS_URL(id), {
+ body: form,
+ method: 'PUT',
+ headers: putHeaders
+ })
+ .then((response) => {
+ return response.json()
+ })
+ .then((data) => data.error ? data : parseStatus(data))
+}
+
const deleteStatus = ({ id, credentials }) => {
return fetch(MASTODON_DELETE_URL(id), {
headers: authHeaders(credentials),
method: 'DELETE'
})
}
const uploadMedia = ({ formData, credentials }) => {
return fetch(MASTODON_MEDIA_UPLOAD_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((data) => data.json())
.then((data) => parseAttachment(data))
}
const setMediaDescription = ({ id, description, credentials }) => {
return promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
headers: authHeaders(credentials),
payload: {
description
}
}).then((data) => parseAttachment(data))
}
const importMutes = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(MUTES_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(BLOCKS_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(FOLLOW_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const deleteAccount = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(DELETE_ACCOUNT_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const changeEmail = ({ credentials, email, password }) => {
const form = new FormData()
form.append('email', email)
form.append('password', password)
return fetch(CHANGE_EMAIL_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const moveAccount = ({ credentials, password, targetAccount }) => {
const form = new FormData()
form.append('password', password)
form.append('target_account', targetAccount)
return fetch(MOVE_ACCOUNT_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const addAlias = ({ credentials, alias }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'PUT',
credentials,
payload: { alias }
})
}
const deleteAlias = ({ credentials, alias }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'DELETE',
credentials,
payload: { alias }
})
}
const listAliases = ({ credentials }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: (new Date()).getTime()
}
})
}
const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return fetch(CHANGE_PASSWORD_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const settingsMFA = ({ credentials }) => {
return fetch(MFA_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(MFA_DISABLE_OTP_URL, {
body: form,
method: 'DELETE',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return fetch(MFA_CONFIRM_OTP_URL, {
body: form,
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const mfaSetupOTP = ({ credentials }) => {
return fetch(MFA_SETUP_OTP_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const generateMfaBackupCodes = ({ credentials }) => {
return fetch(MFA_BACKUP_CODES_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const fetchMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
.then((users) => users.map(parseUser))
}
const muteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' })
}
const unmuteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
}
const subscribeUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_SUBSCRIBE_USER(id), credentials, method: 'POST' })
}
const unsubscribeUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNSUBSCRIBE_USER(id), credentials, method: 'POST' })
}
const fetchBlocks = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials })
.then((users) => users.map(parseUser))
}
const addBackup = ({ credentials }) => {
return promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'POST',
credentials
})
}
const listBackups = ({ credentials }) => {
return promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: (new Date()).getTime()
}
})
}
const fetchOAuthTokens = ({ credentials }) => {
const url = '/api/oauth_tokens.json'
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => {
if (data.ok) {
return data.json()
}
throw new Error('Error fetching auth tokens', data)
})
}
const revokeOAuthToken = ({ id, credentials }) => {
const url = `/api/oauth_tokens/${id}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'DELETE'
})
}
const suggestions = ({ credentials }) => {
return fetch(SUGGESTIONS_URL, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const markNotificationsAsSeen = ({ id, credentials, single = false }) => {
const body = new FormData()
if (single) {
body.append('id', id)
} else {
body.append('max_id', id)
}
return fetch(NOTIFICATION_READ_URL, {
body,
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const vote = ({ pollId, choices, credentials }) => {
const form = new FormData()
form.append('choices', choices)
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices
}
})
}
const fetchPoll = ({ pollId, credentials }) => {
return promisedRequest(
{
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials
}
)
}
const fetchFavoritedByUsers = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials
}).then((users) => users.map(parseUser))
}
const fetchRebloggedByUsers = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials
}).then((users) => users.map(parseUser))
}
const fetchEmojiReactions = ({ id, credentials }) => {
return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id), credentials })
.then((reactions) => reactions.map(r => {
r.accounts = r.accounts.map(parseUser)
return r
}))
}
const reactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials
}).then(parseStatus)
}
const unreactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials
}).then(parseStatus)
}
const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
return promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
account_id: userId,
status_ids: statusIds,
comment,
forward
},
credentials
})
}
const searchUsers = ({ credentials, query }) => {
return promisedRequest({
url: MASTODON_USER_SEARCH_URL,
params: {
q: query,
resolve: true
},
credentials
})
.then((data) => data.map(parseUser))
}
const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
let url = MASTODON_SEARCH_2
const params = []
if (q) {
params.push(['q', encodeURIComponent(q)])
}
if (resolve) {
params.push(['resolve', resolve])
}
if (limit) {
params.push(['limit', limit])
}
if (offset) {
params.push(['offset', offset])
}
if (following) {
params.push(['following', true])
}
params.push(['with_relationships', true])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching search result', data)
})
.then((data) => { return data.json() })
.then((data) => {
data.accounts = data.accounts.slice(0, limit).map(u => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map(s => parseStatus(s))
return data
})
}
const fetchKnownDomains = ({ credentials }) => {
return promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
}
const fetchDomainMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
}
const muteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials
})
}
const unmuteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials
})
}
const dismissNotification = ({ credentials, id }) => {
return promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials
})
}
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
return Object.entries({
...(credentials
? { access_token: credentials }
: {}
),
stream,
...args
}).reduce((acc, [key, val]) => {
return acc + `${key}=${val}&`
}, MASTODON_STREAMING + '?')
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
- 'filters_changed'
+ 'filters_changed',
+ 'status.update'
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update'
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
url,
preprocessor = handleMastoWS,
id = 'Unknown'
}) => {
const eventTarget = new EventTarget()
const socket = new WebSocket(url)
if (!socket) throw new Error(`Failed to create socket ${id}`)
const proxy = (original, eventName, processor = a => a) => {
original.addEventListener(eventName, (eventData) => {
eventTarget.dispatchEvent(new CustomEvent(
eventName,
{ detail: processor(eventData) }
))
})
}
socket.addEventListener('open', (wsEvent) => {
console.debug(`[WS][${id}] Socket connected`, wsEvent)
})
socket.addEventListener('error', (wsEvent) => {
console.debug(`[WS][${id}] Socket errored`, wsEvent)
})
socket.addEventListener('close', (wsEvent) => {
console.debug(
`[WS][${id}] Socket disconnected with code ${wsEvent.code}`,
wsEvent
)
})
// Commented code reason: very spammy, uncomment to enable message debug logging
/*
socket.addEventListener('message', (wsEvent) => {
console.debug(
`[WS][${id}] Message received`,
wsEvent
)
})
/**/
proxy(socket, 'open')
proxy(socket, 'close')
proxy(socket, 'message', preprocessor)
proxy(socket, 'error')
// 1000 = Normal Closure
eventTarget.close = () => { socket.close(1000, 'Shutting down socket') }
eventTarget.getState = () => socket.readyState
return eventTarget
}
export const handleMastoWS = (wsEvent) => {
const { data } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
}
const data = payload ? JSON.parse(payload) : null
if (event === 'update') {
return { event, status: parseStatus(data) }
+ } else if (event === 'status.update') {
+ return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
return null
}
}
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6
})
const chats = ({ credentials }) => {
return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => {
return { chats: data.map(parseChat).filter(c => c) }
})
}
const getOrCreateChat = ({ accountId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials
})
}
const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
let url = PLEROMA_CHAT_MESSAGES_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
return promisedRequest({
url,
method: 'GET',
credentials
})
}
const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
const payload = {
content
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers
})
}
const readChat = ({ id, lastReadId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId
},
credentials
})
}
const deleteChatMessage = ({ chatId, messageId, credentials }) => {
return promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials
})
}
const setReportState = ({ id, state, credentials }) => {
// TODO: Can't use promisedRequest because on OK this does not return json
// See https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1322
return fetch(PLEROMA_ADMIN_REPORTS, {
headers: {
...authHeaders(credentials),
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'PATCH',
body: JSON.stringify({
reports: [{
id,
state
}]
})
})
.then(data => {
if (data.status >= 500) {
throw Error(data.statusText)
} else if (data.status >= 400) {
return data.json()
}
return data
})
.then(data => {
if (data.errors) {
throw Error(data.errors[0].message)
}
})
}
const apiService = {
verifyCredentials,
fetchTimeline,
fetchPinnedStatuses,
fetchConversation,
fetchStatus,
+ fetchStatusSource,
+ fetchStatusHistory,
fetchFriends,
exportFriends,
fetchFollowers,
followUser,
unfollowUser,
pinOwnStatus,
unpinOwnStatus,
muteConversation,
unmuteConversation,
blockUser,
unblockUser,
fetchUser,
fetchUserByName,
fetchUserRelationship,
favorite,
unfavorite,
retweet,
unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus,
+ editStatus,
deleteStatus,
uploadMedia,
setMediaDescription,
fetchMutes,
muteUser,
unmuteUser,
subscribeUser,
unsubscribeUser,
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
tagUser,
untagUser,
deleteUser,
addRight,
deleteRight,
activateUser,
deactivateUser,
register,
getCaptcha,
updateProfileImages,
updateProfile,
importMutes,
importBlocks,
importFollows,
deleteAccount,
changeEmail,
moveAccount,
addAlias,
deleteAlias,
listAliases,
changePassword,
settingsMFA,
mfaDisableOTP,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
addBackup,
listBackups,
fetchFollowRequests,
fetchLists,
createList,
getList,
updateList,
getListAccounts,
addAccountsToList,
removeAccountsFromList,
deleteList,
approveUser,
denyUser,
suggestions,
markNotificationsAsSeen,
dismissNotification,
vote,
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
fetchEmojiReactions,
reactWithEmoji,
unreactWithEmoji,
reportUser,
updateNotificationSettings,
search2,
searchUsers,
fetchKnownDomains,
fetchDomainMutes,
muteDomain,
unmuteDomain,
chats,
getOrCreateChat,
chatMessages,
sendChatMessage,
readChat,
deleteChatMessage,
setReportState,
fetchUserInLists
}
export default apiService
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 451427da5a..23061eba7e 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,468 +1,484 @@
import escape from 'escape-html'
import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
import punycode from 'punycode.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))) {
return 'favorite'
}
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
return 'deletion'
}
if (status.text.match(/started following/) || status.activity_type === 'follow') {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = Object.prototype.hasOwnProperty.call(data, 'acct')
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escape(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map(field => {
return {
name: escape(field.name),
value: field.value
}
})
output.fields_text = data.fields.map(field => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, ''))
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
output.is_local = data.is_local
output.role = data.role
output.show_role = data.show_role
if (data.rights) {
output.rights = {
moderator: data.rights.delete_others_notice,
admin: data.rights.admin
}
}
output.no_rich_text = data.no_rich_text
output.default_scope = data.default_scope
output.hide_follows = data.hide_follows
output.hide_followers = data.hide_followers
output.hide_follows_count = data.hide_follows_count
output.hide_followers_count = data.hide_followers_count
output.background_image = data.background_image
// Websocket token
output.token = data.token
// Convert relationsip data to expected format
output.relationship = {
muting: data.muted,
blocking: data.statusnet_blocking,
followed_by: data.follows_you,
following: data.following
}
}
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = data.pleroma.tags
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated = typeof data.pleroma.is_active !== 'undefined'
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
const masto = !Object.prototype.hasOwnProperty.call(data, 'oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
} else {
output.mimetype = data.mimetype
// output.meta = ??? missing
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = data.description
return output
}
+export const parseSource = (data) => {
+ const output = {}
+
+ output.text = data.text
+ output.spoiler_text = data.spoiler_text
+ output.content_type = data.content_type
+
+ return output
+}
+
export const parseStatus = (data) => {
const output = {}
const masto = Object.prototype.hasOwnProperty.call(data, 'account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
+ output.edited_at = data.edited_at
+
if (data.pleroma) {
const { pleroma } = data
output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else {
output.text = data.content
output.summary = data.spoiler_text
}
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escape(data.spoiler_text)
output.external_url = data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map(field => ({
...field,
title_html: escape(field.title)
}))
}
output.pinned = data.pinned
output.muted = data.muted
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.raw_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
output.in_reply_to_user_id = data.in_reply_to_user_id
output.in_reply_to_screen_name = data.in_reply_to_screen_name
output.statusnet_conversation_id = data.statusnet_conversation_id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.summary_html = data.summary_html
output.external_url = data.external_url
output.is_local = data.is_local
}
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(masto ? data.account : data.user)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(parseUser)
output.attachments = ((masto ? data.media_attachments : data.attachments) || [])
.map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
+ if (Object.prototype.hasOwnProperty.call(data, 'originalStatus')) {
+ Object.assign(output, data.originalStatus)
+ }
+
return output
}
export const parseNotification = (data) => {
const mastoDict = {
favourite: 'like',
reblog: 'repeat'
}
const masto = !Object.prototype.hasOwnProperty.call(data, 'ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
output.action = output.status // TODO: Refactor, this is unneeded
output.target = output.type !== 'move'
? null
: parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status = output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
output.id = parseInt(data.id)
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next.max_id
const minId = parsedLinkHeader.prev.min_id
return {
maxId: flakeId ? maxId : parseInt(maxId, 10),
minId: flakeId ? minId : parseInt(minId, 10)
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) { return }
if (message.isNormalized) { return message }
const output = message
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
output.emojis = message.emojis
output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index f09196aabf..1eb10bb61d 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -1,66 +1,108 @@
import { map } from 'lodash'
import apiService from '../api/api.service.js'
const postStatus = ({
store,
status,
spoilerText,
visibility,
sensitive,
poll,
media = [],
inReplyToStatusId = undefined,
contentType = 'text/plain',
preview = false,
idempotencyKey = ''
}) => {
const mediaIds = map(media, 'id')
return apiService.postStatus({
credentials: store.state.users.currentUser.credentials,
status,
spoilerText,
visibility,
sensitive,
mediaIds,
inReplyToStatusId,
contentType,
poll,
preview,
idempotencyKey
})
.then((data) => {
if (!data.error && !preview) {
store.dispatch('addNewStatuses', {
statuses: [data],
timeline: 'friends',
showImmediately: true,
noIdUpdate: true // To prevent missing notices on next pull.
})
}
return data
})
.catch((err) => {
return {
error: err.message
}
})
}
+const editStatus = ({
+ store,
+ statusId,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ media = [],
+ contentType = 'text/plain'
+}) => {
+ const mediaIds = map(media, 'id')
+
+ return apiService.editStatus({
+ id: statusId,
+ credentials: store.state.users.currentUser.credentials,
+ status,
+ spoilerText,
+ sensitive,
+ poll,
+ mediaIds,
+ contentType
+ })
+ .then((data) => {
+ if (!data.error) {
+ store.dispatch('addNewStatuses', {
+ statuses: [data],
+ timeline: 'friends',
+ showImmediately: true,
+ noIdUpdate: true // To prevent missing notices on next pull.
+ })
+ }
+ return data
+ })
+ .catch((err) => {
+ console.error('Error editing status', err)
+ return {
+ error: err.message
+ }
+ })
+}
+
const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData })
}
const setMediaDescription = ({ store, id, description }) => {
const credentials = store.state.users.currentUser.credentials
return apiService.setMediaDescription({ credentials, id, description })
}
const statusPosterService = {
postStatus,
+ editStatus,
uploadMedia,
setMediaDescription
}
export default statusPosterService

File Metadata

Mime Type
text/x-diff
Expires
Sun, Nov 1, 1:24 AM (16 h, 50 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1802138
Default Alt Text
(297 KB)

Event Timeline