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 ### Changed
- Greentext now has separate color slot for it - Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications - Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed ### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully) - 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 - Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu) - 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances - 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 ### Changed
- Registration page no longer requires email if the server is configured not to require it - Registration page no longer requires email if the server is configured not to require it
@ -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 - Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable - Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files - Add better visual indication for drag-and-drop for files
- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc
### Fixed ### Fixed
- Custom Emoji will display in poll options now. - Custom Emoji will display in poll options now.
@ -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 - Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column - Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results - Fixed autocomplete sometimes not returning the right user when there's already some results
- Videos and audio and misc files show description as alt/title properly now
- Clicking on non-image/video files no longer opens an empty modal
- Audio files can now be played back in the frontend with hidden attachments
- Videos are not cropped awkwardly in the uploads section anymore
- Reply filtering options in Settings -> Filtering now work again using filtering on server
- Don't show just blank-screen when cookies are disabled
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### 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 - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed ### Fixed

View file

@ -8,8 +8,6 @@
> >
> --Catbag > --Catbag
Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## Posting, reading, basic functions. ## Posting, reading, basic functions.
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column. After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.

8
docs/index.md Normal file
View file

@ -0,0 +1,8 @@
# Introduction to Pleroma-FE
## What is Pleroma-FE?
Pleroma-FE is the default user-facing frontend for Pleroma. It's user interface is modeled after Qvitter which is modeled after an older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## How can I use it?
If your instance uses Pleroma-FE, you can acces it by going to your instance (e.g. <https://pleroma.soykaf.com>). You can read more about it's basic functionality in the [Pleroma-FE User Guide](./USER_GUIDE.md). We also have [a guide for administrators](./CONFIGURATION.md) and for [hackers/contributors](./HACKING.md).

View file

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

View file

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

View file

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

View file

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

View file

@ -8,38 +8,72 @@ import backendInteractorService from '../services/backend_interactor_service/bac
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.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 { try {
const res = await window.fetch('/api/statusnet/config.json') const res = await preloadFetch('/api/v1/instance')
if (res.ok) { if (res.ok) {
const data = await res.json() 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: 'textlimit', value: textlimit })
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) })
}
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
} }
return data.site.pleromafe
} else { } else {
throw (res) throw (res)
} }
} catch (error) { } 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) console.error(error)
} }
} }
@ -132,7 +166,7 @@ const getTOS = async ({ store }) => {
const getInstancePanel = async ({ store }) => { const getInstancePanel = async ({ store }) => {
try { try {
const res = await window.fetch('/instance/panel.html') const res = await preloadFetch('/instance/panel.html')
if (res.ok) { if (res.ok) {
const html = await res.text() const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
@ -189,24 +223,33 @@ const getAppSecret = async ({ store }) => {
const resolveStaffAccounts = ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop()) const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
} }
const getNodeInfo = async ({ store }) => { const getNodeInfo = async ({ store }) => {
try { try {
const res = await window.fetch('/nodeinfo/2.0.json') const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features 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: '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: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) 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: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@ -257,7 +300,7 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => { const setConfig = async ({ store }) => {
// apiConfig, staticConfig // apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()]) const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0] const apiConfig = configInfos[0]
const staticConfig = configInfos[1] const staticConfig = configInfos[1]
@ -280,6 +323,11 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => { const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth() const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800) 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 }) await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config 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 // 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([ await Promise.all([
checkOAuthToken({ store }), checkOAuthToken({ store }),
getTOS({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getStickers({ store }), getNodeInfo({ store }),
getNodeInfo({ store }) getInstanceConfig({ store })
]) ])
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', 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 PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
@ -40,6 +41,7 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct', { name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',

View file

@ -8,7 +8,6 @@ const Attachment = {
props: [ props: [
'attachment', 'attachment',
'nsfw', 'nsfw',
'statusId',
'size', 'size',
'allowPlay', 'allowPlay',
'setMedia', 'setMedia',
@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment VideoAttachment
}, },
computed: { computed: {
usePlaceHolder () { usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown' return this.size === 'hide' || this.type === 'unknown'
}, },
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass () {
if (this.type === 'image') return 'icon-picture'
if (this.type === 'video') return 'icon-video'
if (this.type === 'audio') return 'icon-music'
return 'icon-doc'
},
referrerpolicy () { referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
}, },
@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small' return this.size === 'small'
}, },
fullwidth () { fullwidth () {
return this.type === 'html' || this.type === 'audio' if (this.size === 'hide') return false
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
},
useModal () {
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
: this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
return modalTypes.includes(this.type)
}, },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
@ -60,12 +79,7 @@ const Attachment = {
} }
}, },
openModal (event) { openModal (event) {
const modalTypes = this.mergedConfig.playVideosInModal if (this.useModal) {
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
this.usePlaceHolder
) {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
this.setMedia() this.setMedia()

View file

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

View file

@ -0,0 +1,17 @@
import Timeline from '../timeline/timeline.vue'
const Bookmarks = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.bookmarks
}
},
components: {
Timeline
},
destroyed () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}
export default Bookmarks

View file

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

View file

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

View file

@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink) navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
} }
}, },
computed: { computed: {

View file

@ -40,6 +40,22 @@
> >
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<button
v-if="!status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
<i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
<i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
</button>
<button <button
v-if="canDelete" v-if="canDelete"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"

View file

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

View file

@ -0,0 +1,15 @@
const GlobalNoticeList = {
computed: {
notices () {
return this.$store.state.interface.globalNotices
}
},
methods: {
closeNotice (notice) {
this.$store.dispatch('removeGlobalNotice', notice)
}
}
}
export default GlobalNoticeList

View file

@ -0,0 +1,77 @@
<template>
<div class="global-notice-list">
<div
v-for="(notice, index) in notices"
:key="index"
class="alert global-notice"
:class="{ ['global-' + notice.level]: true }"
>
<div class="notice-message">
{{ $t(notice.messageKey, notice.messageArgs) }}
</div>
<i
class="button-icon icon-cancel"
@click="closeNotice(notice)"
/>
</div>
</div>
</template>
<script src="./global_notice_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.global-notice-list {
position: fixed;
top: 50px;
width: 100%;
pointer-events: none;
z-index: 1001;
display: flex;
flex-direction: column;
align-items: center;
.global-notice {
pointer-events: auto;
text-align: center;
width: 40em;
max-width: calc(100% - 3em);
display: flex;
padding-left: 1.5em;
line-height: 2em;
.notice-message {
flex: 1 1 100%;
}
i {
flex: 0 0;
width: 1.5em;
cursor: pointer;
}
}
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
i {
color: var(--alertPopupErrorText, $fallback--text);
}
}
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
i {
color: var(--alertPopupWarningText, $fallback--text);
}
}
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
i {
color: var(--alertPopupNeutralText, $fallback--text);
}
}
}
</style>

