update with bookmarks, clean up some code

This commit is contained in:
Shpuld Shpuldson 2020-07-07 17:34:35 +03:00
commit ddde05771f
59 changed files with 982 additions and 236 deletions

View file

@ -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 - Autocomplete domains from list of known instances
- 'Bot' settings option and badge - 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings - 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 ### Changed
- Registration page no longer requires email if the server is configured not to require it - 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 - Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable - Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files - 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 ### Fixed
- Custom Emoji will display in poll options now. - 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 - Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column - Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results - 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 - 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 ## [2.0.3] - 2020-05-02
### Fixed ### 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 - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### 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 - 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 ### Fixed

View file

@ -8,8 +8,6 @@
> >
> --Catbag > --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. ## 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. 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.

8
docs/index.md Normal file
View file

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

View file

@ -22,6 +22,7 @@
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"parse-link-header": "^1.0.1",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",

View file

@ -13,6 +13,7 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth } from './services/window_utils/window_utils' import { windowWidth } from './services/window_utils/window_utils'
export default { export default {
@ -32,7 +33,8 @@ export default {
MobileNav, MobileNav,
SettingsModal, SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal,
GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',

View file

@ -858,6 +858,10 @@ nav {
display: block; display: block;
margin-right: 0.8em; margin-right: 0.8em;
} }
.main {
margin-bottom: 7em;
}
} }
.select-multiple { .select-multiple {

View file

@ -128,6 +128,7 @@
<PostStatusModal /> <PostStatusModal />
<SettingsModal /> <SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
<GlobalNoticeList />
</div> </div>
</template> </template>

View file

@ -25,10 +25,10 @@ const preloadFetch = async (request) => {
if (!data || !data[request]) { if (!data || !data[request]) {
return window.fetch(request) return window.fetch(request)
} }
const requestData = atob(data[request]) const requestData = JSON.parse(atob(data[request]))
return { return {
ok: true, ok: true,
json: () => JSON.parse(requestData), json: () => requestData,
text: () => requestData text: () => requestData
} }
} }
@ -215,7 +215,6 @@ const getAppSecret = async ({ store }) => {
const resolveStaffAccounts = ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop()) const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
} }

View file

