Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop

This commit is contained in:
sadposter 2020-07-09 22:32:18 +01:00
commit 954d5c05df
76 changed files with 1639 additions and 468 deletions

View File

@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
@ -16,6 +17,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
- 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings
- Added option to reset avatar/banner 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
@ -23,6 +30,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.
@ -34,6 +42,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
@ -95,6 +109,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

View File

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

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",
"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",

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 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',

View File

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

View File

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

View File

@ -8,38 +8,72 @@ import backendInteractorService from '../services/backend_interactor_service/bac
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
const getStatusnetConfig = async ({ store }) => {
let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
return staticInitialResults
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/statusnet/config.json')
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site
const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
return data.site.pleromafe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load statusnet config, potentially fatal')
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
}
const getBackendProvidedConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
@ -132,7 +166,7 @@ const getTOS = async ({ store }) => {
const getInstancePanel = async ({ store }) => {
try {
const res = await window.fetch('/instance/panel.html')
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
@ -189,24 +223,33 @@ 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 })
}
const getNodeInfo = async ({ store }) => {
try {
const res = await window.fetch('/nodeinfo/2.0.json')
const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@ -257,7 +300,7 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
@ -280,6 +323,11 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
@ -299,16 +347,18 @@ const afterStoreSetup = async ({ store, i18n }) => {
}
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getTOS({ store }),
getInstancePanel({ store }),
getStickers({ store }),
getNodeInfo({ store })
getNodeInfo({ store }),
getInstanceConfig({ store })
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = new VueRouter({
mode: 'history',

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 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([^/@]+)',

View File

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

View File

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

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

@ -431,6 +431,7 @@ const EmojiInput = {
const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px'
if (!picker) return
picker.$el.style.top = offsetBottom + 'px'
picker.$el.style.bottom = 'auto'
}

View File

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

View File

@ -40,6 +40,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"

View File

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

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'"
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')"

View File

@ -17,6 +17,11 @@
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</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 && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}

View File

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

View File

@ -118,6 +118,11 @@
flex: 1;
padding-left: 0.8em;
min-width: 0;
.timeago {
min-width: 3em;
text-align: right;
}
}
.emoji-reaction-emoji {

View File

@ -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,29 +221,83 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
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) {
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)))
}
}
}

View File

@ -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
@ -302,14 +330,6 @@
}
}
.post-status-form {
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
}
.post-status-form {
.form-bottom {
display: flex;
@ -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 {

View File

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

View File

@ -37,6 +37,9 @@ const FilteringTab = {
})
},
deep: true
},
replyVisibility () {
this.$store.dispatch('queueFlushAll')
}
}
}

View File

