diff options
| author | Shpuld Shpuldson <shp@cock.li> | 2020-07-07 17:34:35 +0300 |
|---|---|---|
| committer | Shpuld Shpuldson <shp@cock.li> | 2020-07-07 17:34:35 +0300 |
| commit | ddde05771fdda8e07ff757c3469ab981ef2be295 (patch) | |
| tree | 6270c5f68038b8e75b340f6d03c69d4e562847c5 | |
| parent | 77bb31b474f16aa2bbda66e2aa73cf0d20eb934e (diff) | |
| parent | 7206fee4372eba5bb05db9eb90100aee95003962 (diff) | |
update with bookmarks, clean up some code
59 files changed, 978 insertions, 232 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d978d362..9f54352d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Autocomplete domains from list of known instances - 'Bot' settings option and badge - Added profile meta data fields that can be set in profile settings +- Descriptions can be set on uploaded files before posting +- Added status preview option to preview your statuses before posting +- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style ### Changed - Registration page no longer requires email if the server is configured not to require it @@ -26,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Close the media modal on navigation events - Add colons to the emoji alt text, to make them copyable - Add better visual indication for drag-and-drop for files +- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc ### Fixed - Custom Emoji will display in poll options now. @@ -37,7 +41,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Subject field now appears disabled when posting - Fix status ellipsis menu being cut off in notifications column - Fixed autocomplete sometimes not returning the right user when there's already some results +- Videos and audio and misc files show description as alt/title properly now +- Clicking on non-image/video files no longer opens an empty modal +- Audio files can now be played back in the frontend with hidden attachments +- Videos are not cropped awkwardly in the uploads section anymore - Reply filtering options in Settings -> Filtering now work again using filtering on server +- Don't show just blank-screen when cookies are disabled ## [2.0.3] - 2020-05-02 ### Fixed @@ -99,6 +108,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Ability to change user's email - About page - Added remote user redirect +- Bookmarks ### Changed - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes ### Fixed diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f417f33d..241ad331 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -8,8 +8,6 @@ > > --Catbag -Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options. - ## Posting, reading, basic functions. After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..8764f9ab --- /dev/null +++ b/docs/index.md @@ -0,0 +1,8 @@ +# Introduction to Pleroma-FE +## What is Pleroma-FE? + +Pleroma-FE is the default user-facing frontend for Pleroma. It's user interface is modeled after Qvitter which is modeled after an older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options. + +## How can I use it? + +If your instance uses Pleroma-FE, you can acces it by going to your instance (e.g. <https://pleroma.soykaf.com>). You can read more about it's basic functionality in the [Pleroma-FE User Guide](./USER_GUIDE.md). We also have [a guide for administrators](./CONFIGURATION.md) and for [hackers/contributors](./HACKING.md). diff --git a/package.json b/package.json index c0665f6e..96231171 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "cropperjs": "^1.4.3", "diff": "^3.0.1", "escape-html": "^1.0.3", + "parse-link-header": "^1.0.1", "localforage": "^1.5.0", "phoenix": "^1.3.0", "portal-vue": "^2.1.4", @@ -13,6 +13,7 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil import MobileNav from './components/mobile_nav/mobile_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' +import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' import { windowWidth } from './services/window_utils/window_utils' export default { @@ -32,7 +33,8 @@ export default { MobileNav, SettingsModal, UserReportingModal, - PostStatusModal + PostStatusModal, + GlobalNoticeList }, data: () => ({ mobileActivePanel: 'timeline', diff --git a/src/App.scss b/src/App.scss index f2972eda..6597b6f4 100644 --- a/src/App.scss +++ b/src/App.scss @@ -858,6 +858,10 @@ nav { display: block; margin-right: 0.8em; } + + .main { + margin-bottom: 7em; + } } .select-multiple { diff --git a/src/App.vue b/src/App.vue index cad25ba1..6386b3dc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -128,6 +128,7 @@ <PostStatusModal /> <SettingsModal /> <portal-target name="modal" /> + <GlobalNoticeList /> </div> </template> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 1796eb1b..302b278c 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -25,10 +25,10 @@ const preloadFetch = async (request) => { if (!data || !data[request]) { return window.fetch(request) } - const requestData = atob(data[request]) + const requestData = JSON.parse(atob(data[request])) return { ok: true, - json: () => JSON.parse(requestData), + json: () => requestData, text: () => requestData } } @@ -215,7 +215,6 @@ const getAppSecret = async ({ store }) => { const resolveStaffAccounts = ({ store, accounts }) => { const nicknames = accounts.map(uri => uri.split('/').pop()) - nicknames.map(nickname => store.dispatch('fetchUser', nickname)) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) } diff --git a/src/boot/routes.js b/src/boot/routes.js index d98a3b50..f63d8adf 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -2,6 +2,7 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue' import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue' +import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue' import Interactions from 'components/interactions/interactions.vue' import DMs from 'components/dm_timeline/dm_timeline.vue' @@ -40,6 +41,7 @@ export default (store) => { { name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, + { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'remote-user-profile-acct', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index b832e10f..cb31020d 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -8,7 +8,6 @@ const Attachment = { props: [ 'attachment', 'nsfw', - 'statusId', 'size', 'allowPlay', 'setMedia', @@ -30,9 +29,21 @@ const Attachment = { VideoAttachment }, computed: { - usePlaceHolder () { + usePlaceholder () { return this.size === 'hide' || this.type === 'unknown' }, + placeholderName () { + if (this.attachment.description === '' || !this.attachment.description) { + return this.type.toUpperCase() + } + return this.attachment.description + }, + placeholderIconClass () { + if (this.type === 'image') return 'icon-picture' + if (this.type === 'video') return 'icon-video' + if (this.type === 'audio') return 'icon-music' + return 'icon-doc' + }, referrerpolicy () { return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' }, @@ -49,7 +60,15 @@ const Attachment = { return this.size === 'small' }, fullwidth () { - return this.type === 'html' || this.type === 'audio' + if (this.size === 'hide') return false + return this.type === 'html' || this.type === 'audio' || this.type === 'unknown' + }, + useModal () { + const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] + : this.mergedConfig.playVideosInModal + ? ['image', 'video'] + : ['image'] + return modalTypes.includes(this.type) }, ...mapGetters(['mergedConfig']) }, @@ -60,12 +79,7 @@ const Attachment = { } }, openModal (event) { - const modalTypes = this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) || - this.usePlaceHolder - ) { + if (this.useModal) { event.stopPropagation() event.preventDefault() this.setMedia() diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index a7e217c1..be7377e9 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,6 +1,7 @@ <template> <div - v-if="usePlaceHolder" + v-if="usePlaceholder" + :class="{ 'fullwidth': fullwidth }" @click="openModal" > <a @@ -8,8 +9,11 @@ class="placeholder" target="_blank" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" > - [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] + <span :class="placeholderIconClass" /> + <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} </a> </div> <div @@ -22,6 +26,8 @@ v-if="hidden" class="image-attachment" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" @click.prevent="toggleHidden" > <img @@ -51,7 +57,6 @@ :class="{'hidden': hidden && preloadImage }" :href="attachment.url" target="_blank" - :title="attachment.description" @click="openModal" > <StillImage @@ -59,6 +64,7 @@ :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url" :image-load-handler="onImageLoad" + :alt="attachment.description" /> </a> @@ -83,6 +89,8 @@ <audio v-if="type === 'audio'" :src="attachment.url" + :alt="attachment.description" + :title="attachment.description" controls /> @@ -116,22 +124,19 @@ display: flex; flex-wrap: wrap; - .attachment.media-upload-container { - flex: 0 0 auto; - max-height: 200px; + .non-gallery { max-width: 100%; - display: flex; - align-items: center; - video { - max-width: 100%; - } } .placeholder { - margin-right: 8px; - margin-bottom: 4px; + display: inline-block; + padding: 0.3em 1em 0.3em 0; color: $fallback--link; color: var(--postLink, $fallback--link); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; } .nsfw-placeholder { diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js new file mode 100644 index 00000000..64b69e5d --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.js @@ -0,0 +1,17 @@ +import Timeline from '../timeline/timeline.vue' + +const Bookmarks = { + computed: { + timeline () { + return this.$store.state.statuses.timelines.bookmarks + } + }, + components: { + Timeline + }, + destroyed () { + this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) + } +} + +export default Bookmarks diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue new file mode 100644 index 00000000..8da6884b --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.vue @@ -0,0 +1,9 @@ +<template> + <Timeline + :title="$t('nav.bookmarks')" + :timeline="timeline" + :timeline-name="'bookmarks'" + /> +</template> + +<script src="./bookmark_timeline.js"></script> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index e4b19d01..5e0c36bb 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -34,6 +34,16 @@ const ExtraButtons = { 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)) } }, computed: { diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 68db6fd8..7a4e8642 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -41,6 +41,22 @@ <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> </button> <button + v-if="!status.bookmarked" + class="dropdown-item dropdown-item-icon" + @click.prevent="bookmarkStatus" + @click="close" + > + <i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span> + </button> + <button + v-if="status.bookmarked" + class="dropdown-item dropdown-item-icon" + @click.prevent="unbookmarkStatus" + @click="close" + > + <i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span> + </button> + <button v-if="canDelete" class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 1ffa7b3c..ca91c9c1 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -50,9 +50,7 @@ align-content: stretch; } - // FIXME: specificity problem with this and .attachments.attachment - // we shouldn't have the need for .image here - .attachment.image { + .gallery-row-inner .attachment { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js new file mode 100644 index 00000000..3af29c23 --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.js @@ -0,0 +1,15 @@ + +const GlobalNoticeList = { + computed: { + notices () { + return this.$store.state.interface.globalNotices + } + }, + methods: { + closeNotice (notice) { + this.$store.dispatch('removeGlobalNotice', notice) + } + } +} + +export default GlobalNoticeList diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue new file mode 100644 index 00000000..0e4285cc --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.vue @@ -0,0 +1,77 @@ +<template> + <div class="global-notice-list"> + <div + v-for="(notice, index) in notices" + :key="index" + class="alert global-notice" + :class="{ ['global-' + notice.level]: true }" + > + <div class="notice-message"> + {{ $t(notice.messageKey, notice.messageArgs) }} + </div> + <i + class="button-icon icon-cancel" + @click="closeNotice(notice)" + /> + </div> + </div> +</template> + +<script src="./global_notice_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.global-notice-list { + position: fixed; + top: 50px; + width: 100%; + pointer-events: none; + z-index: 1001; + display: flex; + flex-direction: column; + align-items: center; + + .global-notice { + pointer-events: auto; + text-align: center; + width: 40em; + max-width: calc(100% - 3em); + display: flex; + padding-left: 1.5em; + line-height: 2em; + .notice-message { + flex: 1 1 100%; + } + i { + flex: 0 0; + width: 1.5em; + cursor: pointer; + } + } + + .global-error { + background-color: var(--alertPopupError, $fallback--cRed); + color: var(--alertPopupErrorText, $fallback--text); + i { + color: var(--alertPopupErrorText, $fallback--text); + } + } + + .global-warning { + background-color: var(--alertPopupWarning, $fallback--cOrange); + color: var(--alertPopupWarningText, $fallback--text); + i { + color: var(--alertPopupWarningText, $fallback--text); + } + } + + .global-info { + background-color: var(--alertPopupNeutral, $fallback--fg); + color: var(--alertPopupNeutralText, $fallback--text); + i { + color: var(--alertPopupNeutralText, $fallback--text); + } + } +} +</style> diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 80d2a8b9..46931667 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,8 @@ v-if="type === 'image'" class="modal-image" :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" @touchstart.stop="mediaTouchStart" @touchmove.stop="mediaTouchMove" @click="hide" @@ -18,6 +20,14 @@ :attachment="currentMedia" :controls="true" /> + <audio + v-if="type === 'audio'" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + controls + /> <button v-if="canNavigate" :title="$t('media_modal.previous')" diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index f09f43a0..42637a82 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,11 +1,5 @@ import { mapState } from 'vuex' - -const timelineRoutes = [ - 'friends', - 'dms', - 'public-timeline', - 'public-external-timeline' -] +import { timelineNames } from '../timeline_menu/timeline_menu.js' const NavPanel = { created () { @@ -15,7 +9,7 @@ const NavPanel = { }, computed: { onTimelineRoute () { - return timelineRoutes.includes(this.$route.name) + return !!timelineNames()[this.$route.name] }, ...mapState({ currentUser: state => state.users.currentUser, diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 1622eafa..d0e626e6 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -7,7 +7,7 @@ :to="{ name: 'friends' }" :class="onTimelineRoute && 'router-link-active'" > - <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }} + <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} </router-link> </li> <li v-else-if="!privateMode"> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 26ffbab6..d8a327b0 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -27,6 +27,11 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, + created () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + notificationsFetcher.fetchAndUpdate({ store, credentials }) + }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -56,11 +61,6 @@ const Notifications = { components: { Notification }, - created () { - const { dispatch } = this.$store - - dispatch('fetchAndUpdateNotifications') - }, watch: { unseenCount (count) { if (count > 0) { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 9027566f..18f02eba 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -3,9 +3,11 @@ 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 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 } from 'lodash' +import { reject, map, uniqBy, debounce } from 'lodash' import suggestor from '../emoji_input/suggestor.js' import { mapGetters } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' @@ -38,7 +40,9 @@ const PostStatusForm = { EmojiInput, PollForm, ScopeSelector, - Checkbox + Checkbox, + Attachment, + StatusContent }, mounted () { this.resize(this.$refs.textarea) @@ -78,13 +82,16 @@ const PostStatusForm = { nsfw: false, files: [], poll: {}, + mediaDescriptions: {}, visibility: scope, contentType }, caret: 0, pollFormVisible: false, showDropIcon: 'hide', - dropStopTimeout: null + dropStopTimeout: null, + preview: null, + previewLoading: false } }, computed: { @@ -163,18 +170,29 @@ const PostStatusForm = { this.newStatus.poll && this.newStatus.poll.error }, + showPreview () { + return !!this.preview || this.previewLoading + }, + emptyStatus () { + return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 + }, ...mapGetters(['mergedConfig']) }, + watch: { + 'newStatus.contentType': function () { + this.autoPreview() + }, + 'newStatus.spoilerText': function () { + this.autoPreview() + } + }, methods: { - postStatus (newStatus) { + async postStatus (newStatus) { if (this.posting) { return } if (this.submitDisabled) { return } - - if (this.newStatus.status === '') { - if (this.newStatus.files.length === 0) { - this.error = 'Cannot post an empty status with no files' - return - } + if (this.emptyStatus) { + this.error = this.$t('post_status.empty_status_error') + return } const poll = this.pollFormVisible ? this.newStatus.poll : {} @@ -184,7 +202,16 @@ const PostStatusForm = { } this.posting = true - statusPoster.postStatus({ + + try { + await this.setAllMediaDescriptions() + } catch (e) { + this.error = this.$t('post_status.media_description_error') + this.posting = false + return + } + + const data = await statusPoster.postStatus({ status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -194,30 +221,84 @@ const PostStatusForm = { inReplyToStatusId: this.replyTo, contentType: newStatus.contentType, poll + }) + + if (!data.error) { + this.newStatus = { + status: '', + spoilerText: '', + files: [], + visibility: newStatus.visibility, + contentType: newStatus.contentType, + poll: {}, + mediaDescriptions: {} + } + this.pollFormVisible = false + this.$refs.mediaUpload.clearFile() + this.clearPollForm() + this.$emit('posted') + let el = this.$el.querySelector('textarea') + el.style.height = 'auto' + el.style.height = undefined + this.error = null + if (this.preview) this.previewStatus() + } 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.newStatus = { - status: '', - spoilerText: '', - files: [], - visibility: newStatus.visibility, - contentType: newStatus.contentType, - poll: {} - } - this.pollFormVisible = false - this.$refs.mediaUpload.clearFile() - this.clearPollForm() - this.$emit('posted') - let el = this.$el.querySelector('textarea') - el.style.height = 'auto' - el.style.height = undefined - this.error = null + this.preview = data } else { - this.error = data.error + this.preview = { error: data.error } } - this.posting = false + }).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) }, @@ -239,6 +320,7 @@ const PostStatusForm = { return fileTypeService.fileType(fileInfo.mimetype) }, paste (e) { + this.autoPreview() this.resize(e) if (e.clipboardData.files.length > 0) { // prevent pasting of file as text @@ -273,6 +355,7 @@ const PostStatusForm = { } }, onEmojiInputInput (e) { + this.autoPreview() this.$nextTick(() => { this.resize(this.$refs['textarea']) }) @@ -388,6 +471,15 @@ const PostStatusForm = { }, 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))) } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index e3d8d087..626584ed 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -69,6 +69,44 @@ <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 class="preview-heading faint"> + <a + class="preview-toggle faint" + @click.stop.prevent="togglePreview" + > + {{ $t('post_status.preview') }} + <i + class="icon-down-open" + :style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }" + /> + </a> + <i + v-show="previewLoading" + class="icon-spin3 animate-spin" + /> + </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="newStatus.spoilerText || alwaysShowSubject" v-model="newStatus.spoilerText" @@ -77,7 +115,6 @@ class="form-control" > <input - v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" @@ -245,27 +282,18 @@ class="fa button-icon icon-cancel" @click="removeMediaFile(file)" /> - <div class="media-upload-container attachment"> - <img - v-if="type(file) === 'image'" - class="thumbnail media-upload" - :src="file.url" - > - <video - v-if="type(file) === 'video'" - :src="file.url" - controls - /> - <audio - v-if="type(file) === 'audio'" - :src="file.url" - controls - /> - <a - v-if="type(file) === 'unknown'" - :href="file.url" - >{{ file.url }}</a> - </div> + <attachment + :attachment="file" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + size="small" + allow-play="false" + /> + <input + v-model="newStatus.mediaDescriptions[file.id]" + type="text" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > </div> </div> <div @@ -303,14 +331,6 @@ } .post-status-form { - .visibility-tray { - display: flex; - justify-content: space-between; - padding-top: 5px; - } -} - -.post-status-form { .form-bottom { display: flex; justify-content: space-between; @@ -336,6 +356,48 @@ max-width: 10em; } + .preview-heading { + display: flex; + width: 100%; + + .icon-spin3 { + margin-left: auto; + } + } + + .preview-toggle { + display: flex; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .icon-down-open { + transition: transform 0.1s; + } + + .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; + line-height: 1.4em; + } + .text-format { .only-format { color: $fallback--faint; @@ -343,6 +405,12 @@ } } + .visibility-tray { + display: flex; + justify-content: space-between; + padding-top: 5px; + } + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; flex: 1; @@ -381,11 +449,9 @@ } .media-upload-wrapper { - flex: 0 0 auto; - max-width: 100%; - min-width: 50px; margin-right: .2em; margin-bottom: .5em; + width: 18em; .icon-cancel { display: inline-block; @@ -399,6 +465,20 @@ border-bottom-left-radius: 0; border-bottom-right-radius: 0; } + + img, video { + object-fit: contain; + max-height: 10em; + } + + .video { + max-height: 10em; + } + + input { + flex: 1; + width: 100%; + } } .status-input-wrapper { @@ -408,28 +488,13 @@ flex-direction: column; } - .attachments { + .media-upload-wrapper .attachments { padding: 0 0.5em; .attachment { margin: 0; + padding: 0; position: relative; - flex: 0 0 auto; - border: 1px solid $fallback--border; - border: 1px solid var(--border, $fallback--border); - text-align: center; - - audio { - min-width: 300px; - flex: 1 0 auto; - } - - a { - display: block; - text-align: left; - line-height: 1.2; - padding: .5em; - } } i { diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index 833ff89a..0da4d9a8 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -30,7 +30,7 @@ height: 100vh; } - .panel-body { + >.panel-body { height: 100%; overflow-y: hidden; diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index a941143c..6a0a86b4 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -62,6 +62,11 @@ <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} </router-link> </li> + <li @click="toggleDrawer"> + <router-link :to="{ name: 'bookmarks'}"> + <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }} + </router-link> + </li> <li v-if="currentUser.locked" @click="toggleDrawer" diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index 4f98fff6..8665648a 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -2,6 +2,10 @@ import map from 'lodash/map' import BasicUserCard from '../basic_user_card/basic_user_card.vue' const StaffPanel = { + created () { + const nicknames = this.$store.state.instance.staffAccounts + nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname)) + }, components: { BasicUserCard }, diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 7ec29b28..f6b5dd6f 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -197,7 +197,7 @@ > <StatusPopover v-if="!isPreview" - :status-id="status.in_reply_to_status_id" + :status-id="status.parent_visible && status.in_reply_to_status_id" class="reply-to-popover" style="min-width: 0" > @@ -208,7 +208,12 @@ @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > <i class="button-icon icon-reply" /> - <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> + <span + class="faint-link reply-to-text" + :class="{ 'strikethrough': !status.parent_visible }" + > + {{ $t('status.reply_to') }} + </span> </a> </StatusPopover> <span @@ -372,9 +377,6 @@ $status-margin: 0.75em; } .status-el { - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; border-left-width: 0px; min-width: 0; border-color: $fallback--border; @@ -526,6 +528,10 @@ $status-margin: 0.75em; margin: 0 0.4em 0 0.2em; } + .strikethrough { + text-decoration: line-through; + } + .replies-separator { margin-left: 0.4em; } diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index 09ea3a20..67d9bd3c 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -99,15 +99,8 @@ const StatusContent = { file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) }, - hasImageAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'image' - ) - }, - hasVideoAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'video' - ) + attachmentTypes () { + return this.status.attachments.map(file => fileType.fileType(file.mimetype)) }, maxThumbnails () { return this.mergedConfig.maxThumbnails diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 3460c2fa..8068d8d2 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -55,14 +55,22 @@ > {{ $t("status.show_content") }} <span - v-if="hasImageAttachments" + v-if="attachmentTypes.includes('image')" class="icon-picture" /> <span - v-if="hasVideoAttachments" + v-if="attachmentTypes.includes('video')" class="icon-video" /> <span + v-if="attachmentTypes.includes('audio')" + class="icon-music" + /> + <span + v-if="attachmentTypes.includes('unknown')" + class="icon-doc" + /> + <span v-if="status.card" class="icon-link" /> @@ -217,6 +225,9 @@ $status-margin: 0.75em; font-family: var(--postFont, sans-serif); line-height: 1.4em; white-space: pre-wrap; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; blockquote { margin: 0.2em 0 0.2em 2em; diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index 159132a9..51e7680c 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -22,6 +22,10 @@ const StatusPopover = { methods: { enter () { if (!this.status) { + if (!this.statusId) { + this.error = true + return + } this.$store.dispatch('fetchStatus', this.statusId) .then(data => (this.error = false)) .catch(e => (this.error = true)) diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index e48fef47..ab40bbd7 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -4,7 +4,8 @@ const StillImage = { 'referrerpolicy', 'mimetype', 'imageLoadError', - 'imageLoadHandler' + 'imageLoadHandler', + 'alt' ], data () { return { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index f2ddeb7b..2ebf33ba 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -11,6 +11,8 @@ <img ref="src" :key="src" + :alt="alt" + :title="alt" :src="src" :referrerpolicy="referrerpolicy" @load="onLoad" diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 3a7a4268..9cbce819 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -139,7 +139,7 @@ const Timeline = { showImmediately: true, userId: this.userId, tag: this.tag - }).then(statuses => { + }).then(({ statuses }) => { store.commit('setLoading', { timeline: this.timelineName, value: false }) if (statuses && statuses.length === 0) { this.bottomedOut = true diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index d35b7789..02c1318d 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -1,6 +1,18 @@ import Popover from '../popover/popover.vue' import { mapState } from 'vuex' +// Route -> i18n key mapping, exported andnot in the computed +// because nav panel benefits from the same information. +export const timelineNames = () => { + return { + 'friends': 'nav.timeline', + 'bookmarks': 'nav.bookmarks', + 'dms': 'nav.dms', + 'public-timeline': 'nav.public_tl', + 'public-external-timeline': 'nav.twkn' + } +} + const TimelineMenu = { components: { Popover @@ -17,11 +29,14 @@ const TimelineMenu = { }, methods: { openMenu () { - // Tried using $nextTick, but the animation wouldn't - // play, I assume it played too quickly + // $nextTick is too fast, animation won't play back but + // instead starts in fully open position. Low values + // like 1-5 work on fast machines but not on mobile, 25 + // seems like a good compromise that plays without significant + // added lag. setTimeout(() => { this.isOpen = true - }, 1) + }, 25) } }, computed: { @@ -30,13 +45,8 @@ const TimelineMenu = { privateMode: state => state.instance.private, federating: state => state.instance.federating }), - timelineNamesForRoute () { - return { - 'friends': this.$t('nav.timeline'), - 'dms': this.$t('nav.dms'), - 'public-timeline': this.$t('nav.public_tl'), - 'public-external-timeline': this.$t('nav.twkn') - } + timelineNames () { + return timelineNames() } } } diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index 9cb40262..e30bfe34 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -20,6 +20,11 @@ </router-link> </li> <li v-if="currentUser"> + <router-link :to="{ name: 'bookmarks'}"> + <i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }} + </router-link> + </li> + <li v-if="currentUser"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }} </router-link> @@ -40,7 +45,7 @@ slot="trigger" class="title timeline-menu-title" > - <span>{{ timelineNamesForRoute[$route.name] }}</span> + <span>{{ $t(timelineNames[$route.name]) }}</span> <i class="icon-down-open" /> </div> </Popover> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 1db4f648..5685916a 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -10,9 +10,7 @@ :hide-bio="true" rounded="top" /> - <div class="panel-footer"> - <PostStatusForm /> - </div> + <PostStatusForm /> </div> <auth-form v-else diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 97ddf1cd..1ffed4e0 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -4,6 +4,8 @@ :src="attachment.url" :loop="loopVideo" :controls="controls" + :alt="attachment.description" + :title="attachment.description" playsinline @loadeddata="onVideoDataLoad" /> diff --git a/src/i18n/en.json b/src/i18n/en.json index 4329b8f8..254701b4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -120,10 +120,12 @@ "public_tl": "Public Timeline", "timeline": "Timeline", "twkn": "Known Network", + "bookmarks": "Bookmarks", "user_search": "User Search", "search": "Search", "who_to_follow": "Who to follow", - "preferences": "Preferences" + "preferences": "Preferences", + "timelines": "Timelines" }, "notifications": { "broken_favorite": "Unknown status, searching for it…", @@ -163,6 +165,9 @@ "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", @@ -174,6 +179,7 @@ "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", @@ -185,6 +191,10 @@ "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.", "posting": "Posting", + "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", @@ -629,6 +639,8 @@ "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", "replies_list": "Replies:", @@ -724,7 +736,8 @@ "add_reaction": "Add Reaction", "user_settings": "User Settings", "accept_follow_request": "Accept follow request", - "reject_follow_request": "Reject follow request" + "reject_follow_request": "Reject follow request", + "bookmark": "Bookmark" }, "upload": { "error": { diff --git a/src/i18n/fi.json b/src/i18n/fi.json index f8652d19..f3c2e528 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -66,7 +66,7 @@ "search": "Haku" }, "notifications": { - "broken_favorite": "Viestiä ei löydetty...", + "broken_favorite": "Viestiä ei löydetty…", "favorited_you": "tykkäsi viestistäsi", "followed_you": "seuraa sinua", "load_older": "Lataa vanhempia ilmoituksia", @@ -101,7 +101,7 @@ }, "post_status": { "new_status": "Uusi viesti", - "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi", + "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.", "account_not_locked_warning_link": "lukittu", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "content_type": { @@ -288,7 +288,7 @@ "authentication_methods": "Todennus", "warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.", "recovery_codes": "Palautuskoodit.", - "waiting_a_recovery_codes": "Odotetaan palautuskoodeja...", + "waiting_a_recovery_codes": "Odotetaan palautuskoodeja…", "recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.", "scan": { "title": "Skannaa", @@ -575,7 +575,7 @@ "statuses": "Viestit", "hidden": "Piilotettu", "media": "Media", - "block_progress": "Estetään...", + "block_progress": "Estetään…", "admin_menu": { "grant_admin": "Anna Ylläpitöoikeudet", "force_nsfw": "Merkitse kaikki viestit NSFW:nä", @@ -601,10 +601,10 @@ "subscribe": "Tilaa", "unsubscribe": "Poista tilaus", "unblock": "Poista esto", - "unblock_progress": "Postetaan estoa...", + "unblock_progress": "Postetaan estoa…", "unmute": "Poista mykistys", - "unmute_progress": "Poistetaan mykistystä...", - "mute_progress": "Mykistetään...", + "unmute_progress": "Poistetaan mykistystä…", + "mute_progress": "Mykistetään…", "hide_repeats": "Piilota toistot", "show_repeats": "Näytä toistot" }, @@ -674,8 +674,8 @@ "domain_mute_card": { "mute": "Mykistä", "unmute": "Poista mykistys", - "mute_progress": "Mykistetään...", - "unmute_progress": "Poistetaan mykistyst..." + "mute_progress": "Mykistetään…", + "unmute_progress": "Poistetaan mykistyst…" }, "exporter": { "export": "Vie", diff --git a/src/i18n/it.json b/src/i18n/it.json index 7311f0b6..ed78e656 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -34,7 +34,8 @@ "user_search": "Ricerca utenti", "search": "Ricerca", "who_to_follow": "Chi seguire", - "preferences": "Preferenze" + "preferences": "Preferenze", + "bookmarks": "Segnalibri" }, "notifications": { "followed_you": "ti segue", @@ -271,10 +272,59 @@ "shadow_id": "Ombra numero {value}", "override": "Sostituisci", "component": "Componente", - "_tab_label": "Luci ed ombre" + "_tab_label": "Luci ed ombre", + "components": { + "avatarStatus": "Icona utente (vista messaggio)", + "avatar": "Icona utente (vista profilo)", + "topBar": "Barra superiore", + "panelHeader": "Intestazione pannello", + "panel": "Pannello", + "input": "Campo d'immissione", + "buttonPressedHover": "Pulsante (puntato e premuto)", + "buttonPressed": "Pulsante (premuto)", + "buttonHover": "Pulsante (puntato)", + "button": "Pulsante", + "popup": "Sbalzi e suggerimenti" + }, + "filter_hint": { + "inset_classic": "Le ombre incluse usano {0}", + "spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre", + "avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.", + "drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.", + "always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta." + }, + "hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore." }, "radii": { "_tab_label": "Raggio" + }, + "fonts": { + "_tab_label": "Font", + "custom": "Personalizzato", + "weight": "Peso (grassettatura)", + "size": "Dimensione (in pixel)", + "family": "Nome font", + "components": { + "postCode": "Font a spaziatura fissa incluso in un messaggio", + "post": "Testo del messaggio", + "input": "Campi d'immissione", + "interface": "Interfaccia" + }, + "help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema." + }, + "preview": { + "link": "un bel collegamentino", + "checkbox": "Ho dato uno sguardo a termini e condizioni", + "header_faint": "Tutto bene", + "fine_print": "Leggi il nostro {0} per imparare un bel niente!", + "faint_link": "utilissimo manuale", + "input": "Sono appena atterrato a Fiumicino.", + "mono": "contenuto", + "text": "Altro {0} e {1}", + "content": "Contenuto", + "button": "Pulsante", + "error": "Errore d'esempio", + "header": "Anteprima" } }, "enable_web_push_notifications": "Abilita notifiche web push", @@ -336,7 +386,19 @@ "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.", - "mutes_and_blocks": "Zittiti e bloccati" + "mutes_and_blocks": "Zittiti e bloccati", + "profile_fields": { + "value": "Contenuto", + "name": "Etichetta", + "add_field": "Aggiungi campo", + "label": "Metadati profilo" + }, + "bot": "Questo profilo è di un robot", + "version": { + "frontend_version": "Versione interfaccia", + "backend_version": "Versione backend", + "title": "Versione" + } }, "timeline": { "error_fetching": "Errore nell'aggiornamento", @@ -346,7 +408,10 @@ "collapse": "Riduci", "conversation": "Conversazione", "no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso", - "repeated": "condiviso" + "repeated": "condiviso", + "no_statuses": "Nessun messaggio", + "no_more_statuses": "Fine dei messaggi", + "reload": "Ricarica" }, "user_card": { "follow": "Segui", @@ -425,7 +490,10 @@ }, "direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.", "direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.", - "new_status": "Nuovo messaggio" + "new_status": "Nuovo messaggio", + "empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati", + "preview_empty": "Vuoto", + "preview": "Anteprima" }, "registration": { "bio": "Introduzione", @@ -548,5 +616,50 @@ "error": "Non trovato.", "searching_for": "Cerco", "remote_user_resolver": "Cerca utenti remoti" + }, + "errors": { + "storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie." + }, + "status": { + "pinned": "Intestato", + "unpin": "De-intesta", + "pin": "Intesta al profilo", + "delete": "Elimina messaggio", + "repeats": "Condivisi", + "favorites": "Preferiti" + }, + "time": { + "years_short": "{0}a", + "year_short": "{0}a", + "years": "{0} anni", + "year": "{0} anno", + "weeks_short": "{0}set", + "week_short": "{0}set", + "seconds_short": "{0}sec", + "second_short": "{0}sec", + "weeks": "{0} settimane", + "week": "{0} settimana", + "seconds": "{0} secondi", + "second": "{0} secondo", + "now_short": "ora", + "now": "adesso", + "months_short": "{0}me", + "month_short": "{0}me", + "months": "{0} mesi", + "month": "{0} mese", + "minutes_short": "{0}min", + "minute_short": "{0}min", + "minutes": "{0} minuti", + "minute": "{0} minuto", + "in_past": "{0} fa", + "in_future": "fra {0}", + "hours_short": "{0}h", + "days_short": "{0}g", + "hour_short": "{0}h", + "hours": "{0} ore", + "hour": "{0} ora", + "day_short": "{0}g", + "days": "{0} giorni", + "day": "{0} giorno" } } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index aa78db26..08f05d18 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -45,7 +45,8 @@ "timeline": "Лента", "twkn": "Федеративная лента", "search": "Поиск", - "friend_requests": "Запросы на чтение" + "friend_requests": "Запросы на чтение", + "bookmarks": "Закладки" }, "notifications": { "broken_favorite": "Неизвестный статус, ищем...", @@ -366,6 +367,10 @@ "show_new": "Показать новые", "up_to_date": "Обновлено" }, + "status": { + "bookmark": "В закладки", + "unbookmark": "Удалить из закладок" + }, "user_card": { "block": "Заблокировать", "blocked": "Заблокирован", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index f95dc498..7e620bdf 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -85,7 +85,7 @@ "administration": "管理员" }, "notifications": { - "broken_favorite": "未知的状态,正在搜索中...", + "broken_favorite": "未知的状态,正在搜索中…", "favorited_you": "收藏了你的状态", "followed_you": "关注了你", "load_older": "加载更早的通知", @@ -185,7 +185,7 @@ "generate_new_recovery_codes": "生成新的恢复码", "warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。", "recovery_codes": "恢复码。", - "waiting_a_recovery_codes": "正在接收备份码……", + "waiting_a_recovery_codes": "正在接收备份码…", "recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app,也丢失了你的恢复码,你的账号就再也无法登录了。", "authentication_methods": "身份验证方法", "scan": { @@ -564,11 +564,11 @@ "subscribe": "订阅", "unsubscribe": "退订", "unblock": "取消拉黑", - "unblock_progress": "取消拉黑中...", - "block_progress": "拉黑中...", + "unblock_progress": "取消拉黑中…", + "block_progress": "拉黑中…", "unmute": "取消隐藏", - "unmute_progress": "取消隐藏中...", - "mute_progress": "隐藏中...", + "unmute_progress": "取消隐藏中…", + "mute_progress": "隐藏中…", "admin_menu": { "moderation": "权限", "grant_admin": "赋予管理权限", @@ -690,9 +690,9 @@ } }, "domain_mute_card": { - "unmute_progress": "正在取消隐藏……", + "unmute_progress": "正在取消隐藏…", "unmute": "取消隐藏", - "mute_progress": "隐藏中……", + "mute_progress": "隐藏中…", "mute": "隐藏" } } diff --git a/src/main.js b/src/main.js index 9a201e4f..5bddc76e 100644 --- a/src/main.js +++ b/src/main.js @@ -62,7 +62,15 @@ const persistedStateOptions = { }; (async () => { - const persistedState = await createPersistedState(persistedStateOptions) + let storageError = false + const plugins = [pushNotifications] + try { + const persistedState = await createPersistedState(persistedStateOptions) + plugins.push(persistedState) + } catch (e) { + console.error(e) + storageError = true + } const store = new Vuex.Store({ modules: { i18n: { @@ -85,11 +93,13 @@ const persistedStateOptions = { polls: pollsModule, postStatus: postStatusModule }, - plugins: [persistedState, pushNotifications], + 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 }) })() diff --git a/src/modules/api.js b/src/modules/api.js index 748570e5..04ef6ab4 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -138,9 +138,6 @@ const api = { if (!fetcher) return store.commit('removeFetcher', { fetcherName: 'notifications', fetcher }) }, - fetchAndUpdateNotifications (store) { - store.state.backendInteractor.fetchAndUpdateNotifications({ store }) - }, // Follow requests startFetchingFollowRequests (store) { diff --git a/src/modules/interface.js b/src/modules/interface.js index eeebd65e..e31630fc 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -14,7 +14,8 @@ const defaultState = { window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') ) }, - mobileLayout: false + mobileLayout: false, + globalNotices: [] } const interfaceMod = { @@ -58,6 +59,12 @@ const interfaceMod = { if (!state.settingsModalLoaded) { state.settingsModalLoaded = true } + }, + pushGlobalNotice (state, notice) { + state.globalNotices.push(notice) + }, + removeGlobalNotice (state, notice) { + state.globalNotices = state.globalNotices.filter(n => n !== notice) } }, actions: { @@ -81,6 +88,28 @@ const interfaceMod = { }, togglePeekSettingsModal ({ commit }) { commit('togglePeekSettingsModal') + }, + pushGlobalNotice ( + { commit, dispatch }, + { + messageKey, + messageArgs = {}, + level = 'error', + timeout = 0 + }) { + const notice = { + messageKey, + messageArgs, + level + } + if (timeout) { + setTimeout(() => dispatch('removeGlobalNotice', notice), timeout) + } + commit('pushGlobalNotice', notice) + return notice + }, + removeGlobalNotice ({ commit }, notice) { + commit('removeGlobalNotice', notice) } } } diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js index a24b408d..721c25e6 100644 --- a/src/modules/media_viewer.js +++ b/src/modules/media_viewer.js @@ -22,7 +22,7 @@ const mediaViewer = { setMedia ({ commit }, attachments) { const media = attachments.filter(attachment => { const type = fileTypeService.fileType(attachment.mimetype) - return type === 'image' || type === 'video' + return type === 'image' || type === 'video' || type === 'audio' }) commit('setMedia', media) }, diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 4d3f8031..7fbf685c 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -62,7 +62,8 @@ export const defaultState = () => ({ publicAndExternal: emptyTl(), friends: emptyTl(), tag: emptyTl(), - dms: emptyTl() + dms: emptyTl(), + bookmarks: emptyTl() } }) @@ -163,8 +164,7 @@ const removeStatusFromGlobalStorage = (state, status) => { } } -const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, - noIdUpdate = false, userId }) => { +const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => { // Sanity check if (!isArray(statuses)) { return false @@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const allStatuses = state.allStatuses const timelineObject = state.timelines[timeline] - const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 - const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 + // 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 @@ -315,7 +320,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }) // Keep the visible statuses sorted - if (timeline) { + if (timeline && !(timeline === 'bookmarks')) { sortTimeline(timelineObject) } } @@ -463,6 +468,14 @@ export const mutations = { 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] newStatus.deleted = true @@ -590,8 +603,8 @@ export const mutations = { const statuses = { state: defaultState(), actions: { - addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { - commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) + 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 ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) @@ -666,6 +679,20 @@ const statuses = { 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 }) }, diff --git a/src/modules/users.js b/src/modules/users.js index 68d02931..7e136c61 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -266,6 +266,11 @@ const users = { mutations, getters, actions: { + fetchUserIfMissing (store, id) { + if (!store.getters.findUser(id)) { + store.dispatch('fetchUser', id) + } + }, fetchUser (store, id) { return store.rootState.api.backendInteractor.fetchUser({ id }) .then((user) => { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 7e5e9645..ad543c6c 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` 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` @@ -58,6 +59,8 @@ 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` @@ -510,7 +513,8 @@ const fetchTimeline = ({ user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, - tag: MASTODON_TAG_TIMELINE_URL + tag: MASTODON_TAG_TIMELINE_URL, + bookmarks: MASTODON_BOOKMARK_TIMELINE_URL } const isNotifications = timeline === 'notifications' const params = [] @@ -539,7 +543,7 @@ const fetchTimeline = ({ if (timeline === 'public' || timeline === 'publicAndExternal') { params.push(['only_media', false]) } - if (timeline !== 'favorites') { + if (timeline !== 'favorites' && timeline !== 'bookmarks') { params.push(['with_muted', withMuted]) } if (replyVisibility !== 'all') { @@ -552,16 +556,20 @@ const fetchTimeline = ({ 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.error) { - return data.map(isNotifications ? parseNotification : parseStatus) + return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination } } else { data.status = status data.statusText = statusText @@ -612,6 +620,22 @@ const unretweet = ({ id, 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, @@ -621,7 +645,8 @@ const postStatus = ({ poll, mediaIds = [], inReplyToStatusId, - contentType + contentType, + preview }) => { const form = new FormData() const pollOptions = poll.options || [] @@ -651,6 +676,9 @@ const postStatus = ({ if (inReplyToStatusId) { form.append('in_reply_to_id', inReplyToStatusId) } + if (preview) { + form.append('preview', 'true') + } return fetch(MASTODON_POST_STATUS_URL, { body: form, @@ -658,13 +686,7 @@ const postStatus = ({ headers: authHeaders(credentials) }) .then((response) => { - if (response.ok) { - return response.json() - } else { - return { - error: response - } - } + return response.json() }) .then((data) => data.error ? data : parseStatus(data)) } @@ -686,6 +708,17 @@ const uploadMedia = ({ formData, credentials }) => { .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 importBlocks = ({ file, credentials }) => { const formData = new FormData() formData.append('list', file) @@ -1150,9 +1183,12 @@ const apiService = { unfavorite, retweet, unretweet, + bookmarkStatus, + unbookmarkStatus, postStatus, deleteStatus, uploadMedia, + setMediaDescription, fetchMutes, muteUser, unmuteUser, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index e1c32860..45e6bd0e 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({ return notificationsFetcher.startFetching({ store, credentials }) }, - fetchAndUpdateNotifications ({ store }) { - return notificationsFetcher.fetchAndUpdate({ store, credentials }) - }, - startFetchingFollowRequests ({ store }) { return followRequestFetcher.startFetching({ store, credentials }) }, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 3bdb92f3..ec83c02a 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -1,4 +1,5 @@ import escape from 'escape-html' +import parseLinkHeader from 'parse-link-header' import { isStatusNotification } from '../notification_utils/notification_utils.js' const qvitterStatusType = (status) => { @@ -232,6 +233,8 @@ export const parseStatus = (data) => { output.repeated = data.reblogged output.repeat_num = data.reblogs_count + output.bookmarked = data.bookmarked + output.type = data.reblog ? 'retweet' : 'status' output.nsfw = data.sensitive @@ -248,6 +251,7 @@ export const parseStatus = (data) => { 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 @@ -381,3 +385,16 @@ 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) + } +} diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index 64499a1b..d282074a 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => { } const result = fetchNotifications({ store, args, older }) - // load unread notifications repeatedly to provide consistency between browser tabs + // If there's any unread notifications, try fetch notifications since + // the newest read notification to check if any of the unread notifs + // have changed their 'seen' state (marked as read in another session), so + // we can update the state in this session to mark them as read as well. + // The normal maxId-check does not tell if older notifications have changed const notifications = timelineData.data const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) - if (readNotifsIds.length) { + const numUnseenNotifs = notifications.length - readNotifsIds.length + if (numUnseenNotifs > 0) { args['since'] = Math.max(...readNotifsIds) fetchNotifications({ store, args, older }) } - return result } } const fetchNotifications = ({ store, args, older }) => { return apiService.fetchTimeline(args) - .then((notifications) => { + .then(({ data: notifications }) => { update({ store, notifications, older }) return notifications }, () => store.dispatch('setNotificationsError', { value: true })) diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 9e904d3a..ac469175 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -1,7 +1,18 @@ 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' }) => { +const postStatus = ({ + store, + status, + spoilerText, + visibility, + sensitive, + poll, + media = [], + inReplyToStatusId = undefined, + contentType = 'text/plain', + preview = false +}) => { const mediaIds = map(media, 'id') return apiService.postStatus({ @@ -13,9 +24,11 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m mediaIds, inReplyToStatusId, contentType, - poll }) + poll, + preview + }) .then((data) => { - if (!data.error) { + if (!data.error && !preview) { store.dispatch('addNewStatuses', { statuses: [data], timeline: 'friends', @@ -34,13 +47,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m 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, - uploadMedia + uploadMedia, + setMediaDescription } export default statusPosterService diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index b577cfab..6b25cd6f 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = { alert: 0.5, input: 0.5, faint: 0.5, - underlay: 0.15 + underlay: 0.15, + alertPopup: 0.95 } /** SUBJECT TO CHANGE IN THE FUTURE, this is all beta @@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = { textColor: true }, + alertPopupError: { + depends: ['alertError'], + opacity: 'alertPopup' + }, + alertPopupErrorText: { + depends: ['alertErrorText'], + layer: 'popover', + variant: 'alertPopupError', + textColor: true + }, + + alertPopupWarning: { + depends: ['alertWarning'], + opacity: 'alertPopup' + }, + alertPopupWarningText: { + depends: ['alertWarningText'], + layer: 'popover', + variant: 'alertPopupWarning', + textColor: true + }, + + alertPopupNeutral: { + depends: ['alertNeutral'], + opacity: 'alertPopup' + }, + alertPopupNeutralText: { + depends: ['alertNeutralText'], + layer: 'popover', + variant: 'alertPopupNeutral', + textColor: true + }, + badgeNotification: '--cRed', badgeNotificationText: { depends: ['text', 'badgeNotification'], diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 30fb26bd..214294eb 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -2,7 +2,7 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' -const update = ({ store, statuses, timeline, showImmediately, userId }) => { +const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { const ccTimeline = camelCase(timeline) store.dispatch('setError', { value: false }) @@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => { timeline: ccTimeline, userId, statuses, - showImmediately + showImmediately, + pagination }) } @@ -47,16 +48,18 @@ const fetchAndUpdate = ({ const numStatusesBeforeFetch = timelineData.statuses.length return apiService.fetchTimeline(args) - .then((statuses) => { - if (statuses.error) { - store.dispatch('setErrorData', { value: statuses }) + .then(response => { + if (response.error) { + store.dispatch('setErrorData', { value: response }) return } + + const { data: statuses, pagination } = response if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) } - update({ store, statuses, timeline, showImmediately, userId }) - return statuses + update({ store, statuses, timeline, showImmediately, userId, pagination }) + return { statuses, pagination } }, () => store.dispatch('setError', { value: true })) } diff --git a/static/fontello.json b/static/fontello.json index ac3f0a18..5ef8544e 100755..100644 --- a/static/fontello.json +++ b/static/fontello.json @@ -375,6 +375,30 @@ "css": "download", "code": 59429, "src": "fontawesome" + }, + { + "uid": "f04a5d24e9e659145b966739c4fde82a", + "css": "bookmark", + "code": 59430, + "src": "fontawesome" + }, + { + "uid": "2f5ef6f6b7aaebc56458ab4e865beff5", + "css": "bookmark-empty", + "code": 61591, + "src": "fontawesome" + }, + { + "uid": "9ea0a737ccc45d6c510dcbae56058849", + "css": "music", + "code": 59432, + "src": "fontawesome" + }, + { + "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", + "css": "doc", + "code": 59433, + "src": "fontawesome" } ] }
\ No newline at end of file diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index ccb57942..e1f7a958 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -1,4 +1,4 @@ -import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import mastoapidata from '../../../../fixtures/mastoapi.json' import qvitterapidata from '../../../../fixtures/statuses.json' @@ -383,4 +383,24 @@ describe('API Entities normalizer', () => { expect(result).to.include('title=\':[a-z] {|}*:\'') }) }) + + describe('Link header pagination', () => { + it('Parses min and max ids as integers', () => { + const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader) + expect(result).to.eql({ + 'maxId': 861676, + 'minId': 861741 + }) + }) + + it('Parses min and max ids as flakes', () => { + const linkHeader = '<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader, { flakeId: true }) + expect(result).to.eql({ + 'maxId': '9waQx5IIS48qVue2Ai', + 'minId': '9wi61nIPnfn674xgie' + }) + }) + }) }) @@ -5751,6 +5751,13 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse-link-header@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7" + integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc= + dependencies: + xtend "~4.0.1" + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" |