@ -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 PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_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 ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.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: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { 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: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct', { name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',

View file

@ -8,7 +8,6 @@ const Attachment = {
props: [ props: [
'attachment', 'attachment',
'nsfw', 'nsfw',
'statusId',
'size', 'size',
'allowPlay', 'allowPlay',
'setMedia', 'setMedia',
@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment VideoAttachment
}, },
computed: { computed: {
usePlaceHolder () { usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown' 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 () { referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
}, },
@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small' return this.size === 'small'
}, },
fullwidth () { 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']) ...mapGetters(['mergedConfig'])
}, },
@ -60,12 +79,7 @@ const Attachment = {
} }
}, },
openModal (event) { openModal (event) {
const modalTypes = this.mergedConfig.playVideosInModal if (this.useModal) {
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
this.usePlaceHolder
) {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
this.setMedia() this.setMedia()

View file

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="usePlaceHolder" v-if="usePlaceholder"
:class="{ 'fullwidth': fullwidth }"
@click="openModal" @click="openModal"
> >
<a <a
@ -8,8 +9,11 @@
class="placeholder" class="placeholder"
target="_blank" target="_blank"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
> >
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] <span :class="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a> </a>
</div> </div>
<div <div
@ -22,6 +26,8 @@
v-if="hidden" v-if="hidden"
class="image-attachment" class="image-attachment"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
<img <img
@ -51,7 +57,6 @@
:class="{'hidden': hidden && preloadImage }" :class="{'hidden': hidden && preloadImage }"
:href="attachment.url" :href="attachment.url"
target="_blank" target="_blank"
:title="attachment.description"
@click="openModal" @click="openModal"
> >
<StillImage <StillImage
@ -59,6 +64,7 @@
:mimetype="attachment.mimetype" :mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url" :src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad" :image-load-handler="onImageLoad"
:alt="attachment.description"
/> />
</a> </a>
@ -83,6 +89,8 @@
<audio <audio
v-if="type === 'audio'" v-if="type === 'audio'"
:src="attachment.url" :src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls controls
/> />
@ -116,22 +124,19 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.attachment.media-upload-container { .non-gallery {
flex: 0 0 auto;
max-height: 200px;
max-width: 100%; max-width: 100%;
display: flex;
align-items: center;
video {
max-width: 100%;
}
} }
.placeholder { .placeholder {
margin-right: 8px; display: inline-block;
margin-bottom: 4px; padding: 0.3em 1em 0.3em 0;
color: $fallback--link; color: $fallback--link;
color: var(--postLink, $fallback--link); color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
} }
.nsfw-placeholder { .nsfw-placeholder {

View file

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

View file

@ -0,0 +1,9 @@
<template>
<Timeline
:title="$t('nav.bookmarks')"
:timeline="timeline"
:timeline-name="'bookmarks'"
/>
</template>
<script src="./bookmark_timeline.js"></script>

View file

@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink) navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .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: { computed: {

View file

@ -40,6 +40,22 @@
> >
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button> </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 <button
v-if="canDelete" v-if="canDelete"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"

View file

@ -50,9 +50,7 @@
align-content: stretch; align-content: stretch;
} }
// FIXME: specificity problem with this and .attachments.attachment .gallery-row-inner .attachment {
// we shouldn't have the need for .image here
.attachment.image {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;

View file

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

View file

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

View file

@ -8,6 +8,8 @@
v-if="type === 'image'" v-if="type === 'image'"
class="modal-image" class="modal-image"
:src="currentMedia.url" :src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@touchstart.stop="mediaTouchStart" @touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove" @touchmove.stop="mediaTouchMove"
@click="hide" @click="hide"
@ -18,6 +20,14 @@
:attachment="currentMedia" :attachment="currentMedia"
:controls="true" :controls="true"
/> />
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<button <button
v-if="canNavigate" v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"

View file

@ -1,11 +1,5 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import { timelineNames } from '../timeline_menu/timeline_menu.js'
const timelineRoutes = [
'friends',
'dms',
'public-timeline',
'public-external-timeline'
]
const NavPanel = { const NavPanel = {
created () { created () {
@ -15,7 +9,7 @@ const NavPanel = {
}, },
computed: { computed: {
onTimelineRoute () { onTimelineRoute () {
return timelineRoutes.includes(this.$route.name) return !!timelineNames()[this.$route.name]
}, },
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,

View file

@ -7,7 +7,7 @@
:to="{ name: 'friends' }" :to="{ name: 'friends' }"
:class="onTimelineRoute && 'router-link-active'" :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> </router-link>
</li> </li>
<li v-else-if="!privateMode"> <li v-else-if="!privateMode">

View file

@ -27,6 +27,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: { computed: {
mainClass () { mainClass () {
return this.minimalMode ? '' : 'panel panel-default' return this.minimalMode ? '' : 'panel panel-default'
@ -56,11 +61,6 @@ const Notifications = {
components: { components: {
Notification Notification
}, },
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: { watch: {
unseenCount (count) { unseenCount (count) {
if (count > 0) { if (count > 0) {

View file

@ -3,9 +3,11 @@ import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.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 fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.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 suggestor from '../emoji_input/suggestor.js'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
@ -38,7 +40,9 @@ const PostStatusForm = {
EmojiInput, EmojiInput,
PollForm, PollForm,
ScopeSelector, ScopeSelector,
Checkbox Checkbox,
Attachment,
StatusContent
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
@ -78,13 +82,16 @@ const PostStatusForm = {
nsfw: false, nsfw: false,
files: [], files: [],
poll: {}, poll: {},
mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
dropStopTimeout: null dropStopTimeout: null,
preview: null,
previewLoading: false
} }
}, },
computed: { computed: {
@ -163,18 +170,29 @@ const PostStatusForm = {
this.newStatus.poll && this.newStatus.poll &&
this.newStatus.poll.error this.newStatus.poll.error
}, },
showPreview () {
return !!this.preview || this.previewLoading
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
watch: {
'newStatus.contentType': function () {
this.autoPreview()
},
'newStatus.spoilerText': function () {
this.autoPreview()
}
},
methods: { methods: {
postStatus (newStatus) { async postStatus (newStatus) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.submitDisabled) { return }
if (this.emptyStatus) {
if (this.newStatus.status === '') { this.error = this.$t('post_status.empty_status_error')
if (this.newStatus.files.length === 0) { return
this.error = 'Cannot post an empty status with no files'
return
}
} }
const poll = this.pollFormVisible ? this.newStatus.poll : {} const poll = this.pollFormVisible ? this.newStatus.poll : {}
@ -184,7 +202,16 @@ const PostStatusForm = {
} }
this.posting = true 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, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility, visibility: newStatus.visibility,
@ -194,29 +221,83 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll poll
}).then((data) => {
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
} else {
this.error = data.error
}
this.posting = false
}) })
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.preview = data
} else {
this.preview = { error: data.error }
}
}).catch((error) => {
this.preview = { error }
}).finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
autoPreview () {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview () {
this.preview = null
this.previewLoading = false
},
togglePreview () {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
}, },
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
@ -239,6 +320,7 @@ const PostStatusForm = {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
}, },
paste (e) { paste (e) {
this.autoPreview()
this.resize(e) this.resize(e)
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text // prevent pasting of file as text
@ -273,6 +355,7 @@ const PostStatusForm = {
} }
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.autoPreview()
this.$nextTick(() => { this.$nextTick(() => {
this.resize(this.$refs['textarea']) this.resize(this.$refs['textarea'])
}) })
@ -388,6 +471,15 @@ const PostStatusForm = {
}, },
dismissScopeNotice () { dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) 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)))
} }
} }
} }

View file

@ -69,6 +69,44 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<div 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 <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
@ -77,7 +115,6 @@
class="form-control" class="form-control"
> >
<input <input
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
@ -245,27 +282,18 @@
class="fa button-icon icon-cancel" class="fa button-icon icon-cancel"
@click="removeMediaFile(file)" @click="removeMediaFile(file)"
/> />
<div class="media-upload-container attachment"> <attachment
<img :attachment="file"
v-if="type(file) === 'image'" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
class="thumbnail media-upload" size="small"
:src="file.url" allow-play="false"
> />
<video <input
v-if="type(file) === 'video'" v-model="newStatus.mediaDescriptions[file.id]"
:src="file.url" type="text"
controls :placeholder="$t('post_status.media_description')"
/> @keydown.enter.prevent=""
<audio >
v-if="type(file) === 'audio'"
:src="file.url"
controls
/>
<a
v-if="type(file) === 'unknown'"
:href="file.url"
>{{ file.url }}</a>
</div>
</div> </div>
</div> </div>
<div <div
@ -302,14 +330,6 @@
} }
} }
.post-status-form {
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
}
.post-status-form { .post-status-form {
.form-bottom { .form-bottom {
display: flex; display: flex;
@ -336,6 +356,48 @@
max-width: 10em; 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 { .text-format {
.only-format { .only-format {
color: $fallback--faint; 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 { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px; font-size: 26px;
flex: 1; flex: 1;
@ -381,11 +449,9 @@
} }
.media-upload-wrapper { .media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em; margin-right: .2em;
margin-bottom: .5em; margin-bottom: .5em;
width: 18em;
.icon-cancel { .icon-cancel {
display: inline-block; display: inline-block;
@ -399,6 +465,20 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-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 { .status-input-wrapper {
@ -408,28 +488,13 @@
flex-direction: column; flex-direction: column;
} }
.attachments { .media-upload-wrapper .attachments {
padding: 0 0.5em; padding: 0 0.5em;
.attachment { .attachment {
margin: 0; margin: 0;
padding: 0;
position: relative; 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 { i {

View file

@ -30,7 +30,7 @@
height: 100vh; height: 100vh;
} }
.panel-body { >.panel-body {
height: 100%; height: 100%;
overflow-y: hidden; overflow-y: hidden;

View file

@ -62,6 +62,11 @@
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link> </router-link>
</li> </li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li <li
v-if="currentUser.locked" v-if="currentUser.locked"
@click="toggleDrawer" @click="toggleDrawer"

View file

@ -2,6 +2,10 @@ import map from 'lodash/map'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = { const StaffPanel = {
created () {
const nicknames = this.$store.state.instance.staffAccounts
nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname))
},
components: { components: {
BasicUserCard BasicUserCard
}, },

View file

@ -197,7 +197,7 @@
> >
<StatusPopover <StatusPopover
v-if="!isPreview" 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" class="reply-to-popover"
style="min-width: 0" style="min-width: 0"
> >
@ -208,7 +208,12 @@
@click.prevent="gotoOriginal(status.in_reply_to_status_id)" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
> >
<i class="button-icon icon-reply" /> <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> </a>
</StatusPopover> </StatusPopover>
<span <span
@ -372,9 +377,6 @@ $status-margin: 0.75em;
} }
.status-el { .status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px; border-left-width: 0px;
min-width: 0; min-width: 0;
border-color: $fallback--border; border-color: $fallback--border;
@ -526,6 +528,10 @@ $status-margin: 0.75em;
margin: 0 0.4em 0 0.2em; margin: 0 0.4em 0 0.2em;
} }
.strikethrough {
text-decoration: line-through;
}
.replies-separator { .replies-separator {
margin-left: 0.4em; margin-left: 0.4em;
} }

View file

@ -99,15 +99,8 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file) file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
) )
}, },
hasImageAttachments () { attachmentTypes () {
return this.status.attachments.some( return this.status.attachments.map(file => fileType.fileType(file.mimetype))
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
}, },
maxThumbnails () { maxThumbnails () {
return this.mergedConfig.maxThumbnails return this.mergedConfig.maxThumbnails

View file

@ -55,13 +55,21 @@
> >
{{ $t("status.show_content") }} {{ $t("status.show_content") }}
<span <span
v-if="hasImageAttachments" v-if="attachmentTypes.includes('image')"
class="icon-picture" class="icon-picture"
/> />
<span <span
v-if="hasVideoAttachments" v-if="attachmentTypes.includes('video')"
class="icon-video" class="icon-video"
/> />
<span
v-if="attachmentTypes.includes('audio')"
class="icon-music"
/>
<span
v-if="attachmentTypes.includes('unknown')"
class="icon-doc"
/>
<span <span
v-if="status.card" v-if="status.card"
class="icon-link" class="icon-link"
@ -217,6 +225,9 @@ $status-margin: 0.75em;
font-family: var(--postFont, sans-serif); font-family: var(--postFont, sans-serif);
line-height: 1.4em; line-height: 1.4em;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
blockquote { blockquote {
margin: 0.2em 0 0.2em 2em; margin: 0.2em 0 0.2em 2em;

View file

@ -22,6 +22,10 @@ const StatusPopover = {
methods: { methods: {
enter () { enter () {
if (!this.status) { if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
this.$store.dispatch('fetchStatus', this.statusId) this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false)) .then(data => (this.error = false))
.catch(e => (this.error = true)) .catch(e => (this.error = true))

View file

@ -4,7 +4,8 @@ const StillImage = {
'referrerpolicy', 'referrerpolicy',
'mimetype', 'mimetype',
'imageLoadError', 'imageLoadError',
'imageLoadHandler' 'imageLoadHandler',
'alt'
], ],
data () { data () {
return { return {

View file

@ -11,6 +11,8 @@
<img <img
ref="src" ref="src"
:key="src" :key="src"
:alt="alt"
:title="alt"
:src="src" :src="src"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
@load="onLoad" @load="onLoad"

View file

@ -139,7 +139,7 @@ const Timeline = {
showImmediately: true, showImmediately: true,
userId: this.userId, userId: this.userId,
tag: this.tag tag: this.tag
}).then(statuses => { }).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false }) store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) { if (statuses && statuses.length === 0) {
this.bottomedOut = true this.bottomedOut = true

View file

@ -1,6 +1,18 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import { mapState } from 'vuex' 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 = { const TimelineMenu = {
components: { components: {
Popover Popover
@ -17,11 +29,14 @@ const TimelineMenu = {
}, },
methods: { methods: {
openMenu () { openMenu () {
// Tried using $nextTick, but the animation wouldn't // $nextTick is too fast, animation won't play back but
// play, I assume it played too quickly // 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(() => { setTimeout(() => {
this.isOpen = true this.isOpen = true
}, 1) }, 25)
} }
}, },
computed: { computed: {
@ -30,13 +45,8 @@ const TimelineMenu = {
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating federating: state => state.instance.federating
}), }),
timelineNamesForRoute () { timelineNames () {
return { return timelineNames()
'friends': this.$t('nav.timeline'),
'dms': this.$t('nav.dms'),
'public-timeline': this.$t('nav.public_tl'),
'public-external-timeline': this.$t('nav.twkn')
}
} }
} }
} }

View file

@ -19,6 +19,11 @@
<i class="button-icon icon-home-2" />{{ $t("nav.timeline") }} <i class="button-icon icon-home-2" />{{ $t("nav.timeline") }}
</router-link> </router-link>
</li> </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"> <li v-if="currentUser">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }} <i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }}
@ -40,7 +45,7 @@
slot="trigger" slot="trigger"
class="title timeline-menu-title" class="title timeline-menu-title"
> >
<span>{{ timelineNamesForRoute[$route.name] }}</span> <span>{{ $t(timelineNames[$route.name]) }}</span>
<i class="icon-down-open" /> <i class="icon-down-open" />
</div> </div>
</Popover> </Popover>

View file

@ -10,9 +10,7 @@
:hide-bio="true" :hide-bio="true"
rounded="top" rounded="top"
/> />
<div class="panel-footer"> <PostStatusForm />
<PostStatusForm />
</div>
</div> </div>
<auth-form <auth-form
v-else v-else

View file

@ -4,6 +4,8 @@
:src="attachment.url" :src="attachment.url"
:loop="loopVideo" :loop="loopVideo"
:controls="controls" :controls="controls"
:alt="attachment.description"
:title="attachment.description"
playsinline playsinline
@loadeddata="onVideoDataLoad" @loadeddata="onVideoDataLoad"
/> />

View file

@ -120,10 +120,12 @@
"public_tl": "Public Timeline", "public_tl": "Public Timeline",
"timeline": "Timeline", "timeline": "Timeline",
"twkn": "Known Network", "twkn": "Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search", "user_search": "User Search",
"search": "Search", "search": "Search",
"who_to_follow": "Who to follow", "who_to_follow": "Who to follow",
"preferences": "Preferences" "preferences": "Preferences",
"timelines": "Timelines"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "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_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji" "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": { "interactions": {
"favs_repeats": "Repeats and Favorites", "favs_repeats": "Repeats and Favorites",
"follows": "New follows", "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": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": { "content_type": {
"text/plain": "Plain text", "text/plain": "Plain text",
"text/html": "HTML", "text/html": "HTML",
@ -185,6 +191,10 @@
"direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"posting": "Posting", "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": { "scope_notice": {
"public": "This post will be visible to everyone", "public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only", "private": "This post will be visible to your followers only",
@ -629,6 +639,8 @@
"pin": "Pin on profile", "pin": "Pin on profile",
"unpin": "Unpin from profile", "unpin": "Unpin from profile",
"pinned": "Pinned", "pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?", "delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to", "reply_to": "Reply to",
"replies_list": "Replies:", "replies_list": "Replies:",
@ -724,7 +736,8 @@
"add_reaction": "Add Reaction", "add_reaction": "Add Reaction",
"user_settings": "User Settings", "user_settings": "User Settings",
"accept_follow_request": "Accept follow request", "accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request" "reject_follow_request": "Reject follow request",
"bookmark": "Bookmark"
}, },
"upload": { "upload": {
"error": { "error": {

View file

@ -66,7 +66,7 @@
"search": "Haku" "search": "Haku"
}, },
"notifications": { "notifications": {
"broken_favorite": "Viestiä ei löydetty...", "broken_favorite": "Viestiä ei löydetty",
"favorited_you": "tykkäsi viestistäsi", "favorited_you": "tykkäsi viestistäsi",
"followed_you": "seuraa sinua", "followed_you": "seuraa sinua",
"load_older": "Lataa vanhempia ilmoituksia", "load_older": "Lataa vanhempia ilmoituksia",
@ -101,7 +101,7 @@
}, },
"post_status": { "post_status": {
"new_status": "Uusi viesti", "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", "account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": { "content_type": {
@ -288,7 +288,7 @@
"authentication_methods": "Todennus", "authentication_methods": "Todennus",
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.", "warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
"recovery_codes": "Palautuskoodit.", "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.", "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": { "scan": {
"title": "Skannaa", "title": "Skannaa",
@ -575,7 +575,7 @@
"statuses": "Viestit", "statuses": "Viestit",
"hidden": "Piilotettu", "hidden": "Piilotettu",
"media": "Media", "media": "Media",
"block_progress": "Estetään...", "block_progress": "Estetään",
"admin_menu": { "admin_menu": {
"grant_admin": "Anna Ylläpitöoikeudet", "grant_admin": "Anna Ylläpitöoikeudet",
"force_nsfw": "Merkitse kaikki viestit NSFW:nä", "force_nsfw": "Merkitse kaikki viestit NSFW:nä",
@ -601,10 +601,10 @@
"subscribe": "Tilaa", "subscribe": "Tilaa",
"unsubscribe": "Poista tilaus", "unsubscribe": "Poista tilaus",
"unblock": "Poista esto", "unblock": "Poista esto",
"unblock_progress": "Postetaan estoa...", "unblock_progress": "Postetaan estoa",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"unmute_progress": "Poistetaan mykistystä...", "unmute_progress": "Poistetaan mykistystä",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"hide_repeats": "Piilota toistot", "hide_repeats": "Piilota toistot",
"show_repeats": "Näytä toistot" "show_repeats": "Näytä toistot"
}, },
@ -674,8 +674,8 @@
"domain_mute_card": { "domain_mute_card": {
"mute": "Mykistä", "mute": "Mykistä",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"unmute_progress": "Poistetaan mykistyst..." "unmute_progress": "Poistetaan mykistyst"
}, },
"exporter": { "exporter": {
"export": "Vie", "export": "Vie",

View file

@ -34,7 +34,8 @@
"user_search": "Ricerca utenti", "user_search": "Ricerca utenti",
"search": "Ricerca", "search": "Ricerca",
"who_to_follow": "Chi seguire", "who_to_follow": "Chi seguire",
"preferences": "Preferenze" "preferences": "Preferenze",
"bookmarks": "Segnalibri"
}, },
"notifications": { "notifications": {
"followed_you": "ti segue", "followed_you": "ti segue",
@ -271,10 +272,59 @@
"shadow_id": "Ombra numero {value}", "shadow_id": "Ombra numero {value}",
"override": "Sostituisci", "override": "Sostituisci",
"component": "Componente", "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": { "radii": {
"_tab_label": "Raggio" "_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", "enable_web_push_notifications": "Abilita notifiche web push",
@ -336,7 +386,19 @@
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.", "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": { "timeline": {
"error_fetching": "Errore nell'aggiornamento", "error_fetching": "Errore nell'aggiornamento",
@ -346,7 +408,10 @@
"collapse": "Riduci", "collapse": "Riduci",
"conversation": "Conversazione", "conversation": "Conversazione",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso", "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": { "user_card": {
"follow": "Segui", "follow": "Segui",
@ -425,7 +490,10 @@
}, },
"direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.", "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.", "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": { "registration": {
"bio": "Introduzione", "bio": "Introduzione",
@ -548,5 +616,50 @@
"error": "Non trovato.", "error": "Non trovato.",
"searching_for": "Cerco", "searching_for": "Cerco",
"remote_user_resolver": "Cerca utenti remoti" "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"
} }
} }

View file

@ -45,7 +45,8 @@
"timeline": "Лента", "timeline": "Лента",
"twkn": "Федеративная лента", "twkn": "Федеративная лента",
"search": "Поиск", "search": "Поиск",
"friend_requests": "Запросы на чтение" "friend_requests": "Запросы на чтение",
"bookmarks": "Закладки"
}, },
"notifications": { "notifications": {
"broken_favorite": "Неизвестный статус, ищем...", "broken_favorite": "Неизвестный статус, ищем...",
@ -366,6 +367,10 @@
"show_new": "Показать новые", "show_new": "Показать новые",
"up_to_date": "Обновлено" "up_to_date": "Обновлено"
}, },
"status": {
"bookmark": "В закладки",
"unbookmark": "Удалить из закладок"
},
"user_card": { "user_card": {
"block": "Заблокировать", "block": "Заблокировать",
"blocked": "Заблокирован", "blocked": "Заблокирован",

View file

@ -85,7 +85,7 @@
"administration": "管理员" "administration": "管理员"
}, },
"notifications": { "notifications": {
"broken_favorite": "未知的状态,正在搜索中...", "broken_favorite": "未知的状态,正在搜索中",
"favorited_you": "收藏了你的状态", "favorited_you": "收藏了你的状态",
"followed_you": "关注了你", "followed_you": "关注了你",
"load_older": "加载更早的通知", "load_older": "加载更早的通知",
@ -185,7 +185,7 @@
"generate_new_recovery_codes": "生成新的恢复码", "generate_new_recovery_codes": "生成新的恢复码",
"warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。", "warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。",
"recovery_codes": "恢复码。", "recovery_codes": "恢复码。",
"waiting_a_recovery_codes": "正在接收备份码…", "waiting_a_recovery_codes": "正在接收备份码…",
"recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app也丢失了你的恢复码你的账号就再也无法登录了。", "recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app也丢失了你的恢复码你的账号就再也无法登录了。",
"authentication_methods": "身份验证方法", "authentication_methods": "身份验证方法",
"scan": { "scan": {
@ -564,11 +564,11 @@
"subscribe": "订阅", "subscribe": "订阅",
"unsubscribe": "退订", "unsubscribe": "退订",
"unblock": "取消拉黑", "unblock": "取消拉黑",
"unblock_progress": "取消拉黑中...", "unblock_progress": "取消拉黑中",
"block_progress": "拉黑中...", "block_progress": "拉黑中",
"unmute": "取消隐藏", "unmute": "取消隐藏",
"unmute_progress": "取消隐藏中...", "unmute_progress": "取消隐藏中",
"mute_progress": "隐藏中...", "mute_progress": "隐藏中",
"admin_menu": { "admin_menu": {
"moderation": "权限", "moderation": "权限",
"grant_admin": "赋予管理权限", "grant_admin": "赋予管理权限",
@ -690,9 +690,9 @@
} }
}, },
"domain_mute_card": { "domain_mute_card": {
"unmute_progress": "正在取消隐藏…", "unmute_progress": "正在取消隐藏…",
"unmute": "取消隐藏", "unmute": "取消隐藏",
"mute_progress": "隐藏中…", "mute_progress": "隐藏中…",
"mute": "隐藏" "mute": "隐藏"
} }
} }

View file

@ -62,7 +62,15 @@ const persistedStateOptions = {
}; };
(async () => { (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({ const store = new Vuex.Store({
modules: { modules: {
i18n: { i18n: {
@ -85,11 +93,13 @@ const persistedStateOptions = {
polls: pollsModule, polls: pollsModule,
postStatus: postStatusModule postStatus: postStatusModule
}, },
plugins: [persistedState, pushNotifications], plugins,
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production' // strict: process.env.NODE_ENV !== 'production'
}) })
if (storageError) {
store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
}
afterStoreSetup({ store, i18n }) afterStoreSetup({ store, i18n })
})() })()

View file

@ -138,9 +138,6 @@ const api = {
if (!fetcher) return if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher }) store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
}, },
fetchAndUpdateNotifications (store) {
store.state.backendInteractor.fetchAndUpdateNotifications({ store })
},
// Follow requests // Follow requests
startFetchingFollowRequests (store) { startFetchingFollowRequests (store) {

View file

@ -14,7 +14,8 @@ const defaultState = {
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
) )
}, },
mobileLayout: false mobileLayout: false,
globalNotices: []
} }
const interfaceMod = { const interfaceMod = {
@ -58,6 +59,12 @@ const interfaceMod = {
if (!state.settingsModalLoaded) { if (!state.settingsModalLoaded) {
state.settingsModalLoaded = true state.settingsModalLoaded = true
} }
},
pushGlobalNotice (state, notice) {
state.globalNotices.push(notice)
},
removeGlobalNotice (state, notice) {
state.globalNotices = state.globalNotices.filter(n => n !== notice)
} }
}, },
actions: { actions: {
@ -81,6 +88,28 @@ const interfaceMod = {
}, },
togglePeekSettingsModal ({ commit }) { togglePeekSettingsModal ({ commit }) {
commit('togglePeekSettingsModal') 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)
} }
} }
} }

View file

@ -22,7 +22,7 @@ const mediaViewer = {
setMedia ({ commit }, attachments) { setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => { const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype) const type = fileTypeService.fileType(attachment.mimetype)
return type === 'image' || type === 'video' return type === 'image' || type === 'video' || type === 'audio'
}) })
commit('setMedia', media) commit('setMedia', media)
}, },

View file

@ -62,7 +62,8 @@ export const defaultState = () => ({
publicAndExternal: emptyTl(), publicAndExternal: emptyTl(),
friends: emptyTl(), friends: emptyTl(),
tag: 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 = {}, const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
noIdUpdate = false, userId }) => {
// Sanity check // Sanity check
if (!isArray(statuses)) { if (!isArray(statuses)) {
return false return false
@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const allStatuses = state.allStatuses const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline] const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 // Mismatch between API pagination and our internal minId/maxId tracking systems:
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 // 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 newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 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 // Keep the visible statuses sorted
if (timeline) { if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject) sortTimeline(timelineObject)
} }
} }
@ -463,6 +468,14 @@ export const mutations = {
newStatus.rebloggedBy.push(user) 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 }) { setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true newStatus.deleted = true
@ -590,8 +603,8 @@ export const mutations = {
const statuses = { const statuses = {
state: defaultState(), state: defaultState(),
actions: { actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
}, },
addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
@ -666,6 +679,20 @@ const statuses = {
rootState.api.backendInteractor.unretweet({ id: status.id }) rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) .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 }) { queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id }) commit('queueFlush', { timeline, id })
}, },