@ -1,4 +1,5 @@
import unescape from 'lodash/unescape'
import merge from 'lodash/merge'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
@ -16,6 +17,7 @@ const ProfileTab = {
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
@ -23,6 +25,7 @@ const ProfileTab = {
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
@ -62,6 +65,45 @@ const ProfileTab = {
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
},
userSuggestor () {
return suggestor({
users: this.$store.state.users.users,
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
avatarImgSrc () {
const src = this.$store.state.users.currentUser.profile_image_url_original
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.$store.state.users.currentUser.cover_photo
return (!src) ? this.defaultBanner : src
}
},
methods: {
@ -74,17 +116,21 @@ const ProfileTab = {
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
bot: this.bot,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
@ -92,6 +138,16 @@ const ProfileTab = {
changeVis (visibility) {
this.newDefaultScope = visibility
},
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
deleteField (index, event) {
this.$delete(this.newFields, index)
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
@ -121,11 +177,29 @@ const ProfileTab = {
}
reader.readAsDataURL(file)
},
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
@ -143,11 +217,11 @@ const ProfileTab = {
}
})
},
submitBanner () {
if (!this.bannerPreview) { return }
submitBanner (banner) {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
@ -158,11 +232,11 @@ const ProfileTab = {
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)

View File

@ -13,8 +13,14 @@
height: auto;
}
.banner {
.banner-background-preview {
max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
}
.uploading {
@ -26,18 +32,40 @@
width: 100%;
}
.bg {
max-width: 100%;
.current-avatar-container {
position: relative;
width: 150px;
height: 150px;
}
.current-avatar {
display: block;
width: 150px;
height: 150px;
width: 100%;
height: 100%;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
}
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
background-color: rgba(0, 0, 0, 0.6);
opacity: 0.7;
color: white;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.oauth-tokens {
width: 100%;
@ -79,4 +107,22 @@
.setting-subitem {
margin-left: 1.75em;
}
.profile-fields {
display: flex;
&>.emoji-input {
flex: 1 1 auto;
margin: 0 .2em .5em;
min-width: 0;
}
&>.icon-container {
width: 20px;
&>.icon-cancel {
vertical-align: sub;
}
}
}
}

View File

@ -95,6 +95,59 @@
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
v-for="(_, i) in newFields"
:key="i"
class="profile-fields"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
>
</EmojiInput>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
>
</EmojiInput>
<div
class="icon-container"
>
<i
v-show="newFields.length > 1"
class="icon-cancel"
@click="deleteField(i)"
/>
</div>
</div>
<a
v-if="newFields.length < maxFields"
class="add-field faint"
@click="addField"
>
<i class="icon-plus" />
{{ $t("settings.profile_fields.add_field") }}
</a>
</div>
<p>
<Checkbox v-model="bot">
{{ $t('settings.bot') }}
</Checkbox>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
@ -108,11 +161,19 @@
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
<p>{{ $t('settings.current_avatar') }}</p>
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<div class="current-avatar-container">
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<i
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
class="reset-button icon-cancel"
type="button"
@click="resetAvatar"
/>
</div>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
@ -131,15 +192,20 @@
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p>
<img
:src="user.cover_photo"
class="banner"
>
<div class="banner-background-preview">
<img :src="user.cover_photo">
<i
v-if="!isDefaultBanner"
:title="$t('settings.reset_profile_banner')"
class="reset-button icon-cancel"
type="button"
@click="resetBanner"
/>
</div>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner"
class="banner-background-preview"
:src="bannerPreview"
>
<div>
@ -155,7 +221,7 @@
<button
v-else-if="bannerPreview"
class="btn btn-default"
@click="submitBanner"
@click="submitBanner(banner)"
>
{{ $t('general.submit') }}
</button>
@ -172,10 +238,20 @@
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
<i
v-if="!isDefaultBackground"
:title="$t('settings.reset_profile_background')"
class="reset-button icon-cancel"
type="button"
@click="resetBackground"
/>
</div>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="bg"
class="banner-background-preview"
:src="backgroundPreview"
>
<div>
@ -191,7 +267,7 @@
<button
v-else-if="backgroundPreview"
class="btn btn-default"
@click="submitBg"
@click="submitBackground(background)"
>
{{ $t('general.submit') }}
</button>

View File

@ -65,6 +65,14 @@
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li
v-if="currentUser && currentUser.locked"
@click="toggleDrawer"

View File

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

View File

@ -141,7 +141,7 @@ const Status = {
return this.mergedConfig.hideFilteredStatuses
},
hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
return this.deleted || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
@ -164,37 +164,6 @@ const Status = {
return user && user.screen_name
}
},
hideReply () {
if (this.mergedConfig.replyVisibility === 'all') {
return false
}
if (this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.currentUser.id) {
return false
}
if (this.status.type === 'retweet') {
return false
}
const checkFollowing = this.mergedConfig.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
// There's zero guarantee of this working. If we happen to have that user and their
// relationship in store then it will work, but there's kinda little chance of having
// them for people you're not following.
const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
if (checkFollowing && relationship && relationship.following) {
return false
}
if (this.status.attentions[i].id === this.currentUser.id) {
return false
}
}
return this.status.attentions.length > 0
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)

View File

@ -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;
@ -418,7 +420,7 @@ $status-margin: 0.75em;
max-width: 85%;
font-weight: bold;
img {
img.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
@ -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;
}

View File

@ -44,14 +44,14 @@ const StatusContent = {
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 900
return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
@ -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
@ -142,12 +135,6 @@ const StatusContent = {
return html
}
},
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,

View File

@ -3,45 +3,32 @@
<div class="status-body">
<slot name="header" />
<div
v-if="longSubject"
class="status-content-wrapper"
:class="{ 'tall-status': !showingLongSubject }"
v-if="status.summary_html"
class="summary-wrapper"
:class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
>
<div
class="media-body summary"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
v-if="longSubject && showingLongSubject"
href="#"
class="tall-subject-hider"
@click.prevent="showingLongSubject=false"
>{{ $t("status.hide_full_subject") }}</a>
<a
v-else-if="longSubject"
class="tall-subject-hider"
:class="{ 'tall-subject-hider_focused': focused }"
href="#"
@click.prevent="showingLongSubject=true"
>
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
{{ $t("status.show_full_subject") }}
</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
@ -51,31 +38,51 @@
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
>
{{ $t("general.show_more") }}
</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
v-html="postBodyHtml"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
>
{{ $t("status.show_content") }}
<span
v-if="attachmentTypes.includes('image')"
class="icon-picture"
/>
<span
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"
/>
</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
>
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
</a>
</div>
<div v-if="status.poll && status.poll.options">
@ -129,6 +136,12 @@ $status-margin: 0.75em;
flex: 1;
min-width: 0;
.status-content-wrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.tall-status {
position: relative;
height: 220px;
@ -136,7 +149,7 @@ $status-margin: 0.75em;
overflow-y: hidden;
z-index: 1;
.status-content {
height: 100%;
min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
@ -176,10 +189,45 @@ $status-margin: 0.75em;
}
}
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
border-width: 0 0 1px 0;
border-color: var(--border, $fallback--border);
flex-grow: 0;
}
.summary {
font-style: italic;
padding-bottom: 0.5em;
}
.tall-subject {
position: relative;
.summary {
max-height: 2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.tall-subject-hider {
display: inline-block;
word-break: break-all;
// position: absolute;
width: 100%;
text-align: center;
padding-bottom: 0.5em;
}
.status-content {
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;

View File

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

View File

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

View File

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

View File

@ -45,11 +45,15 @@ const Timeline = {
newStatusCount () {
return this.timeline.newStatusCount
},
newStatusCountStr () {
showLoadButton () {
if (this.timelineError || this.errorData) return false
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString () {
if (this.timeline.flushMarker !== 0) {
return ''
return this.$t('timeline.reload')
} else {
return ` (${this.newStatusCount})`
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
classes () {
@ -112,8 +116,6 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses () {
if (this.newStatusCount === 0) return
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@ -135,7 +137,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

View File

@ -19,14 +19,14 @@
{{ errorData.statusText }}
</div>
<button
v-if="timeline.newStatusCount > 0 && !timelineError && !errorData"
v-else-if="showLoadButton"
class="loadmore-button"
@click.prevent="showNewStatuses"
>
{{ $t('timeline.show_new') }}{{ newStatusCountStr }}
{{ loadButtonString }}
</button>
<div
v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData"
v-else
class="loadmore-text faint"
@click.prevent
>

View File

@ -8,26 +8,20 @@ const UserAvatar = {
],
data () {
return {
showPlaceholder: false
showPlaceholder: false,
defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`
}
},
components: {
StillImage
},
computed: {
imgSrc () {
return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original
}
},
methods: {
imgSrc (src) {
return (!src || this.showPlaceholder) ? this.defaultAvatar : src
},
imageLoadError () {
this.showPlaceholder = true
}
},
watch: {
src () {
this.showPlaceholder = false
}
}
}

View File

@ -3,7 +3,7 @@
class="avatar"
:alt="user.screen_name"
:title="user.screen_name"
:src="imgSrc"
:src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
/>

View File

@ -70,10 +70,20 @@
>
@{{ user.screen_name }}
</router-link>
<span
v-if="!hideBio && !!visibleRole"
class="alert staff"
>{{ visibleRole }}</span>
<template v-if="!hideBio">
<span
v-if="!!visibleRole"
class="alert user-role"
>
{{ visibleRole }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
bot
</span>
</template>
<span v-if="user.locked"><i class="icon icon-lock" /></span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
@ -458,7 +468,7 @@
color: var(--text, $fallback--text);
}
.staff {
.user-role {
flex: none;
text-transform: capitalize;
color: $fallback--text;

View File

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

View File

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

View File

@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) {
panel.usersToFollow.forEach((toFollow, index) => {
let user = shuffled[index]
let img = user.avatar || '/images/avi.png'
let img = user.avatar || this.$store.state.instance.defaultAvatar
let name = user.acct
toFollow.img = img
@ -38,13 +38,7 @@ function getWhoToFollow (panel) {
const WhoToFollowPanel = {
data: () => ({
usersToFollow: new Array(3).fill().map(x => (
{
img: '/images/avi.png',
name: '',
id: 0
}
))
usersToFollow: []
}),
computed: {
user: function () {
@ -68,6 +62,13 @@ const WhoToFollowPanel = {
},
mounted:
function () {
this.usersToFollow = new Array(3).fill().map(x => (
{
img: this.$store.state.instance.defaultAvatar,
name: '',
id: 0
}
))
if (this.suggestionsEnabled) {
getWhoToFollow(this)
}

View File

@ -120,6 +120,7 @@
"public_tl": "Public Timeline",
"timeline": "Timeline",
"twkn": "The Whole Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search",
"search": "Search",
"who_to_follow": "Who to follow",
@ -163,6 +164,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 +178,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 +190,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",
@ -266,6 +275,7 @@
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
@ -283,7 +293,6 @@
"current_avatar": "Your current avatar",
"current_mascot": "Your current mascot",
"current_password": "Current password",
"current_profile_banner": "Your current profile banner",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope",
@ -334,6 +343,12 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add Field",
"name": "Label",
"value": "Content"
},
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & Bio",
@ -386,6 +401,12 @@
"set_new_mascot": "Set new mascot",
"set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
@ -613,6 +634,7 @@
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses"
@ -624,6 +646,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:",
@ -632,7 +656,11 @@
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:"
"thread_muted_and_words": ", has words:",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content"
},
"user_card": {
"approve": "Approve",
@ -715,7 +743,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": {

View File

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

View File

@ -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",
@ -255,7 +256,8 @@
"top_bar": "Barra superiore",
"panel_header": "Titolo pannello",
"badge_notification": "Notifica",
"popover": "Suggerimenti, menù, sbalzi"
"popover": "Suggerimenti, menù, sbalzi",
"toggled": "Scambiato"
},
"common_colors": {
"rgbo": "Icone, accenti, medaglie",
@ -270,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",
@ -335,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",
@ -345,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",
@ -424,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",
@ -547,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"
}
}

View File

@ -28,7 +28,12 @@
"enable": "Inschakelen",
"confirm": "Bevestigen",
"verify": "Verifiëren",
"generic_error": "Er is een fout opgetreden"
"generic_error": "Er is een fout opgetreden",
"peek": "Spiek",
"close": "Sluiten",
"retry": "Opnieuw proberen",
"error_retry": "Probeer het opnieuw",
"loading": "Laden…"
},
"login": {
"login": "Log in",
@ -90,7 +95,7 @@
"text/bbcode": "BBCode"
},
"content_warning": "Onderwerp (optioneel)",
"default": "Zojuist geland in L.A.",
"default": "Tijd voor anime!",
"direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
"posting": "Plaatsen",
"scope": {
@ -377,7 +382,7 @@
"button": "Knop",
"text": "Nog een boel andere {0} en {1}",
"mono": "inhoud",
"input": "Zojuist geland in L.A.",
"input": "Tijd voor anime!",
"faint_link": "handige gebruikershandleiding",
"fine_print": "Lees onze {0} om niets nuttig te leren!",
"header_faint": "Alles komt goed",
@ -451,7 +456,7 @@
"user_mutes": "Gebruikers",
"useStreamingApi": "Berichten en meldingen in real-time ontvangen",
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)",
"type_domains_to_mute": "Voer domeinen in om te negeren",
"type_domains_to_mute": "Zoek domeinen om te negeren",
"upload_a_photo": "Upload een foto",
"fun": "Plezier",
"greentext": "Meme pijlen",
@ -470,7 +475,15 @@
"frontend_version": "Frontend Versie",
"backend_version": "Backend Versie",
"title": "Versie"
}
},
"mutes_and_blocks": "Negeringen en Blokkades",
"profile_fields": {
"value": "Inhoud",
"name": "Label",
"add_field": "Veld Toevoegen",
"label": "Profiel metadata"
},
"bot": "Dit is een bot account"
},
"timeline": {
"collapse": "Inklappen",
@ -708,7 +721,9 @@
"unpin": "Van profiel losmaken",
"delete": "Status verwijderen",
"repeats": "Herhalingen",
"favorites": "Favorieten"
"favorites": "Favorieten",
"thread_muted_and_words": ", heeft woorden:",
"thread_muted": "Thread genegeerd"
},
"time": {
"years_short": "{0}j",

View File

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

View File

@ -0,0 +1,35 @@
/* eslint-disable import/no-webpack-loader-syntax */
// This module exports only the notification part of the i18n,
// which is useful for the service worker
const messages = {
ar: require('../lib/notification-i18n-loader.js!./ar.json'),
ca: require('../lib/notification-i18n-loader.js!./ca.json'),
cs: require('../lib/notification-i18n-loader.js!./cs.json'),
de: require('../lib/notification-i18n-loader.js!./de.json'),
eo: require('../lib/notification-i18n-loader.js!./eo.json'),
es: require('../lib/notification-i18n-loader.js!./es.json'),
et: require('../lib/notification-i18n-loader.js!./et.json'),
eu: require('../lib/notification-i18n-loader.js!./eu.json'),
fi: require('../lib/notification-i18n-loader.js!./fi.json'),
fr: require('../lib/notification-i18n-loader.js!./fr.json'),
ga: require('../lib/notification-i18n-loader.js!./ga.json'),
he: require('../lib/notification-i18n-loader.js!./he.json'),
hu: require('../lib/notification-i18n-loader.js!./hu.json'),
it: require('../lib/notification-i18n-loader.js!./it.json'),
ja: require('../lib/notification-i18n-loader.js!./ja_pedantic.json'),
ja_easy: require('../lib/notification-i18n-loader.js!./ja_easy.json'),
ko: require('../lib/notification-i18n-loader.js!./ko.json'),
nb: require('../lib/notification-i18n-loader.js!./nb.json'),
nl: require('../lib/notification-i18n-loader.js!./nl.json'),
oc: require('../lib/notification-i18n-loader.js!./oc.json'),
pl: require('../lib/notification-i18n-loader.js!./pl.json'),
pt: require('../lib/notification-i18n-loader.js!./pt.json'),
ro: require('../lib/notification-i18n-loader.js!./ro.json'),
ru: require('../lib/notification-i18n-loader.js!./ru.json'),
te: require('../lib/notification-i18n-loader.js!./te.json'),
zh: require('../lib/notification-i18n-loader.js!./zh.json'),
en: require('../lib/notification-i18n-loader.js!./en.json')
}
export default messages

View File

@ -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": "隐藏"
}
}

View File

@ -0,0 +1,12 @@
// This somewhat mysterious module will load a json string
// and then extract only the 'notifications' part. This is
// meant to be used to load the partial i18n we need for
// the service worker.
module.exports = function (source) {
var object = JSON.parse(source)
var smol = {
notifications: object.notifications || {}
}
return JSON.stringify(smol)
}

View File

@ -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 })
})()

View File

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

View File

@ -15,6 +15,8 @@ const defaultState = {
// Stuff from static/config.json
alwaysShowSubjectInput: true,
defaultAvatar: '/images/avi.png',
defaultBanner: '/images/banner.png',
background: '/static/aurora_borealis.jpg',
collapseMessageWithSubject: false,
disableChat: false,

View File

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

View File

@ -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)
},

View File

@ -13,7 +13,7 @@ import {
omitBy
} from 'lodash'
import { set } from 'vue'
import { isStatusNotification } from '../services/notification_utils/notification_utils.js'
import { isStatusNotification, prepareNotificationObject } from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js'
import { muteWordHits } from '../services/status_parser/status_parser.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)
}
}
@ -344,42 +349,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
state.notifications.idStore[notification.id] = notification
if ('Notification' in window && window.Notification.permission === 'granted') {
const notifObj = {}
const status = notification.status
const title = notification.from_profile.name
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
status.attachments[0].mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
const reasonsToMuteNotif = (
notification.seen ||
@ -393,7 +363,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
)
)
if (!reasonsToMuteNotif) {
let desktopNotification = new window.Notification(title, notifObj)
let desktopNotification = new window.Notification(notifObj.title, notifObj)
// Chrome is known for not closing notifications automatically
// according to MDN, anyway.
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
@ -498,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
@ -550,6 +528,11 @@ export const mutations = {
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll (state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
@ -620,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 })
@ -696,9 +679,26 @@ 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 })
},
queueFlushAll ({ rootState, commit }) {
commit('queueFlushAll')
},
markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({

View File

@ -1,6 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, merge, last, concat, uniq } from 'lodash'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
@ -23,6 +23,13 @@ export const mergeOrAdd = (arr, obj, item) => {
}
}
const mergeArrayLength = (oldValue, newValue) => {
if (isArray(oldValue) && isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
@ -120,7 +127,7 @@ export const mutations = {
},
setCurrentUser (state, user) {
state.lastLoginName = user.screen_name
state.currentUser = merge(state.currentUser || {}, user)
state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
},
clearCurrentUser (state) {
state.currentUser = false
@ -266,6 +273,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) => {

View File

@ -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`
@ -139,20 +142,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json())
}
const updateAvatar = ({ credentials, avatar }) => {
const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
const form = new FormData()
form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBg = ({ credentials, background }) => {
const form = new FormData()
form.append('pleroma_background_image', background)
if (avatar !== null) form.append('avatar', avatar)
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
@ -162,17 +156,6 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data))
}
const updateBanner = ({ credentials, banner }) => {
const form = new FormData()
form.append('header', banner)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
@ -499,7 +482,8 @@ const fetchTimeline = ({
until = false,
userId = false,
tag = false,
withMuted = false
withMuted = false,
replyVisibility = 'all'
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
@ -510,7 +494,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,9 +524,12 @@ 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') {
params.push(['reply_visibility', replyVisibility])
}
params.push(['limit', 20])
@ -549,16 +537,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
@ -609,6 +601,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,
@ -618,7 +626,8 @@ const postStatus = ({
poll,
mediaIds = [],
inReplyToStatusId,
contentType
contentType,
preview
}) => {
const form = new FormData()
const pollOptions = poll.options || []
@ -648,6 +657,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,
@ -655,13 +667,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))
}
@ -683,6 +689,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)
@ -1161,9 +1178,12 @@ const apiService = {
unfavorite,
retweet,
unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus,
deleteStatus,
uploadMedia,
setMediaDescription,
fetchMutes,
muteUser,
unmuteUser,
@ -1181,10 +1201,8 @@ const apiService = {
deactivateUser,
register,
getCaptcha,
updateAvatar,
updateBg,
updateProfileImages,
updateProfile,
updateBanner,
importBlocks,
importFollows,
deleteAccount,

View File

@ -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 })
},

View File

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

View File

@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchFollowRequests({ credentials })
.then((requests) => {
store.commit('setFollowRequests', requests)
store.commit('addNewUsers', requests)
}, () => {})
.catch(() => {})
}

View File

@ -43,3 +43,47 @@ export const filteredNotificationsFromStore = (store, types) => {
export const unseenNotificationsFromStore = store =>
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
export const prepareNotificationObject = (notification, i18n) => {
const notifObj = {
tag: notification.id
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
status.attachments[0].mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
return notifObj
}

View File

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

View File

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

View File

@ -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'],

View File

@ -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
})
}
@ -30,7 +31,8 @@ const fetchAndUpdate = ({
const rootState = store.rootState || store.state
const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
const { hideMutedPosts, replyVisibility } = getters.mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) {
args['until'] = until || timelineData.minId
@ -41,20 +43,23 @@ const fetchAndUpdate = ({
args['userId'] = userId
args['tag'] = tag
args['withMuted'] = !hideMutedPosts
if (loggedIn) args['replyVisibility'] = replyVisibility
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 }))
}

View File

@ -1,6 +1,19 @@
/* eslint-env serviceworker */
import localForage from 'localforage'
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import messages from './i18n/service_worker_messages.js'
Vue.use(VueI18n)
const i18n = new VueI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages
})
function isEnabled () {
return localForage.getItem('vuex-lz')
@ -12,15 +25,33 @@ function getWindowClients () {
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
self.addEventListener('push', (event) => {
if (event.data) {
event.waitUntil(isEnabled().then((isEnabled) => {
return isEnabled && getWindowClients().then((list) => {
const data = event.data.json()
const setLocale = async () => {
const state = await localForage.getItem('vuex-lz')
const locale = state.config.interfaceLanguage || 'en'
i18n.locale = locale
}
if (list.length === 0) return self.registration.showNotification(data.title, data)
})
}))
const maybeShowNotification = async (event) => {
const enabled = await isEnabled()
const activeClients = await getWindowClients()
await setLocale()
if (enabled && (activeClients.length === 0)) {
const data = event.data.json()
const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
const notification = await fetch(url, { headers: { Authorization: 'Bearer ' + data.access_token } })
const notificationJson = await notification.json()
const parsedNotification = parseNotification(notificationJson)
const res = prepareNotificationObject(parsedNotification, i18n)
self.registration.showNotification(res.title, res)
}
}
self.addEventListener('push', async (event) => {
if (event.data) {
event.waitUntil(maybeShowNotification(event))
}
})

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

@ -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"
}
]
}

View File

@ -18,6 +18,42 @@ describe('The users module', () => {
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
it('merging array field in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = {
id: '1',
fields: [
{ name: 'Label 1', value: 'Content 1' }
]
}
const firstModUser = {
id: '1',
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' }
]
}
const secondModUser = {
id: '1',
fields: [
{ name: 'Label 4', value: 'Content 4' }
]
}
mutations.addNewUsers(state, [user])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 1')
mutations.addNewUsers(state, [firstModUser])
expect(state.users[0].fields).to.have.length(2)
expect(state.users[0].fields[0].name).to.eql('Label 2')
expect(state.users[0].fields[1].name).to.eql('Label 3')
mutations.addNewUsers(state, [secondModUser])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 4')
})
})
describe('findUser', () => {

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 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'
})
})
})
})

View File

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