Merge branch 'add/edit-status' into 'develop'

Add edit status functionality

See merge request pleroma/pleroma-fe!1537
This commit is contained in:
tusooa 2022-09-11 18:08:00 +00:00
commit 2bea5d8128
27 changed files with 625 additions and 18 deletions

View File

@ -10,3 +10,5 @@ Contributors of this project.
- shpuld (shpuld@shitposter.club): CSS and styling - shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images. - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code - hj (hj@shigusegubu.club): Code
- Sean King (seanking@freespeechextremist.com): Code
- Tusooa Zhu (tusooa@kazv.moe): Code

View File

@ -10,7 +10,9 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue' import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.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 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 GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils' import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
@ -35,6 +37,8 @@ export default {
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')), UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal, UserReportingModal,
PostStatusModal, PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList GlobalNoticeList
}, },
data: () => ({ data: () => ({
@ -101,6 +105,7 @@ export default {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () { shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
}, },

View File

@ -67,6 +67,8 @@
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<EditStatusModal v-if="editingAvailable" />
<StatusHistoryModal v-if="editingAvailable" />
<SettingsModal /> <SettingsModal />
<UpdateNotification /> <UpdateNotification />
<div id="modal" /> <div id="modal" />

View File

@ -251,6 +251,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) 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: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })

View File

@ -129,6 +129,9 @@ const Attachment = {
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
watch: { watch: {
'attachment.description' (newVal) {
this.localDescription = newVal
},
localDescription (newVal) { localDescription (newVal) {
this.onEdit(newVal) this.onEdit(newVal)
} }

View File

@ -1,6 +1,8 @@
import { reduce, filter, findIndex, clone, get } from 'lodash' import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.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 QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
@ -79,6 +81,9 @@ const conversation = {
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1 return maxDepth >= 1 ? maxDepth : 1
}, },
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
displayStyle () { displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay return this.$store.getters.mergedConfig.conversationDisplay
}, },
@ -341,7 +346,11 @@ const conversation = {
}, },
maybeHighlight () { maybeHighlight () {
return this.isExpanded ? this.highlight : null return this.isExpanded ? this.highlight : null
} },
...mapGetters(['mergedConfig']),
...mapState({
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
})
}, },
components: { components: {
Status, Status,
@ -399,6 +408,11 @@ const conversation = {
setHighlight (id) { setHighlight (id) {
if (!id) return if (!id) return
this.highlight = id this.highlight = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id) this.$store.dispatch('fetchEmojiReactionsBy', id)
}, },

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
faHistory,
faPlus, faPlus,
faTimes faTimes
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
@ -24,6 +25,7 @@ library.add(
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
faFlag, faFlag,
faHistory,
faPlus, faPlus,
faTimes faTimes
) )
@ -86,6 +88,25 @@ const ExtraButtons = {
}, },
reportStatus () { reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) 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: { computed: {
@ -109,7 +130,11 @@ const ExtraButtons = {
}, },
statusLink () { statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` 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 }
} }
} }

View File

@ -77,6 +77,28 @@
/><span>{{ $t("status.unbookmark") }}</span> /><span>{{ $t("status.unbookmark") }}</span>
</button> </button>
</template> </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 <button
v-if="canDelete" v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"

View File

@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
const PostStatusForm = { const PostStatusForm = {
props: [ props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo', 'replyTo',
'repliedUser', 'repliedUser',
'attentions', 'attentions',
@ -62,6 +70,7 @@ const PostStatusForm = {
'subject', 'subject',
'disableSubject', 'disableSubject',
'disableScopeSelector', 'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice', 'disableNotice',
'disableLockWarning', 'disableLockWarning',
'disablePolls', 'disablePolls',
@ -125,22 +134,38 @@ const PostStatusForm = {
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig 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 { return {
dropFiles: [], dropFiles: [],
uploadingFiles: false, uploadingFiles: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0, highlighted: 0,
newStatus: { newStatus: statusParams,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
mediaDescriptions: {},
visibility: scope,
contentType
},
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
@ -236,6 +261,9 @@ const PostStatusForm = {
uploadFileLimitReached () { uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit return this.newStatus.files.length >= this.fileLimit
}, },
isEdit () {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
...mapGetters(['mergedConfig']), ...mapGetters(['mergedConfig']),
...mapState({ ...mapState({
mobileLayout: state => state.interface.mobileLayout mobileLayout: state => state.interface.mobileLayout

View File

@ -66,6 +66,13 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </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 <div
v-if="!disablePreview" v-if="!disablePreview"
class="preview-heading faint" class="preview-heading faint"
@ -170,6 +177,7 @@
class="visibility-tray" class="visibility-tray"
> >
<scope-selector <scope-selector
v-if="!disableVisibilitySelector"
:show-all="showAllScopes" :show-all="showAllScopes"
:user-default="userDefaultScope" :user-default="userDefaultScope"
:original-scope="copyMessageScope" :original-scope="copyMessageScope"
@ -410,6 +418,16 @@
align-items: baseline; align-items: baseline;
} }
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
.media-upload-icon, .poll-icon, .emoji-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 1.85em; font-size: 1.85em;
line-height: 1.1; line-height: 1.1;

View File

@ -395,6 +395,12 @@ const Status = {
}, },
visibilityLocalized () { visibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) 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: { methods: {

View File

@ -156,7 +156,8 @@
margin-right: 0.2em; margin-right: 0.2em;
} }
& .heading-reply-row { & .heading-reply-row,
& .heading-edited-row {
position: relative; position: relative;
align-content: baseline; align-content: baseline;
font-size: 0.85em; font-size: 0.85em;

View File

@ -327,6 +327,24 @@
class="mentions-line" class="mentions-line"
/> />
</div> </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> </div>
<StatusContent <StatusContent

View File

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

View File

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

View File

@ -3,7 +3,7 @@
:datetime="time" :datetime="time"
:title="localeDateString" :title="localeDateString"
> >
{{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }} {{ relativeTimeString }}
</time> </time>
</template> </template>
@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js'
export default { export default {
name: 'Timeago', name: 'Timeago',
props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data () { data () {
return { return {
relativeTime: { key: 'time.now', num: 0 }, relativeTime: { key: 'time.now', num: 0 },
@ -26,6 +26,23 @@ export default {
return typeof this.time === 'string' return typeof this.time === 'string'
? new Date(Date.parse(this.time)).toLocaleString(browserLocale) ? new Date(Date.parse(this.time)).toLocaleString(browserLocale)
: 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 () { created () {

View File

@ -214,6 +214,7 @@
"load_older": "Load older interactions" "load_older": "Load older interactions"
}, },
"post_status": { "post_status": {
"edit_status": "Edit status",
"new_status": "Post new 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": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
@ -229,6 +230,8 @@
"default": "Just landed in L.A.", "default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.", "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.", "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", "posting": "Posting",
"post": "Post", "post": "Post",
"preview": "Preview", "preview": "Preview",
@ -797,6 +800,8 @@
"favorites": "Favorites", "favorites": "Favorites",
"repeats": "Repeats", "repeats": "Repeats",
"delete": "Delete status", "delete": "Delete status",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"pin": "Pin on profile", "pin": "Pin on profile",
"unpin": "Unpin from profile", "unpin": "Unpin from profile",
"pinned": "Pinned", "pinned": "Pinned",
@ -844,7 +849,8 @@
"ancestor_follow_with_icon": "{icon} {text}", "ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_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_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": { "user_card": {
"approve": "Approve", "approve": "Approve",

View File

@ -20,6 +20,9 @@ import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js' import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js' import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.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 chatsModule from './modules/chats.js'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@ -86,6 +89,8 @@ const persistedStateOptions = {
reports: reportsModule, reports: reportsModule,
polls: pollsModule, polls: pollsModule,
postStatus: postStatusModule, postStatus: postStatusModule,
editStatus: editStatusModule,
statusHistory: statusHistoryModule,
chats: chatsModule chats: chatsModule
}, },
plugins, plugins,

View File

@ -103,6 +103,13 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0, showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends' 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') { } else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id) dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') { } else if (message.event === 'pleroma:chat_update') {

25
src/modules/editStatus.js Normal file
View File

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

View File

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

View File

@ -249,6 +249,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
status: (status) => { status: (status) => {
addStatus(status, showImmediately) addStatus(status, showImmediately)
}, },
edit: (status) => {
addStatus(status, showImmediately)
},
retweet: (status) => { retweet: (status) => {
// RetweetedStatuses are never shown immediately // RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false) const retweetedStatus = addStatus(status.retweeted_status, false, false)
@ -606,6 +609,12 @@ const statuses = {
return rootState.api.backendInteractor.fetchStatus({ id }) return rootState.api.backendInteractor.fetchStatus({ id })
.then((status) => dispatch('addNewStatuses', { statuses: [status] })) .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) { deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status }) commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })

View File

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash' 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' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* eslint-env browser */
@ -49,6 +49,8 @@ const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home' const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}` const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` 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_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup' const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
@ -522,6 +524,31 @@ const fetchStatus = ({ id, credentials }) => {
.then((data) => parseStatus(data)) .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 tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name const screenName = user.screen_name
const form = { const form = {
@ -825,6 +852,54 @@ const postStatus = ({
.then((data) => data.error ? data : parseStatus(data)) .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 }) => { const deleteStatus = ({ id, credentials }) => {
return fetch(MASTODON_DELETE_URL(id), { return fetch(MASTODON_DELETE_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
@ -1291,7 +1366,8 @@ const MASTODON_STREAMING_EVENTS = new Set([
'update', 'update',
'notification', 'notification',
'delete', 'delete',
'filters_changed' 'filters_changed',
'status.update'
]) ])
const PLEROMA_STREAMING_EVENTS = new Set([ const PLEROMA_STREAMING_EVENTS = new Set([
@ -1363,6 +1439,8 @@ export const handleMastoWS = (wsEvent) => {
const data = payload ? JSON.parse(payload) : null const data = payload ? JSON.parse(payload) : null
if (event === 'update') { if (event === 'update') {
return { event, status: parseStatus(data) } return { event, status: parseStatus(data) }
} else if (event === 'status.update') {
return { event, status: parseStatus(data) }
} else if (event === 'notification') { } else if (event === 'notification') {
return { event, notification: parseNotification(data) } return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') { } else if (event === 'pleroma:chat_update') {
@ -1497,6 +1575,8 @@ const apiService = {
fetchPinnedStatuses, fetchPinnedStatuses,
fetchConversation, fetchConversation,
fetchStatus, fetchStatus,
fetchStatusSource,
fetchStatusHistory,
fetchFriends, fetchFriends,
exportFriends, exportFriends,
fetchFollowers, fetchFollowers,
@ -1518,6 +1598,7 @@ const apiService = {
bookmarkStatus, bookmarkStatus,
unbookmarkStatus, unbookmarkStatus,
postStatus, postStatus,
editStatus,
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
setMediaDescription, setMediaDescription,

View File

@ -251,6 +251,16 @@ export const parseAttachment = (data) => {
return output 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) => { export const parseStatus = (data) => {
const output = {} const output = {}
const masto = Object.prototype.hasOwnProperty.call(data, 'account') const masto = Object.prototype.hasOwnProperty.call(data, 'account')
@ -272,6 +282,8 @@ export const parseStatus = (data) => {
output.tags = data.tags output.tags = data.tags
output.edited_at = data.edited_at
if (data.pleroma) { if (data.pleroma) {
const { pleroma } = data const { pleroma } = data
output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
@ -373,6 +385,10 @@ export const parseStatus = (data) => {
output.favoritedBy = [] output.favoritedBy = []
output.rebloggedBy = [] output.rebloggedBy = []
if (Object.prototype.hasOwnProperty.call(data, 'originalStatus')) {
Object.assign(output, data.originalStatus)
}
return output return output
} }

View File

@ -47,6 +47,47 @@ const postStatus = ({
}) })
} }
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 uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData }) return apiService.uploadMedia({ credentials, formData })
@ -59,6 +100,7 @@ const setMediaDescription = ({ store, id, description }) => {
const statusPosterService = { const statusPosterService = {
postStatus, postStatus,
editStatus,
uploadMedia, uploadMedia,
setMediaDescription setMediaDescription
} }