Merge branch 'from/develop/tusooa/announcements' into 'develop'

Announcements

See merge request pleroma/pleroma-fe!1466
This commit is contained in:
HJ 2022-12-05 15:34:59 +00:00
commit 00f4e20492
20 changed files with 729 additions and 13 deletions

View file

@ -0,0 +1,105 @@
import { mapState } from 'vuex'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx'
import localeService from '../../services/locale/locale.service.js'
const Announcement = {
components: {
AnnouncementEditor,
RichContent
},
data () {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined
},
editError: ''
}
},
props: {
announcement: Object
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
content () {
return this.announcement.content
},
isRead () {
return this.announcement.read
},
publishedAt () {
const time = this.announcement.published_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
startsAt () {
const time = this.announcement.starts_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
endsAt () {
const time = this.announcement.ends_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
inactive () {
return this.announcement.inactive
}
},
methods: {
markAsRead () {
if (!this.isRead) {
return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
}
},
deleteAnnouncement () {
return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
},
formatTimeOrDate (time, locale) {
const d = new Date(time)
return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
},
enterEditMode () {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true
},
submitEdit () {
this.$store.dispatch('editAnnouncement', {
id: this.announcement.id,
...this.editedAnnouncement
})
.then(() => {
this.editing = false
})
.catch(error => {
this.editError = error.error
})
},
cancelEdit () {
this.editing = false
},
clearError () {
this.editError = undefined
}
}
}
export default Announcement

View file

@ -0,0 +1,136 @@
<template>
<div class="announcement">
<div class="heading">
<h4>{{ $t('announcements.title') }}</h4>
</div>
<div class="body">
<rich-content
v-if="!editing"
:html="content"
:emoji="announcement.emojis"
:handle-links="true"
/>
<announcement-editor
v-else
:announcement="editedAnnouncement"
/>
</div>
<div class="footer">
<div
v-if="!editing"
class="times"
>
<span v-if="publishedAt">
{{ $t('announcements.published_time_display', { time: publishedAt }) }}
</span>
<span v-if="startsAt">
{{ $t('announcements.start_time_display', { time: startsAt }) }}
</span>
<span v-if="endsAt">
{{ $t('announcements.end_time_display', { time: endsAt }) }}
</span>
</div>
<div
v-if="!editing"
class="actions"
>
<button
v-if="currentUser"
class="btn button-default"
:class="{ toggled: isRead }"
:disabled="inactive"
:title="inactive ? $t('announcements.inactive_message') : ''"
@click="markAsRead"
>
{{ $t('announcements.mark_as_read_action') }}
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="btn button-default"
@click="enterEditMode"
>
{{ $t('announcements.edit_action') }}
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="btn button-default"
@click="deleteAnnouncement"
>
{{ $t('announcements.delete_action') }}
</button>
</div>
<div
v-else
class="actions"
>
<button
class="btn button-default"
@click="submitEdit"
>
{{ $t('announcements.submit_edit_action') }}
</button>
<button
class="btn button-default"
@click="cancelEdit"
>
{{ $t('announcements.cancel_edit_action') }}
</button>
<div
v-if="editing && editError"
class="alert error"
>
{{ $t('announcements.edit_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<script src="./announcement.js"></script>
<style lang="scss">
@import "../../variables";
.announcement {
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
padding: var(--status-margin, $status-margin);
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}
.footer {
display: flex;
flex-direction: column;
.times {
display: flex;
flex-direction: column;
}
}
.footer .actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
.btn {
flex: 1;
margin: 1em;
max-width: 10em;
}
}
}
</style>

View file

@ -0,0 +1,13 @@
import Checkbox from '../checkbox/checkbox.vue'
const AnnouncementEditor = {
components: {
Checkbox
},
props: {
announcement: Object,
disabled: Boolean
}
}
export default AnnouncementEditor

View file

@ -0,0 +1,60 @@
<template>
<div class="announcement-editor">
<textarea
ref="textarea"
v-model="announcement.content"
class="post-textarea"
rows="1"
cols="1"
:placeholder="$t('announcements.post_placeholder')"
:disabled="disabled"
/>
<span class="announcement-metadata">
<label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
<input
id="announcement-start-time"
v-model="announcement.startsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
<input
id="announcement-end-time"
v-model="announcement.endsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
</span>
<span class="announcement-metadata">
<Checkbox
id="announcement-all-day"
v-model="announcement.allDay"
:disabled="disabled"
/>
<label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
</span>
</div>
</template>
<script src="./announcement_editor.js"></script>
<style lang="scss">
.announcement-editor {
display: flex;
align-items: stretch;
flex-direction: column;
.announcement-metadata {
margin-top: 0.5em;
}
.post-textarea {
resize: vertical;
height: 10em;
overflow: none;
box-sizing: content-box;
}
}
</style>

View file

@ -0,0 +1,55 @@
import { mapState } from 'vuex'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor
},
data () {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false
},
posting: false,
error: undefined
}
},
mounted () {
this.$store.dispatch('fetchAnnouncements')
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
announcements () {
return this.$store.state.announcements.announcements
}
},
methods: {
postAnnouncement () {
this.posting = true
this.$store.dispatch('postAnnouncement', this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch(error => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError () {
this.error = undefined
}
}
}
export default AnnouncementsPage

View file

@ -0,0 +1,79 @@
<template>
<div class="panel panel-default announcements-page">
<div class="panel-heading">
<span>
{{ $t('announcements.page_header') }}
</span>
</div>
<div class="panel-body">
<section
v-if="currentUser && currentUser.role === 'admin'"
>
<div class="post-form">
<div class="heading">
<h4>{{ $t('announcements.post_form_header') }}</h4>
</div>
<div class="body">
<announcement-editor
:announcement="newAnnouncement"
:disabled="posting"
/>
</div>
<div class="footer">
<button
class="btn button-default post-button"
:disabled="posting"
@click.prevent="postAnnouncement"
>
{{ $t('announcements.post_action') }}
</button>
<div
v-if="error"
class="alert error"
>
{{ $t('announcements.post_error', { error }) }}
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
:title="$t('announcements.close_error')"
/>
</button>
</div>
</div>
</div>
</section>
<section
v-for="announcement in announcements"
:key="announcement.id"
>
<announcement
:announcement="announcement"
/>
</section>
</div>
</div>
</template>
<script src="./announcements_page.js"></script>
<style lang="scss">
@import "../../variables";
.announcements-page {
.post-form {
padding: var(--status-margin, $status-margin);
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}
.post-button {
min-width: 10em;
}
}
}
</style>

View file

@ -54,7 +54,7 @@ const MobileNav = {
isChat () {
return this.$route.name === 'chat'
},
...mapGetters(['unreadChatCount']),
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
}

View file

@ -19,7 +19,7 @@
icon="bars"
/>
<div
v-if="unreadChatCount && !chatsPinned"
v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
class="alert-dot"
/>
</button>

View file

@ -18,7 +18,8 @@ import {
faBell,
faInfoCircle,
faStream,
faList
faList,
faBullhorn
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -32,7 +33,8 @@ library.add(
faBell,
faInfoCircle,
faStream,
faList
faList,
faBullhorn
)
const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
@ -86,6 +88,7 @@ const NavPanel = {
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
supportsAnnouncements: state => state.announcements.supportsAnnouncements,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
}),
@ -96,6 +99,7 @@ const NavPanel = {
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
@ -109,13 +113,14 @@ const NavPanel = {
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
)
},
...mapGetters(['unreadChatCount'])
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
}
}

