aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md10
-rw-r--r--docs/USER_GUIDE.md2
-rw-r--r--docs/index.md8
-rw-r--r--package.json1
-rw-r--r--src/App.js4
-rw-r--r--src/App.scss4
-rw-r--r--src/App.vue1
-rw-r--r--src/boot/after_store.js5
-rw-r--r--src/boot/routes.js2
-rw-r--r--src/components/attachment/attachment.js32
-rw-r--r--src/components/attachment/attachment.vue31
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.js17
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.vue9
-rw-r--r--src/components/extra_buttons/extra_buttons.js10
-rw-r--r--src/components/extra_buttons/extra_buttons.vue16
-rw-r--r--src/components/gallery/gallery.vue4
-rw-r--r--src/components/global_notice_list/global_notice_list.js15
-rw-r--r--src/components/global_notice_list/global_notice_list.vue77
-rw-r--r--src/components/media_modal/media_modal.vue10
-rw-r--r--src/components/nav_panel/nav_panel.js10
-rw-r--r--src/components/nav_panel/nav_panel.vue2
-rw-r--r--src/components/notifications/notifications.js10
-rw-r--r--src/components/post_status_form/post_status_form.js150
-rw-r--r--src/components/post_status_form/post_status_form.vue165
-rw-r--r--src/components/settings_modal/settings_modal.scss2
-rw-r--r--src/components/side_drawer/side_drawer.vue5
-rw-r--r--src/components/staff_panel/staff_panel.js4
-rw-r--r--src/components/status/status.vue16
-rw-r--r--src/components/status_content/status_content.js11
-rw-r--r--src/components/status_content/status_content.vue15
-rw-r--r--src/components/status_popover/status_popover.js4
-rw-r--r--src/components/still-image/still-image.js3
-rw-r--r--src/components/still-image/still-image.vue2
-rw-r--r--src/components/timeline/timeline.js2
-rw-r--r--src/components/timeline_menu/timeline_menu.js30
-rw-r--r--src/components/timeline_menu/timeline_menu.vue7
-rw-r--r--src/components/user_panel/user_panel.vue4
-rw-r--r--src/components/video_attachment/video_attachment.vue2
-rw-r--r--src/i18n/en.json17
-rw-r--r--src/i18n/fi.json18
-rw-r--r--src/i18n/it.json123
-rw-r--r--src/i18n/ru.json7
-rw-r--r--src/i18n/zh.json16
-rw-r--r--src/main.js16
-rw-r--r--src/modules/api.js3
-rw-r--r--src/modules/interface.js31
-rw-r--r--src/modules/media_viewer.js2
-rw-r--r--src/modules/statuses.js43
-rw-r--r--src/modules/users.js5
-rw-r--r--src/services/api/api.service.js60
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js4
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js17
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js12
-rw-r--r--src/services/status_poster/status_poster.service.js28
-rw-r--r--src/services/theme_data/pleromafe.js36
-rw-r--r--src/services/timeline_fetcher/timeline_fetcher.service.js17
-rw-r--r--[-rwxr-xr-x]static/fontello.json24
-rw-r--r--test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js22
-rw-r--r--yarn.lock7
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",
diff --git a/src/App.js b/src/App.js
index 040138c9..92c4e2f5 100644
--- a/src/App.js
+++ b/src/App.js
@@ -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'
+ })
+ })
+ })
})
diff --git a/yarn.lock b/yarn.lock
index f05b00b1..09316863 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"