View file

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

View file

@ -17,6 +17,11 @@
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link> </router-link>
</li> </li>
<li v-if="currentUser">
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked"> <li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }"> <router-link :to="{ name: 'friend-requests' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.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 seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: { computed: {
mainClass () { mainClass () {
return this.minimalMode ? '' : 'panel panel-default' return this.minimalMode ? '' : 'panel panel-default'
@ -56,11 +61,6 @@ const Notifications = {
components: { components: {
Notification Notification
}, },
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: { watch: {
unseenCount (count) { unseenCount (count) {
if (count > 0) { if (count > 0) {

View file

@ -118,6 +118,11 @@
flex: 1; flex: 1;
padding-left: 0.8em; padding-left: 0.8em;
min-width: 0; min-width: 0;
.timeago {
min-width: 3em;
text-align: right;
}
} }
.emoji-reaction-emoji { .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 ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
@ -38,7 +40,9 @@ const PostStatusForm = {
EmojiInput, EmojiInput,
PollForm, PollForm,
ScopeSelector, ScopeSelector,
Checkbox Checkbox,
Attachment,
StatusContent
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
@ -78,13 +82,16 @@ const PostStatusForm = {
nsfw: false, nsfw: false,
files: [], files: [],
poll: {}, poll: {},
mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
dropStopTimeout: null dropStopTimeout: null,
preview: null,
previewLoading: false
} }
}, },
computed: { computed: {
@ -163,19 +170,30 @@ const PostStatusForm = {
this.newStatus.poll && this.newStatus.poll &&
this.newStatus.poll.error this.newStatus.poll.error
}, },
showPreview () {
return !!this.preview || this.previewLoading
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
watch: {
'newStatus.contentType': function () {
this.autoPreview()
},
'newStatus.spoilerText': function () {
this.autoPreview()
}
},
methods: { methods: {
postStatus (newStatus) { async postStatus (newStatus) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.submitDisabled) { return }
if (this.emptyStatus) {
if (this.newStatus.status === '') { this.error = this.$t('post_status.empty_status_error')
if (this.newStatus.files.length === 0) {
this.error = 'Cannot post an empty status with no files'
return return
} }
}
const poll = this.pollFormVisible ? this.newStatus.poll : {} const poll = this.pollFormVisible ? this.newStatus.poll : {}
if (this.pollContentError) { if (this.pollContentError) {
@ -184,7 +202,16 @@ const PostStatusForm = {
} }
this.posting = true this.posting = true
statusPoster.postStatus({
try {
await this.setAllMediaDescriptions()
} catch (e) {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const data = await statusPoster.postStatus({
status: newStatus.status, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility, visibility: newStatus.visibility,
@ -194,7 +221,8 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll poll
}).then((data) => { })
if (!data.error) { if (!data.error) {
this.newStatus = { this.newStatus = {
status: '', status: '',
@ -202,7 +230,8 @@ const PostStatusForm = {
files: [], files: [],
visibility: newStatus.visibility, visibility: newStatus.visibility,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {} poll: {},
mediaDescriptions: {}
} }
this.pollFormVisible = false this.pollFormVisible = false
this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload.clearFile()
@ -212,12 +241,64 @@ const PostStatusForm = {
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = undefined el.style.height = undefined
this.error = null this.error = null
if (this.preview) this.previewStatus()
} else { } else {
this.error = data.error this.error = data.error
} }
this.posting = false this.posting = false
},
previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll: {},
preview: true
}).then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
if (!data.error) {
this.preview = data
} else {
this.preview = { error: data.error }
}
}).catch((error) => {
this.preview = { error }
}).finally(() => {
this.previewLoading = false
}) })
}, },
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
autoPreview () {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview () {
this.preview = null
this.previewLoading = false
},
togglePreview () {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
}, },
@ -239,6 +320,7 @@ const PostStatusForm = {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
}, },
paste (e) { paste (e) {
this.autoPreview()
this.resize(e) this.resize(e)
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text // prevent pasting of file as text
@ -273,6 +355,7 @@ const PostStatusForm = {
} }
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.autoPreview()
this.$nextTick(() => { this.$nextTick(() => {
this.resize(this.$refs['textarea']) this.resize(this.$refs['textarea'])
}) })
@ -388,6 +471,15 @@ const PostStatusForm = {
}, },
dismissScopeNotice () { dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
},
setMediaDescription (id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({ store: this.$store, id, description })
},
setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id)))
} }
} }
} }

View file