View file

@ -1,4 +1,4 @@
export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser }) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
@ -6,6 +6,7 @@ export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate,
if (!currentUser && !(anon || anonRoute)) return false
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
if (!hasAnnouncements && set.has('announcements')) return false
return true
})
}

View file

@ -71,5 +71,12 @@ export const ROOT_ITEMS = {
anon: true,
icon: 'info-circle',
label: 'nav.about'
},
announcements: {
route: 'announcements',
icon: 'bullhorn',
label: 'nav.announcements',
badgeGetter: 'unreadAnnouncementCount',
criteria: ['announcements']
}
}

View file

@ -69,7 +69,7 @@ const Notifications = {
return this.unseenNotifications.length
},
unseenCountTitle () {
return this.unseenCount + (this.unreadChatCount)
return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount
},
loading () {
return this.$store.state.statuses.notifications.loading
@ -94,7 +94,7 @@ const Notifications = {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
},
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
...mapGetters(['unreadChatCount'])
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
},
mounted () {
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')

View file

@ -95,9 +95,10 @@ const SideDrawer = {
}
},
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
supportsAnnouncements: state => state.announcements.supportsAnnouncements
}),
...mapGetters(['unreadChatCount'])
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
},
methods: {
toggleDrawer () {

View file

@ -191,6 +191,26 @@
/> {{ $t("nav.administration") }}
</a>
</li>
<li
v-if="currentUser && supportsAnnouncements"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'announcements' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bullhorn"
/> {{ $t("nav.announcements") }}
<span
v-if="unreadAnnouncementCount"
class="badge badge-notification"
>
{{ unreadAnnouncementCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"