View file

@ -266,6 +266,11 @@ const users = {
mutations, mutations,
getters, getters,
actions: { actions: {
fetchUserIfMissing (store, id) {
if (!store.getters.findUser(id)) {
store.dispatch('fetchUser', id)
}
},
fetchUser (store, id) { fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id }) return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => { .then((user) => {

View file

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash' import { each, map, concat, last, get } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } 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' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* 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_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` 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_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/' const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` 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_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` 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_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes` const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@ -510,7 +513,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL, user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_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 isNotifications = timeline === 'notifications'
const params = [] const params = []
@ -539,7 +543,7 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') { if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false]) params.push(['only_media', false])
} }
if (timeline !== 'favorites') { if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted]) params.push(['with_muted', withMuted])
} }
if (replyVisibility !== 'all') { if (replyVisibility !== 'all') {
@ -552,16 +556,20 @@ const fetchTimeline = ({
url += `?${queryString}` url += `?${queryString}`
let status = '' let status = ''
let statusText = '' let statusText = ''
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => { .then((data) => {
status = data.status status = data.status
statusText = data.statusText statusText = data.statusText
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
})
return data return data
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
return data.map(isNotifications ? parseNotification : parseStatus) return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else { } else {
data.status = status data.status = status
data.statusText = statusText data.statusText = statusText
@ -612,6 +620,22 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data)) .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 = ({ const postStatus = ({
credentials, credentials,
status, status,
@ -621,7 +645,8 @@ const postStatus = ({
poll, poll,
mediaIds = [], mediaIds = [],
inReplyToStatusId, inReplyToStatusId,
contentType contentType,
preview
}) => { }) => {
const form = new FormData() const form = new FormData()
const pollOptions = poll.options || [] const pollOptions = poll.options || []
@ -651,6 +676,9 @@ const postStatus = ({
if (inReplyToStatusId) { if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId) form.append('in_reply_to_id', inReplyToStatusId)
} }
if (preview) {
form.append('preview', 'true')
}
return fetch(MASTODON_POST_STATUS_URL, { return fetch(MASTODON_POST_STATUS_URL, {
body: form, body: form,
@ -658,13 +686,7 @@ const postStatus = ({
headers: authHeaders(credentials) headers: authHeaders(credentials)
}) })
.then((response) => { .then((response) => {
if (response.ok) { return response.json()
return response.json()
} else {
return {
error: response
}
}
}) })
.then((data) => data.error ? data : parseStatus(data)) .then((data) => data.error ? data : parseStatus(data))
} }
@ -686,6 +708,17 @@ const uploadMedia = ({ formData, credentials }) => {
.then((data) => parseAttachment(data)) .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 importBlocks = ({ file, credentials }) => {
const formData = new FormData() const formData = new FormData()
formData.append('list', file) formData.append('list', file)
@ -1150,9 +1183,12 @@ const apiService = {
unfavorite, unfavorite,
retweet, retweet,
unretweet, unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus, postStatus,
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
setMediaDescription,
fetchMutes, fetchMutes,
muteUser, muteUser,
unmuteUser, unmuteUser,

View file

@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.startFetching({ store, credentials }) return notificationsFetcher.startFetching({ store, credentials })
}, },
fetchAndUpdateNotifications ({ store }) {
return notificationsFetcher.fetchAndUpdate({ store, credentials })
},
startFetchingFollowRequests ({ store }) { startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials }) return followRequestFetcher.startFetching({ store, credentials })
}, },

View file

@ -1,4 +1,5 @@
import escape from 'escape-html' import escape from 'escape-html'
import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js' import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => { const qvitterStatusType = (status) => {
@ -232,6 +233,8 @@ export const parseStatus = (data) => {
output.repeated = data.reblogged output.repeated = data.reblogged
output.repeat_num = data.reblogs_count output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status' output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive 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.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else { } else {
output.text = data.content output.text = data.content
output.summary = data.spoiler_text output.summary = data.spoiler_text
@ -381,3 +385,16 @@ const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex) 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)
}
}

View file

@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
} }
const result = fetchNotifications({ store, args, older }) 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 notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) 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) args['since'] = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older }) fetchNotifications({ store, args, older })
} }
return result return result
} }
} }
const fetchNotifications = ({ store, args, older }) => { const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((notifications) => { .then(({ data: notifications }) => {
update({ store, notifications, older }) update({ store, notifications, older })
return notifications return notifications
}, () => store.dispatch('setNotificationsError', { value: true })) }, () => store.dispatch('setNotificationsError', { value: true }))

View file

@ -1,7 +1,18 @@
import { map } from 'lodash' import { map } from 'lodash'
import apiService from '../api/api.service.js' 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') const mediaIds = map(media, 'id')
return apiService.postStatus({ return apiService.postStatus({
@ -13,9 +24,11 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
mediaIds, mediaIds,
inReplyToStatusId, inReplyToStatusId,
contentType, contentType,
poll }) poll,
preview
})
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error && !preview) {
store.dispatch('addNewStatuses', { store.dispatch('addNewStatuses', {
statuses: [data], statuses: [data],
timeline: 'friends', timeline: 'friends',
@ -34,13 +47,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
const uploadMedia = ({ store, formData }) => { const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData }) return apiService.uploadMedia({ credentials, formData })
} }
const setMediaDescription = ({ store, id, description }) => {
const credentials = store.state.users.currentUser.credentials
return apiService.setMediaDescription({ credentials, id, description })
}
const statusPosterService = { const statusPosterService = {
postStatus, postStatus,
uploadMedia uploadMedia,
setMediaDescription
} }
export default statusPosterService export default statusPosterService

View file

@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5, alert: 0.5,
input: 0.5, input: 0.5,
faint: 0.5, faint: 0.5,
underlay: 0.15 underlay: 0.15,
alertPopup: 0.95
} }
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta /** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
textColor: true 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', badgeNotification: '--cRed',
badgeNotificationText: { badgeNotificationText: {
depends: ['text', 'badgeNotification'], depends: ['text', 'badgeNotification'],

View file

@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js' 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) const ccTimeline = camelCase(timeline)
store.dispatch('setError', { value: false }) store.dispatch('setError', { value: false })
@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline, timeline: ccTimeline,
userId, userId,
statuses, statuses,
showImmediately showImmediately,
pagination
}) })
} }
@ -47,16 +48,18 @@ const fetchAndUpdate = ({
const numStatusesBeforeFetch = timelineData.statuses.length const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((statuses) => { .then(response => {
if (statuses.error) { if (response.error) {
store.dispatch('setErrorData', { value: statuses }) store.dispatch('setErrorData', { value: response })
return return
} }
const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
} }
update({ store, statuses, timeline, showImmediately, userId }) update({ store, statuses, timeline, showImmediately, userId, pagination })
return statuses return { statuses, pagination }
}, () => store.dispatch('setError', { value: true })) }, () => store.dispatch('setError', { value: true }))
} }

24
static/fontello.json Executable file → Normal file
View file

@ -375,6 +375,30 @@
"css": "download", "css": "download",
"code": 59429, "code": 59429,
"src": "fontawesome" "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"
} }
] ]
} }

View file

@ -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 mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json' import qvitterapidata from '../../../../fixtures/statuses.json'
@ -383,4 +383,24 @@ describe('API Entities normalizer', () => {
expect(result).to.include('title=\':[a-z] {|}*:\'') 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'
})
})
})
}) })

View file

@ -5751,6 +5751,13 @@ parse-json@^4.0.0:
error-ex "^1.3.1" error-ex "^1.3.1"
json-parse-better-errors "^1.0.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: parseqs@0.0.5:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"