@ -69,6 +69,44 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<div class="preview-heading faint">
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<i
class="icon-down-open"
:style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
/>
</a>
<i
v-show="previewLoading"
class="icon-spin3 animate-spin"
/>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<EmojiInput <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
@ -77,7 +115,6 @@
class="form-control" class="form-control"
> >
<input <input
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
@ -245,27 +282,18 @@
class="fa button-icon icon-cancel" class="fa button-icon icon-cancel"
@click="removeMediaFile(file)" @click="removeMediaFile(file)"
/> />
<div class="media-upload-container attachment"> <attachment
<img :attachment="file"
v-if="type(file) === 'image'" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
class="thumbnail media-upload" size="small"
:src="file.url" allow-play="false"
/>
<input
v-model="newStatus.mediaDescriptions[file.id]"
type="text"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
> >
<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>
</div> </div>
</div> </div>
<div <div
@ -302,14 +330,6 @@
} }
} }
.post-status-form {
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
}
.post-status-form { .post-status-form {
.form-bottom { .form-bottom {
display: flex; display: flex;
@ -336,6 +356,48 @@
max-width: 10em; max-width: 10em;
} }
.preview-heading {
display: flex;
width: 100%;
.icon-spin3 {
margin-left: auto;
}
}
.preview-toggle {
display: flex;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.icon-down-open {
transition: transform 0.1s;
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
.preview-status {
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
padding: 0.5em;
margin: 0;
line-height: 1.4em;
}
.text-format { .text-format {
.only-format { .only-format {
color: $fallback--faint; color: $fallback--faint;
@ -343,6 +405,12 @@
} }
} }
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
.media-upload-icon, .poll-icon, .emoji-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px; font-size: 26px;
flex: 1; flex: 1;
@ -381,11 +449,9 @@
} }
.media-upload-wrapper { .media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em; margin-right: .2em;
margin-bottom: .5em; margin-bottom: .5em;
width: 18em;
.icon-cancel { .icon-cancel {
display: inline-block; display: inline-block;
@ -399,6 +465,20 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
img, video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
} }
.status-input-wrapper { .status-input-wrapper {
@ -408,28 +488,13 @@
flex-direction: column; flex-direction: column;
} }
.attachments { .media-upload-wrapper .attachments {
padding: 0 0.5em; padding: 0 0.5em;
.attachment { .attachment {
margin: 0; margin: 0;
padding: 0;
position: relative; position: relative;
flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
text-align: center;
audio {
min-width: 300px;
flex: 1 0 auto;
}
a {
display: block;
text-align: left;
line-height: 1.2;
padding: .5em;
}
} }
i { i {

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import unescape from 'lodash/unescape' import unescape from 'lodash/unescape'
import merge from 'lodash/merge'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue' import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js' 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, newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text, newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope, 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, hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers, hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count, hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
@ -23,6 +25,7 @@ const ProfileTab = {
showRole: this.$store.state.users.currentUser.show_role, showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable, discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true, pickAvatarBtnVisible: true,
bannerUploading: false, bannerUploading: false,
@ -62,6 +65,45 @@ const ProfileTab = {
...this.$store.state.instance.emoji, ...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji ...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: { methods: {
@ -74,17 +116,21 @@ const ProfileTab = {
// Backend notation. // Backend notation.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
display_name: this.newName, display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope, default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText, no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows, hide_follows: this.hideFollows,
hide_followers: this.hideFollowers, hide_followers: this.hideFollowers,
discoverable: this.discoverable, discoverable: this.discoverable,
bot: this.bot,
allow_following_move: this.allowFollowingMove, allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount, hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount, hide_followers_count: this.hideFollowersCount,
show_role: this.showRole show_role: this.showRole
/* eslint-enable camelcase */ /* eslint-enable camelcase */
} }).then((user) => { } }).then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user) this.$store.commit('setCurrentUser', user)
}) })
@ -92,6 +138,16 @@ const ProfileTab = {
changeVis (visibility) { changeVis (visibility) {
this.newDefaultScope = 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) { uploadFile (slot, e) {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) { return } if (!file) { return }
@ -121,11 +177,29 @@ const ProfileTab = {
} }
reader.readAsDataURL(file) 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) { submitAvatar (cropper, file) {
const that = this const that = this
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function updateAvatar (avatar) { function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar }) that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
.then((user) => { .then((user) => {
that.$store.commit('addNewUsers', [user]) that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user) that.$store.commit('setCurrentUser', user)
@ -143,11 +217,11 @@ const ProfileTab = {
} }
}) })
}, },
submitBanner () { submitBanner (banner) {
if (!this.bannerPreview) { return } if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => { .then((user) => {
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user) this.$store.commit('setCurrentUser', user)
@ -158,11 +232,11 @@ const ProfileTab = {
}) })
.then(() => { this.bannerUploading = false }) .then(() => { this.bannerUploading = false })
}, },
submitBg () { submitBackground (background) {
if (!this.backgroundPreview) { return } if (!this.backgroundPreview && background !== '') { return }
let background = this.background
this.backgroundUploading = true this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) { if (!data.error) {
this.$store.commit('addNewUsers', [data]) this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data) this.$store.commit('setCurrentUser', data)

View file

@ -13,8 +13,14 @@
height: auto; height: auto;
} }
.banner { .banner-background-preview {
max-width: 100%; max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
} }
.uploading { .uploading {
@ -26,18 +32,40 @@
width: 100%; width: 100%;
} }
.bg { .current-avatar-container {
max-width: 100%; position: relative;
width: 150px;
height: 150px;
} }
.current-avatar { .current-avatar {
display: block; display: block;
width: 150px; width: 100%;
height: 150px; height: 100%;
border-radius: $fallback--avatarRadius; border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $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 { .oauth-tokens {
width: 100%; width: 100%;
@ -79,4 +107,22 @@
.setting-subitem { .setting-subitem {
margin-left: 1.75em; 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') }} {{ $t('settings.discoverable') }}
</Checkbox> </Checkbox>
</p> </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 <button
:disabled="newName && newName.length === 0" :disabled="newName && newName.length === 0"
class="btn btn-default" class="btn btn-default"
@ -108,11 +161,19 @@
<p class="visibility-notice"> <p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }} {{ $t('settings.avatar_size_instruction') }}
</p> </p>
<p>{{ $t('settings.current_avatar') }}</p> <div class="current-avatar-container">
<img <img
:src="user.profile_image_url_original" :src="user.profile_image_url_original"
class="current-avatar" 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> <p>{{ $t('settings.set_new_avatar') }}</p>
<button <button
v-show="pickAvatarBtnVisible" v-show="pickAvatarBtnVisible"
@ -131,15 +192,20 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2> <h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p> <div class="banner-background-preview">
<img <img :src="user.cover_photo">
:src="user.cover_photo" <i
class="banner" 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> <p>{{ $t('settings.set_new_profile_banner') }}</p>
<img <img
v-if="bannerPreview" v-if="bannerPreview"
class="banner" class="banner-background-preview"
:src="bannerPreview" :src="bannerPreview"
> >
<div> <div>
@ -155,7 +221,7 @@
<button <button
v-else-if="bannerPreview" v-else-if="bannerPreview"
class="btn btn-default" class="btn btn-default"
@click="submitBanner" @click="submitBanner(banner)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
@ -172,10 +238,20 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2> <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> <p>{{ $t('settings.set_new_profile_background') }}</p>
<img <img
v-if="backgroundPreview" v-if="backgroundPreview"
class="bg" class="banner-background-preview"
:src="backgroundPreview" :src="backgroundPreview"
> >
<div> <div>
@ -191,7 +267,7 @@
<button <button
v-else-if="backgroundPreview" v-else-if="backgroundPreview"
class="btn btn-default" class="btn btn-default"
@click="submitBg" @click="submitBackground(background)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>

View file

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

View file

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

View file

@ -141,7 +141,7 @@ const Status = {
return this.mergedConfig.hideFilteredStatuses return this.mergedConfig.hideFilteredStatuses
}, },
hideStatus () { hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) return this.deleted || (this.muted && this.hideFilteredStatuses)
}, },
isFocused () { isFocused () {
// retweet or root of an expanded conversation // retweet or root of an expanded conversation
@ -164,37 +164,6 @@ const Status = {
return user && user.screen_name 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 () { replySubject () {
if (!this.status.summary) return '' if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary) const decodedSummary = unescape(this.status.summary)

View file

@ -197,7 +197,7 @@
> >
<StatusPopover <StatusPopover
v-if="!isPreview" v-if="!isPreview"
:status-id="status.in_reply_to_status_id" :status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover" class="reply-to-popover"
style="min-width: 0" style="min-width: 0"
> >
@ -208,7 +208,12 @@
@click.prevent="gotoOriginal(status.in_reply_to_status_id)" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
> >
<i class="button-icon icon-reply" /> <i class="button-icon icon-reply" />
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> <span
class="faint-link reply-to-text"
:class="{ 'strikethrough': !status.parent_visible }"
>
{{ $t('status.reply_to') }}
</span>
</a> </a>
</StatusPopover> </StatusPopover>
<span <span
@ -372,9 +377,6 @@ $status-margin: 0.75em;
} }
.status-el { .status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px; border-left-width: 0px;
min-width: 0; min-width: 0;
border-color: $fallback--border; border-color: $fallback--border;
@ -418,7 +420,7 @@ $status-margin: 0.75em;
max-width: 85%; max-width: 85%;
font-weight: bold; font-weight: bold;
img { img.emoji {
width: 14px; width: 14px;
height: 14px; height: 14px;
vertical-align: middle; vertical-align: middle;
@ -526,6 +528,10 @@ $status-margin: 0.75em;
margin: 0 0.4em 0 0.2em; margin: 0 0.4em 0 0.2em;
} }
.strikethrough {
text-decoration: line-through;
}
.replies-separator { .replies-separator {
margin-left: 0.4em; margin-left: 0.4em;
} }

View file

@ -44,14 +44,14 @@ const StatusContent = {
return lengthScore > 20 return lengthScore > 20
}, },
longSubject () { 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. // 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 () { mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault) return !!this.status.summary && this.localCollapseSubjectDefault
}, },
mightHideBecauseTall () { mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
}, },
hideSubjectStatus () { hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject return this.mightHideBecauseSubject && !this.expandingSubject
@ -99,15 +99,8 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file) file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
) )
}, },
hasImageAttachments () { attachmentTypes () {
return this.status.attachments.some( return this.status.attachments.map(file => fileType.fileType(file.mimetype))
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
}, },
maxThumbnails () { maxThumbnails () {
return this.mergedConfig.maxThumbnails return this.mergedConfig.maxThumbnails
@ -142,12 +135,6 @@ const StatusContent = {
return html return html
} }
}, },
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
...mapGetters(['mergedConfig']), ...mapGetters(['mergedConfig']),
...mapState({ ...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter, betterShadow: state => state.interface.browserSupport.cssFilter,

View file

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

View file

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

View file

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

View file

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

View file

@ -45,11 +45,15 @@ const Timeline = {
newStatusCount () { newStatusCount () {
return this.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) { if (this.timeline.flushMarker !== 0) {
return '' return this.$t('timeline.reload')
} else { } else {
return ` (${this.newStatusCount})` return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
} }
}, },
classes () { classes () {
@ -112,8 +116,6 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses() if (e.key === '.') this.showNewStatuses()
}, },
showNewStatuses () { showNewStatuses () {
if (this.newStatusCount === 0) return
if (this.timeline.flushMarker !== 0) { if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@ -135,7 +137,7 @@ const Timeline = {
showImmediately: true, showImmediately: true,
userId: this.userId, userId: this.userId,
tag: this.tag tag: this.tag
}).then(statuses => { }).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false }) store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) { if (statuses && statuses.length === 0) {
this.bottomedOut = true this.bottomedOut = true

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -120,6 +120,7 @@
"public_tl": "Public Timeline", "public_tl": "Public Timeline",
"timeline": "Timeline", "timeline": "Timeline",
"twkn": "The Whole Known Network", "twkn": "The Whole Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search", "user_search": "User Search",
"search": "Search", "search": "Search",
"who_to_follow": "Who to follow", "who_to_follow": "Who to follow",
@ -163,6 +164,9 @@
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.", "load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji" "load_all": "Loading all {emojiAmount} emoji"
}, },
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": { "interactions": {
"favs_repeats": "Repeats and Favorites", "favs_repeats": "Repeats and Favorites",
"follows": "New follows", "follows": "New follows",
@ -174,6 +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": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": { "content_type": {
"text/plain": "Plain text", "text/plain": "Plain text",
"text/html": "HTML", "text/html": "HTML",
@ -185,6 +190,10 @@
"direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"posting": "Posting", "posting": "Posting",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": { "scope_notice": {
"public": "This post will be visible to everyone", "public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only", "private": "This post will be visible to your followers only",
@ -266,6 +275,7 @@
"block_import_error": "Error importing blocks", "block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.", "blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks", "blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons", "btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)", "cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)", "cGreen": "Green (Retweet)",
@ -283,7 +293,6 @@
"current_avatar": "Your current avatar", "current_avatar": "Your current avatar",
"current_mascot": "Your current mascot", "current_mascot": "Your current mascot",
"current_password": "Current password", "current_password": "Current password",
"current_profile_banner": "Your current profile banner",
"mutes_and_blocks": "Mutes and Blocks", "mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export", "data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope", "default_vis": "Default visibility scope",
@ -334,6 +343,12 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes", "mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame", "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", "use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name", "name": "Name",
"name_bio": "Name & Bio", "name_bio": "Name & Bio",
@ -386,6 +401,12 @@
"set_new_mascot": "Set new mascot", "set_new_mascot": "Set new mascot",
"set_new_profile_background": "Set new profile background", "set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner", "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", "settings": "Settings",
"subject_input_always_show": "Always show subject field", "subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying", "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", "no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated", "repeated": "repeated",
"show_new": "Show new", "show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date", "up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses", "no_more_statuses": "No more statuses",
"no_statuses": "No statuses" "no_statuses": "No statuses"
@ -624,6 +646,8 @@
"pin": "Pin on profile", "pin": "Pin on profile",
"unpin": "Unpin from profile", "unpin": "Unpin from profile",
"pinned": "Pinned", "pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?", "delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to", "reply_to": "Reply to",
"replies_list": "Replies:", "replies_list": "Replies:",
@ -632,7 +656,11 @@
"status_unavailable": "Status unavailable", "status_unavailable": "Status unavailable",
"copy_link": "Copy link to status", "copy_link": "Copy link to status",
"thread_muted": "Thread muted", "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": { "user_card": {
"approve": "Approve", "approve": "Approve",
@ -715,7 +743,8 @@
"add_reaction": "Add Reaction", "add_reaction": "Add Reaction",
"user_settings": "User Settings", "user_settings": "User Settings",
"accept_follow_request": "Accept follow request", "accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request" "reject_follow_request": "Reject follow request",
"bookmark": "Bookmark"
}, },
"upload": { "upload": {
"error": { "error": {

View file

@ -66,7 +66,7 @@
"search": "Haku" "search": "Haku"
}, },
"notifications": { "notifications": {
"broken_favorite": "Viestiä ei löydetty...", "broken_favorite": "Viestiä ei löydetty",
"favorited_you": "tykkäsi viestistäsi", "favorited_you": "tykkäsi viestistäsi",
"followed_you": "seuraa sinua", "followed_you": "seuraa sinua",
"load_older": "Lataa vanhempia ilmoituksia", "load_older": "Lataa vanhempia ilmoituksia",
@ -101,7 +101,7 @@
}, },
"post_status": { "post_status": {
"new_status": "Uusi viesti", "new_status": "Uusi viesti",
"account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi", "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.",
"account_not_locked_warning_link": "lukittu", "account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": { "content_type": {
@ -288,7 +288,7 @@
"authentication_methods": "Todennus", "authentication_methods": "Todennus",
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.", "warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
"recovery_codes": "Palautuskoodit.", "recovery_codes": "Palautuskoodit.",
"waiting_a_recovery_codes": "Odotetaan palautuskoodeja...", "waiting_a_recovery_codes": "Odotetaan palautuskoodeja",
"recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.", "recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.",
"scan": { "scan": {
"title": "Skannaa", "title": "Skannaa",
@ -575,7 +575,7 @@
"statuses": "Viestit", "statuses": "Viestit",
"hidden": "Piilotettu", "hidden": "Piilotettu",
"media": "Media", "media": "Media",
"block_progress": "Estetään...", "block_progress": "Estetään",
"admin_menu": { "admin_menu": {
"grant_admin": "Anna Ylläpitöoikeudet", "grant_admin": "Anna Ylläpitöoikeudet",
"force_nsfw": "Merkitse kaikki viestit NSFW:nä", "force_nsfw": "Merkitse kaikki viestit NSFW:nä",
@ -601,10 +601,10 @@
"subscribe": "Tilaa", "subscribe": "Tilaa",
"unsubscribe": "Poista tilaus", "unsubscribe": "Poista tilaus",
"unblock": "Poista esto", "unblock": "Poista esto",
"unblock_progress": "Postetaan estoa...", "unblock_progress": "Postetaan estoa",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"unmute_progress": "Poistetaan mykistystä...", "unmute_progress": "Poistetaan mykistystä",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"hide_repeats": "Piilota toistot", "hide_repeats": "Piilota toistot",
"show_repeats": "Näytä toistot" "show_repeats": "Näytä toistot"
}, },
@ -674,8 +674,8 @@
"domain_mute_card": { "domain_mute_card": {
"mute": "Mykistä", "mute": "Mykistä",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"unmute_progress": "Poistetaan mykistyst..." "unmute_progress": "Poistetaan mykistyst"
}, },
"exporter": { "exporter": {
"export": "Vie", "export": "Vie",

View file

@ -34,7 +34,8 @@
"user_search": "Ricerca utenti", "user_search": "Ricerca utenti",
"search": "Ricerca", "search": "Ricerca",
"who_to_follow": "Chi seguire", "who_to_follow": "Chi seguire",
"preferences": "Preferenze" "preferences": "Preferenze",
"bookmarks": "Segnalibri"
}, },
"notifications": { "notifications": {
"followed_you": "ti segue", "followed_you": "ti segue",
@ -255,7 +256,8 @@
"top_bar": "Barra superiore", "top_bar": "Barra superiore",
"panel_header": "Titolo pannello", "panel_header": "Titolo pannello",
"badge_notification": "Notifica", "badge_notification": "Notifica",
"popover": "Suggerimenti, menù, sbalzi" "popover": "Suggerimenti, menù, sbalzi",
"toggled": "Scambiato"
}, },
"common_colors": { "common_colors": {
"rgbo": "Icone, accenti, medaglie", "rgbo": "Icone, accenti, medaglie",
@ -270,10 +272,59 @@
"shadow_id": "Ombra numero {value}", "shadow_id": "Ombra numero {value}",
"override": "Sostituisci", "override": "Sostituisci",
"component": "Componente", "component": "Componente",
"_tab_label": "Luci ed ombre" "_tab_label": "Luci ed ombre",
"components": {
"avatarStatus": "Icona utente (vista messaggio)",
"avatar": "Icona utente (vista profilo)",
"topBar": "Barra superiore",
"panelHeader": "Intestazione pannello",
"panel": "Pannello",
"input": "Campo d'immissione",
"buttonPressedHover": "Pulsante (puntato e premuto)",
"buttonPressed": "Pulsante (premuto)",
"buttonHover": "Pulsante (puntato)",
"button": "Pulsante",
"popup": "Sbalzi e suggerimenti"
},
"filter_hint": {
"inset_classic": "Le ombre incluse usano {0}",
"spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre",
"avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
"always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta."
},
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore."
}, },
"radii": { "radii": {
"_tab_label": "Raggio" "_tab_label": "Raggio"
},
"fonts": {
"_tab_label": "Font",
"custom": "Personalizzato",
"weight": "Peso (grassettatura)",
"size": "Dimensione (in pixel)",
"family": "Nome font",
"components": {
"postCode": "Font a spaziatura fissa incluso in un messaggio",
"post": "Testo del messaggio",
"input": "Campi d'immissione",
"interface": "Interfaccia"
},
"help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema."
},
"preview": {
"link": "un bel collegamentino",
"checkbox": "Ho dato uno sguardo a termini e condizioni",
"header_faint": "Tutto bene",
"fine_print": "Leggi il nostro {0} per imparare un bel niente!",
"faint_link": "utilissimo manuale",
"input": "Sono appena atterrato a Fiumicino.",
"mono": "contenuto",
"text": "Altro {0} e {1}",
"content": "Contenuto",
"button": "Pulsante",
"error": "Errore d'esempio",
"header": "Anteprima"
} }
}, },
"enable_web_push_notifications": "Abilita notifiche web push", "enable_web_push_notifications": "Abilita notifiche web push",
@ -335,7 +386,19 @@
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.", "notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.",
"mutes_and_blocks": "Zittiti e bloccati" "mutes_and_blocks": "Zittiti e bloccati",
"profile_fields": {
"value": "Contenuto",
"name": "Etichetta",
"add_field": "Aggiungi campo",
"label": "Metadati profilo"
},
"bot": "Questo profilo è di un robot",
"version": {
"frontend_version": "Versione interfaccia",
"backend_version": "Versione backend",
"title": "Versione"
}
}, },
"timeline": { "timeline": {
"error_fetching": "Errore nell'aggiornamento", "error_fetching": "Errore nell'aggiornamento",
@ -345,7 +408,10 @@
"collapse": "Riduci", "collapse": "Riduci",
"conversation": "Conversazione", "conversation": "Conversazione",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso", "no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
"repeated": "condiviso" "repeated": "condiviso",
"no_statuses": "Nessun messaggio",
"no_more_statuses": "Fine dei messaggi",
"reload": "Ricarica"
}, },
"user_card": { "user_card": {
"follow": "Segui", "follow": "Segui",
@ -424,7 +490,10 @@
}, },
"direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.", "direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.",
"direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.", "direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.",
"new_status": "Nuovo messaggio" "new_status": "Nuovo messaggio",
"empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati",
"preview_empty": "Vuoto",
"preview": "Anteprima"
}, },
"registration": { "registration": {
"bio": "Introduzione", "bio": "Introduzione",
@ -547,5 +616,50 @@
"error": "Non trovato.", "error": "Non trovato.",
"searching_for": "Cerco", "searching_for": "Cerco",
"remote_user_resolver": "Cerca utenti remoti" "remote_user_resolver": "Cerca utenti remoti"
},
"errors": {
"storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie."
},
"status": {
"pinned": "Intestato",
"unpin": "De-intesta",
"pin": "Intesta al profilo",
"delete": "Elimina messaggio",
"repeats": "Condivisi",
"favorites": "Preferiti"
},
"time": {
"years_short": "{0}a",
"year_short": "{0}a",
"years": "{0} anni",
"year": "{0} anno",
"weeks_short": "{0}set",
"week_short": "{0}set",
"seconds_short": "{0}sec",
"second_short": "{0}sec",
"weeks": "{0} settimane",
"week": "{0} settimana",
"seconds": "{0} secondi",
"second": "{0} secondo",
"now_short": "ora",
"now": "adesso",
"months_short": "{0}me",
"month_short": "{0}me",
"months": "{0} mesi",
"month": "{0} mese",
"minutes_short": "{0}min",
"minute_short": "{0}min",
"minutes": "{0} minuti",
"minute": "{0} minuto",
"in_past": "{0} fa",
"in_future": "fra {0}",
"hours_short": "{0}h",
"days_short": "{0}g",
"hour_short": "{0}h",
"hours": "{0} ore",
"hour": "{0} ora",
"day_short": "{0}g",
"days": "{0} giorni",
"day": "{0} giorno"
} }
} }

View file

@ -28,7 +28,12 @@
"enable": "Inschakelen", "enable": "Inschakelen",
"confirm": "Bevestigen", "confirm": "Bevestigen",
"verify": "Verifiëren", "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": {
"login": "Log in", "login": "Log in",
@ -90,7 +95,7 @@
"text/bbcode": "BBCode" "text/bbcode": "BBCode"
}, },
"content_warning": "Onderwerp (optioneel)", "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.", "direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
"posting": "Plaatsen", "posting": "Plaatsen",
"scope": { "scope": {
@ -377,7 +382,7 @@
"button": "Knop", "button": "Knop",
"text": "Nog een boel andere {0} en {1}", "text": "Nog een boel andere {0} en {1}",
"mono": "inhoud", "mono": "inhoud",
"input": "Zojuist geland in L.A.", "input": "Tijd voor anime!",
"faint_link": "handige gebruikershandleiding", "faint_link": "handige gebruikershandleiding",
"fine_print": "Lees onze {0} om niets nuttig te leren!", "fine_print": "Lees onze {0} om niets nuttig te leren!",
"header_faint": "Alles komt goed", "header_faint": "Alles komt goed",
@ -451,7 +456,7 @@
"user_mutes": "Gebruikers", "user_mutes": "Gebruikers",
"useStreamingApi": "Berichten en meldingen in real-time ontvangen", "useStreamingApi": "Berichten en meldingen in real-time ontvangen",
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)", "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", "upload_a_photo": "Upload een foto",
"fun": "Plezier", "fun": "Plezier",
"greentext": "Meme pijlen", "greentext": "Meme pijlen",
@ -470,7 +475,15 @@
"frontend_version": "Frontend Versie", "frontend_version": "Frontend Versie",
"backend_version": "Backend Versie", "backend_version": "Backend Versie",
"title": "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": { "timeline": {
"collapse": "Inklappen", "collapse": "Inklappen",
@ -708,7 +721,9 @@
"unpin": "Van profiel losmaken", "unpin": "Van profiel losmaken",
"delete": "Status verwijderen", "delete": "Status verwijderen",
"repeats": "Herhalingen", "repeats": "Herhalingen",
"favorites": "Favorieten" "favorites": "Favorieten",
"thread_muted_and_words": ", heeft woorden:",
"thread_muted": "Thread genegeerd"
}, },
"time": { "time": {
"years_short": "{0}j", "years_short": "{0}j",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import {
omitBy omitBy
} from 'lodash' } from 'lodash'
import { set } from 'vue' 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 apiService from '../services/api/api.service.js'
import { muteWordHits } from '../services/status_parser/status_parser.js' import { muteWordHits } from '../services/status_parser/status_parser.js'
@ -62,7 +62,8 @@ export const defaultState = () => ({
publicAndExternal: emptyTl(), publicAndExternal: emptyTl(),
friends: emptyTl(), friends: emptyTl(),
tag: emptyTl(), tag: emptyTl(),
dms: emptyTl() dms: emptyTl(),
bookmarks: emptyTl()
} }
}) })
@ -163,8 +164,7 @@ const removeStatusFromGlobalStorage = (state, status) => {
} }
} }
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
noIdUpdate = false, userId }) => {
// Sanity check // Sanity check
if (!isArray(statuses)) { if (!isArray(statuses)) {
return false return false
@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const allStatuses = state.allStatuses const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline] const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 // Mismatch between API pagination and our internal minId/maxId tracking systems:
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 // pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0 const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0 const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
@ -315,7 +320,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
}) })
// Keep the visible statuses sorted // Keep the visible statuses sorted
if (timeline) { if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject) sortTimeline(timelineObject)
} }
} }
@ -344,42 +349,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
state.notifications.idStore[notification.id] = notification state.notifications.idStore[notification.id] = notification
if ('Notification' in window && window.Notification.permission === 'granted') { if ('Notification' in window && window.Notification.permission === 'granted') {
const notifObj = {} const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
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 reasonsToMuteNotif = ( const reasonsToMuteNotif = (
notification.seen || notification.seen ||
@ -393,7 +363,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
) )
) )
if (!reasonsToMuteNotif) { 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 // Chrome is known for not closing notifications automatically
// according to MDN, anyway. // according to MDN, anyway.
setTimeout(desktopNotification.close.bind(desktopNotification), 5000) setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
@ -498,6 +468,14 @@ export const mutations = {
newStatus.rebloggedBy.push(user) newStatus.rebloggedBy.push(user)
} }
}, },
setBookmarked (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
},
setBookmarkedConfirm (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
},
setDeleted (state, { status }) { setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true newStatus.deleted = true
@ -550,6 +528,11 @@ export const mutations = {
queueFlush (state, { timeline, id }) { queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = 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 }) { addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id] const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
@ -620,8 +603,8 @@ export const mutations = {
const statuses = { const statuses = {
state: defaultState(), state: defaultState(),
actions: { actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
}, },
addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
@ -696,9 +679,26 @@ const statuses = {
rootState.api.backendInteractor.unretweet({ id: status.id }) rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) .then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
}, },
bookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush ({ rootState, commit }, { timeline, id }) { queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id }) commit('queueFlush', { timeline, id })
}, },
queueFlushAll ({ rootState, commit }) {
commit('queueFlushAll')
},
markNotificationsAsSeen ({ rootState, commit }) { markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen') commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({ apiService.markNotificationsAsSeen({

View file

@ -1,6 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.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 { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id] const oldItem = obj[item.id]
if (oldItem) { if (oldItem) {
// We already have this, so only merge the new info. // We already have this, so only merge the new info.
merge(oldItem, item) mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false } return { item: oldItem, new: false }
} else { } else {
// This is a new item, prepare it // 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 getNotificationPermission = () => {
const Notification = window.Notification const Notification = window.Notification
@ -120,7 +127,7 @@ export const mutations = {
}, },
setCurrentUser (state, user) { setCurrentUser (state, user) {
state.lastLoginName = user.screen_name state.lastLoginName = user.screen_name
state.currentUser = merge(state.currentUser || {}, user) state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
}, },
clearCurrentUser (state) { clearCurrentUser (state) {
state.currentUser = false state.currentUser = false
@ -266,6 +273,11 @@ const users = {
mutations, mutations,
getters, getters,
actions: { actions: {
fetchUserIfMissing (store, id) {
if (!store.getters.findUser(id)) {
store.dispatch('fetchUser', id)
}
},
fetchUser (store, id) { fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id }) return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => { .then((user) => {

View file

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash' import { each, map, concat, last, get } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* eslint-env browser */
@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/' const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/' const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
@ -58,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes` const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@ -139,20 +142,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json()) }).then((data) => data.json())
} }
const updateAvatar = ({ credentials, avatar }) => { const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
const form = new FormData() const form = new FormData()
form.append('avatar', avatar) if (avatar !== null) form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, { if (banner !== null) form.append('header', banner)
headers: authHeaders(credentials), if (background !== null) form.append('pleroma_background_image', background)
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)
return fetch(MASTODON_PROFILE_UPDATE_URL, { return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'PATCH', method: 'PATCH',
@ -162,17 +156,6 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data)) .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 }) => { const updateProfile = ({ credentials, params }) => {
return promisedRequest({ return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL, url: MASTODON_PROFILE_UPDATE_URL,
@ -499,7 +482,8 @@ const fetchTimeline = ({
until = false, until = false,
userId = false, userId = false,
tag = false, tag = false,
withMuted = false withMuted = false,
replyVisibility = 'all'
}) => { }) => {
const timelineUrls = { const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE, public: MASTODON_PUBLIC_TIMELINE,
@ -510,7 +494,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL, user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
} }
const isNotifications = timeline === 'notifications' const isNotifications = timeline === 'notifications'
const params = [] const params = []
@ -539,9 +524,12 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') { if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false]) params.push(['only_media', false])
} }
if (timeline !== 'favorites') { if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted]) params.push(['with_muted', withMuted])
} }
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
params.push(['limit', 20]) params.push(['limit', 20])
@ -549,16 +537,20 @@ const fetchTimeline = ({
url += `?${queryString}` url += `?${queryString}`
let status = '' let status = ''
let statusText = '' let statusText = ''
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => { .then((data) => {
status = data.status status = data.status
statusText = data.statusText statusText = data.statusText
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
})
return data return data
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
return data.map(isNotifications ? parseNotification : parseStatus) return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else { } else {
data.status = status data.status = status
data.statusText = statusText data.statusText = statusText
@ -609,6 +601,22 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
} }
const bookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const unbookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const postStatus = ({ const postStatus = ({
credentials, credentials,
status, status,
@ -618,7 +626,8 @@ const postStatus = ({
poll, poll,
mediaIds = [], mediaIds = [],
inReplyToStatusId, inReplyToStatusId,
contentType contentType,
preview
}) => { }) => {
const form = new FormData() const form = new FormData()
const pollOptions = poll.options || [] const pollOptions = poll.options || []
@ -648,6 +657,9 @@ const postStatus = ({
if (inReplyToStatusId) { if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId) form.append('in_reply_to_id', inReplyToStatusId)
} }
if (preview) {
form.append('preview', 'true')
}
return fetch(MASTODON_POST_STATUS_URL, { return fetch(MASTODON_POST_STATUS_URL, {
body: form, body: form,
@ -655,13 +667,7 @@ const postStatus = ({
headers: authHeaders(credentials) headers: authHeaders(credentials)
}) })
.then((response) => { .then((response) => {
if (response.ok) {
return response.json() return response.json()
} else {
return {
error: response
}
}
}) })
.then((data) => data.error ? data : parseStatus(data)) .then((data) => data.error ? data : parseStatus(data))
} }
@ -683,6 +689,17 @@ const uploadMedia = ({ formData, credentials }) => {
.then((data) => parseAttachment(data)) .then((data) => parseAttachment(data))
} }
const setMediaDescription = ({ id, description, credentials }) => {
return promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
headers: authHeaders(credentials),
payload: {
description
}
}).then((data) => parseAttachment(data))
}
const importBlocks = ({ file, credentials }) => { const importBlocks = ({ file, credentials }) => {
const formData = new FormData() const formData = new FormData()
formData.append('list', file) formData.append('list', file)
@ -1161,9 +1178,12 @@ const apiService = {
unfavorite, unfavorite,
retweet, retweet,
unretweet, unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus, postStatus,
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
setMediaDescription,
fetchMutes, fetchMutes,
muteUser, muteUser,
unmuteUser, unmuteUser,
@ -1181,10 +1201,8 @@ const apiService = {
deactivateUser, deactivateUser,
register, register,
getCaptcha, getCaptcha,
updateAvatar, updateProfileImages,
updateBg,
updateProfile, updateProfile,
updateBanner,
importBlocks, importBlocks,
importFollows, importFollows,
deleteAccount, deleteAccount,

View file

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

View file

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

View file

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

View file

@ -43,3 +43,47 @@ export const filteredNotificationsFromStore = (store, types) => {
export const unseenNotificationsFromStore = store => export const unseenNotificationsFromStore = store =>
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen) 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 }) const result = fetchNotifications({ store, args, older })
// load unread notifications repeatedly to provide consistency between browser tabs // If there's any unread notifications, try fetch notifications since
// the newest read notification to check if any of the unread notifs
// have changed their 'seen' state (marked as read in another session), so
// we can update the state in this session to mark them as read as well.
// The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data const notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
if (readNotifsIds.length) { const numUnseenNotifs = notifications.length - readNotifsIds.length
if (numUnseenNotifs > 0) {
args['since'] = Math.max(...readNotifsIds) args['since'] = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older }) fetchNotifications({ store, args, older })
} }
return result return result
} }
} }
const fetchNotifications = ({ store, args, older }) => { const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((notifications) => { .then(({ data: notifications }) => {
update({ store, notifications, older }) update({ store, notifications, older })
return notifications return notifications
}, () => store.dispatch('setNotificationsError', { value: true })) }, () => store.dispatch('setNotificationsError', { value: true }))

View file

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

View file

@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5, alert: 0.5,
input: 0.5, input: 0.5,
faint: 0.5, faint: 0.5,
underlay: 0.15 underlay: 0.15,
alertPopup: 0.95
} }
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta /** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
textColor: true textColor: true
}, },
alertPopupError: {
depends: ['alertError'],
opacity: 'alertPopup'
},
alertPopupErrorText: {
depends: ['alertErrorText'],
layer: 'popover',
variant: 'alertPopupError',
textColor: true
},
alertPopupWarning: {
depends: ['alertWarning'],
opacity: 'alertPopup'
},
alertPopupWarningText: {
depends: ['alertWarningText'],
layer: 'popover',
variant: 'alertPopupWarning',
textColor: true
},
alertPopupNeutral: {
depends: ['alertNeutral'],
opacity: 'alertPopup'
},
alertPopupNeutralText: {
depends: ['alertNeutralText'],
layer: 'popover',
variant: 'alertPopupNeutral',
textColor: true
},
badgeNotification: '--cRed', badgeNotification: '--cRed',
badgeNotificationText: { badgeNotificationText: {
depends: ['text', 'badgeNotification'], depends: ['text', 'badgeNotification'],

View file

@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js' import apiService from '../api/api.service.js'
const update = ({ store, statuses, timeline, showImmediately, userId }) => { const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
const ccTimeline = camelCase(timeline) const ccTimeline = camelCase(timeline)
store.dispatch('setError', { value: false }) store.dispatch('setError', { value: false })
@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline, timeline: ccTimeline,
userId, userId,
statuses, statuses,
showImmediately showImmediately,
pagination
}) })
} }
@ -30,7 +31,8 @@ const fetchAndUpdate = ({
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const { getters } = store const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)] const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const hideMutedPosts = getters.mergedConfig.hideMutedPosts const { hideMutedPosts, replyVisibility } = getters.mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) { if (older) {
args['until'] = until || timelineData.minId args['until'] = until || timelineData.minId
@ -41,20 +43,23 @@ const fetchAndUpdate = ({
args['userId'] = userId args['userId'] = userId
args['tag'] = tag args['tag'] = tag
args['withMuted'] = !hideMutedPosts args['withMuted'] = !hideMutedPosts
if (loggedIn) args['replyVisibility'] = replyVisibility
const numStatusesBeforeFetch = timelineData.statuses.length const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((statuses) => { .then(response => {
if (statuses.error) { if (response.error) {
store.dispatch('setErrorData', { value: statuses }) store.dispatch('setErrorData', { value: response })
return return
} }
const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
} }
update({ store, statuses, timeline, showImmediately, userId }) update({ store, statuses, timeline, showImmediately, userId, pagination })
return statuses return { statuses, pagination }
}, () => store.dispatch('setError', { value: true })) }, () => store.dispatch('setError', { value: true }))
} }

View file

@ -1,6 +1,19 @@
/* eslint-env serviceworker */ /* eslint-env serviceworker */
import localForage from 'localforage' 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 () { function isEnabled () {
return localForage.getItem('vuex-lz') return localForage.getItem('vuex-lz')
@ -12,15 +25,33 @@ function getWindowClients () {
.then((clientList) => clientList.filter(({ type }) => type === 'window')) .then((clientList) => clientList.filter(({ type }) => type === 'window'))
} }
self.addEventListener('push', (event) => { const setLocale = async () => {
if (event.data) { const state = await localForage.getItem('vuex-lz')
event.waitUntil(isEnabled().then((isEnabled) => { const locale = state.config.interfaceLanguage || 'en'
return isEnabled && getWindowClients().then((list) => { i18n.locale = locale
}
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 data = event.data.json()
if (list.length === 0) return self.registration.showNotification(data.title, data) 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", "css": "download",
"code": 59429, "code": 59429,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "f04a5d24e9e659145b966739c4fde82a",
"css": "bookmark",
"code": 59430,
"src": "fontawesome"
},
{
"uid": "2f5ef6f6b7aaebc56458ab4e865beff5",
"css": "bookmark-empty",
"code": 61591,
"src": "fontawesome"
},
{
"uid": "9ea0a737ccc45d6c510dcbae56058849",
"css": "music",
"code": 59432,
"src": "fontawesome"
},
{
"uid": "1b5a5d7b7e3c71437f5a26befdd045ed",
"css": "doc",
"code": 59433,
"src": "fontawesome"
} }
] ]
} }

View file

@ -18,6 +18,42 @@ describe('The users module', () => {
expect(state.users).to.eql([user]) expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude') 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', () => { 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 mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json' import qvitterapidata from '../../../../fixtures/statuses.json'
@ -383,4 +383,24 @@ describe('API Entities normalizer', () => {
expect(result).to.include('title=\':[a-z] {|}*:\'') expect(result).to.include('title=\':[a-z] {|}*:\'')
}) })
}) })
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
'maxId': 861676,
'minId': 861741
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader = '<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
'maxId': '9waQx5IIS48qVue2Ai',
'minId': '9wi61nIPnfn674xgie'
})
})
})
}) })

View file

@ -5751,6 +5751,13 @@ parse-json@^4.0.0:
error-ex "^1.3.1" error-ex "^1.3.1"
json-parse-better-errors "^1.0.1" json-parse-better-errors "^1.0.1"
parse-link-header@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7"
integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc=
dependencies:
xtend "~4.0.1"
parseqs@0.0.5: parseqs@0.0.5:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"