Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop
This commit is contained in:
commit
954d5c05df
76 changed files with 1639 additions and 468 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
### Changed
|
||||
- Greentext now has separate color slot for it
|
||||
- Removed the use of with_move parameters when fetching notifications
|
||||
- Push notifications now are the same as normal notfication, and are localized.
|
||||
|
||||
### Fixed
|
||||
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
|
||||
|
@ -16,6 +17,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Added private notifications option for push notifications
|
||||
- 'Copy link' button for statuses (in the ellipsis menu)
|
||||
- Autocomplete domains from list of known instances
|
||||
- 'Bot' settings option and badge
|
||||
- Added profile meta data fields that can be set in profile settings
|
||||
- Added option to reset avatar/banner in profile settings
|
||||
- Descriptions can be set on uploaded files before posting
|
||||
- Added status preview option to preview your statuses before posting
|
||||
- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
|
||||
|
||||
### Changed
|
||||
- Registration page no longer requires email if the server is configured not to require it
|
||||
|
@ -23,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Close the media modal on navigation events
|
||||
- Add colons to the emoji alt text, to make them copyable
|
||||
- Add better visual indication for drag-and-drop for files
|
||||
- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc
|
||||
|
||||
### Fixed
|
||||
- Custom Emoji will display in poll options now.
|
||||
|
@ -34,6 +42,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Subject field now appears disabled when posting
|
||||
- Fix status ellipsis menu being cut off in notifications column
|
||||
- Fixed autocomplete sometimes not returning the right user when there's already some results
|
||||
- Videos and audio and misc files show description as alt/title properly now
|
||||
- Clicking on non-image/video files no longer opens an empty modal
|
||||
- Audio files can now be played back in the frontend with hidden attachments
|
||||
- Videos are not cropped awkwardly in the uploads section anymore
|
||||
- Reply filtering options in Settings -> Filtering now work again using filtering on server
|
||||
- Don't show just blank-screen when cookies are disabled
|
||||
|
||||
## [2.0.3] - 2020-05-02
|
||||
### Fixed
|
||||
|
@ -95,6 +109,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Ability to change user's email
|
||||
- About page
|
||||
- Added remote user redirect
|
||||
- Bookmarks
|
||||
### Changed
|
||||
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
|
||||
### Fixed
|
||||
|
|
|
@ -8,8 +8,6 @@
|
|||
>
|
||||
> --Catbag
|
||||
|
||||
Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
|
||||
|
||||
## Posting, reading, basic functions.
|
||||
|
||||
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.
|
||||
|
|
8
docs/index.md
Normal file
8
docs/index.md
Normal 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).
|
|
@ -22,6 +22,7 @@
|
|||
"cropperjs": "^1.4.3",
|
||||
"diff": "^3.0.1",
|
||||
"escape-html": "^1.0.3",
|
||||
"parse-link-header": "^1.0.1",
|
||||
"localforage": "^1.5.0",
|
||||
"phoenix": "^1.3.0",
|
||||
"portal-vue": "^2.1.4",
|
||||
|
|
|
@ -13,6 +13,7 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
|
|||
import MobileNav from './components/mobile_nav/mobile_nav.vue'
|
||||
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
|
||||
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
|
||||
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
|
||||
import { windowWidth } from './services/window_utils/window_utils'
|
||||
|
||||
export default {
|
||||
|
@ -32,7 +33,8 @@ export default {
|
|||
MobileNav,
|
||||
SettingsModal,
|
||||
UserReportingModal,
|
||||
PostStatusModal
|
||||
PostStatusModal,
|
||||
GlobalNoticeList
|
||||
},
|
||||
data: () => ({
|
||||
mobileActivePanel: 'timeline',
|
||||
|
|
|
@ -858,6 +858,10 @@ nav {
|
|||
display: block;
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-bottom: 7em;
|
||||
}
|
||||
}
|
||||
|
||||
.select-multiple {
|
||||
|
|
|
@ -128,6 +128,7 @@
|
|||
<PostStatusModal />
|
||||
<SettingsModal />
|
||||
<portal-target name="modal" />
|
||||
<GlobalNoticeList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -8,38 +8,72 @@ import backendInteractorService from '../services/backend_interactor_service/bac
|
|||
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
|
||||
import { applyTheme } from '../services/style_setter/style_setter.js'
|
||||
|
||||
const getStatusnetConfig = async ({ store }) => {
|
||||
let staticInitialResults = null
|
||||
|
||||
const parsedInitialResults = () => {
|
||||
if (!document.getElementById('initial-results')) {
|
||||
return null
|
||||
}
|
||||
if (!staticInitialResults) {
|
||||
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
|
||||
}
|
||||
return staticInitialResults
|
||||
}
|
||||
|
||||
const decodeUTF8Base64 = (data) => {
|
||||
const rawData = atob(data)
|
||||
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
|
||||
const text = new TextDecoder().decode(array)
|
||||
return text
|
||||
}
|
||||
|
||||
const preloadFetch = async (request) => {
|
||||
const data = parsedInitialResults()
|
||||
if (!data || !data[request]) {
|
||||
return window.fetch(request)
|
||||
}
|
||||
const decoded = decodeUTF8Base64(data[request])
|
||||
const requestData = JSON.parse(decoded)
|
||||
return {
|
||||
ok: true,
|
||||
json: () => requestData,
|
||||
text: () => requestData
|
||||
}
|
||||
}
|
||||
|
||||
const getInstanceConfig = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/api/statusnet/config.json')
|
||||
const res = await preloadFetch('/api/v1/instance')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site
|
||||
const textlimit = data.max_toot_chars
|
||||
const vapidPublicKey = data.pleroma.vapid_public_key
|
||||
|
||||
store.dispatch('setInstanceOption', { name: 'name', value: name })
|
||||
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
|
||||
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'server', value: server })
|
||||
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
|
||||
|
||||
// TODO: default values for this stuff, added if to not make it break on
|
||||
// my dev config out of the box.
|
||||
if (uploadlimit) {
|
||||
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
|
||||
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
|
||||
}
|
||||
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
|
||||
|
||||
if (vapidPublicKey) {
|
||||
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
|
||||
}
|
||||
|
||||
return data.site.pleromafe
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not load statusnet config, potentially fatal')
|
||||
console.error('Could not load instance config, potentially fatal')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getBackendProvidedConfig = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/api/pleroma/frontend_configurations')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.pleroma_fe
|
||||
} else {
|
||||
throw (res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not load backend-provided frontend config, potentially fatal')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
@ -132,7 +166,7 @@ const getTOS = async ({ store }) => {
|
|||
|
||||
const getInstancePanel = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/instance/panel.html')
|
||||
const res = await preloadFetch('/instance/panel.html')
|
||||
if (res.ok) {
|
||||
const html = await res.text()
|
||||
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
|
||||
|
@ -189,24 +223,33 @@ const getAppSecret = async ({ store }) => {
|
|||
|
||||
const resolveStaffAccounts = ({ store, accounts }) => {
|
||||
const nicknames = accounts.map(uri => uri.split('/').pop())
|
||||
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
|
||||
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
|
||||
}
|
||||
|
||||
const getNodeInfo = async ({ store }) => {
|
||||
try {
|
||||
const res = await window.fetch('/nodeinfo/2.0.json')
|
||||
const res = await preloadFetch('/nodeinfo/2.0.json')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const metadata = data.metadata
|
||||
const features = metadata.features
|
||||
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
|
||||
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
|
||||
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
|
||||
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
|
||||
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
|
||||
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
|
||||
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
|
||||
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
|
||||
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
|
||||
|
||||
const uploadLimits = metadata.uploadLimits
|
||||
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
|
||||
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
|
||||
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
|
||||
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
|
||||
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
|
||||
|
||||
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
|
||||
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
|
||||
|
||||
|
@ -257,7 +300,7 @@ const getNodeInfo = async ({ store }) => {
|
|||
|
||||
const setConfig = async ({ store }) => {
|
||||
// apiConfig, staticConfig
|
||||
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
|
||||
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
|
||||
const apiConfig = configInfos[0]
|
||||
const staticConfig = configInfos[1]
|
||||
|
||||
|
@ -280,6 +323,11 @@ const checkOAuthToken = async ({ store }) => {
|
|||
const afterStoreSetup = async ({ store, i18n }) => {
|
||||
const width = windowWidth()
|
||||
store.dispatch('setMobileLayout', width <= 800)
|
||||
|
||||
const overrides = window.___pleromafe_dev_overrides || {}
|
||||
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
|
||||
store.dispatch('setInstanceOption', { name: 'server', value: server })
|
||||
|
||||
await setConfig({ store })
|
||||
|
||||
const { customTheme, customThemeSource } = store.state.config
|
||||
|
@ -299,16 +347,18 @@ const afterStoreSetup = async ({ store, i18n }) => {
|
|||
}
|
||||
|
||||
// Now we can try getting the server settings and logging in
|
||||
// Most of these are preloaded into the index.html so blocking is minimized
|
||||
await Promise.all([
|
||||
checkOAuthToken({ store }),
|
||||
getTOS({ store }),
|
||||
getInstancePanel({ store }),
|
||||
getStickers({ store }),
|
||||
getNodeInfo({ store })
|
||||
getNodeInfo({ store }),
|
||||
getInstanceConfig({ store })
|
||||
])
|
||||
|
||||
// Start fetching things that don't need to block the UI
|
||||
store.dispatch('fetchMutes')
|
||||
getTOS({ store })
|
||||
getStickers({ store })
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
|
|
|
@ -2,6 +2,7 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
|
|||
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
|
||||
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
|
||||
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
|
||||
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
|
||||
import ConversationPage from 'components/conversation-page/conversation-page.vue'
|
||||
import Interactions from 'components/interactions/interactions.vue'
|
||||
import DMs from 'components/dm_timeline/dm_timeline.vue'
|
||||
|
@ -40,6 +41,7 @@ export default (store) => {
|
|||
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
|
||||
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
|
||||
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
|
||||
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
|
||||
{ name: 'remote-user-profile-acct',
|
||||
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
|
||||
|
|
|
@ -8,7 +8,6 @@ const Attachment = {
|
|||
props: [
|
||||
'attachment',
|
||||
'nsfw',
|
||||
'statusId',
|
||||
'size',
|
||||
'allowPlay',
|
||||
'setMedia',
|
||||
|
@ -30,9 +29,21 @@ const Attachment = {
|
|||
VideoAttachment
|
||||
},
|
||||
computed: {
|
||||
usePlaceHolder () {
|
||||
usePlaceholder () {
|
||||
return this.size === 'hide' || this.type === 'unknown'
|
||||
},
|
||||
placeholderName () {
|
||||
if (this.attachment.description === '' || !this.attachment.description) {
|
||||
return this.type.toUpperCase()
|
||||
}
|
||||
return this.attachment.description
|
||||
},
|
||||
placeholderIconClass () {
|
||||
if (this.type === 'image') return 'icon-picture'
|
||||
if (this.type === 'video') return 'icon-video'
|
||||
if (this.type === 'audio') return 'icon-music'
|
||||
return 'icon-doc'
|
||||
},
|
||||
referrerpolicy () {
|
||||
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
|
||||
},
|
||||
|
@ -49,7 +60,15 @@ const Attachment = {
|
|||
return this.size === 'small'
|
||||
},
|
||||
fullwidth () {
|
||||
return this.type === 'html' || this.type === 'audio'
|
||||
if (this.size === 'hide') return false
|
||||
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
|
||||
},
|
||||
useModal () {
|
||||
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
|
||||
: this.mergedConfig.playVideosInModal
|
||||
? ['image', 'video']
|
||||
: ['image']
|
||||
return modalTypes.includes(this.type)
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
},
|
||||
|
@ -60,12 +79,7 @@ const Attachment = {
|
|||
}
|
||||
},
|
||||
openModal (event) {
|
||||
const modalTypes = this.mergedConfig.playVideosInModal
|
||||
? ['image', 'video']
|
||||
: ['image']
|
||||
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
|
||||
this.usePlaceHolder
|
||||
) {
|
||||
if (this.useModal) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
this.setMedia()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="usePlaceHolder"
|
||||
v-if="usePlaceholder"
|
||||
:class="{ 'fullwidth': fullwidth }"
|
||||
@click="openModal"
|
||||
>
|
||||
<a
|
||||
|
@ -8,8 +9,11 @@
|
|||
class="placeholder"
|
||||
target="_blank"
|
||||
:href="attachment.url"
|
||||
:alt="attachment.description"
|
||||
:title="attachment.description"
|
||||
>
|
||||
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}]
|
||||
<span :class="placeholderIconClass" />
|
||||
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
|
@ -22,6 +26,8 @@
|
|||
v-if="hidden"
|
||||
class="image-attachment"
|
||||
:href="attachment.url"
|
||||
:alt="attachment.description"
|
||||
:title="attachment.description"
|
||||
@click.prevent="toggleHidden"
|
||||
>
|
||||
<img
|
||||
|
@ -51,7 +57,6 @@
|
|||
:class="{'hidden': hidden && preloadImage }"
|
||||
:href="attachment.url"
|
||||
target="_blank"
|
||||
:title="attachment.description"
|
||||
@click="openModal"
|
||||
>
|
||||
<StillImage
|
||||
|
@ -59,6 +64,7 @@
|
|||
:mimetype="attachment.mimetype"
|
||||
:src="attachment.large_thumb_url || attachment.url"
|
||||
:image-load-handler="onImageLoad"
|
||||
:alt="attachment.description"
|
||||
/>
|
||||
</a>
|
||||
|
||||
|
@ -83,6 +89,8 @@
|
|||
<audio
|
||||
v-if="type === 'audio'"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.description"
|
||||
:title="attachment.description"
|
||||
controls
|
||||
/>
|
||||
|
||||
|
@ -116,22 +124,19 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.attachment.media-upload-container {
|
||||
flex: 0 0 auto;
|
||||
max-height: 200px;
|
||||
.non-gallery {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
display: inline-block;
|
||||
padding: 0.3em 1em 0.3em 0;
|
||||
color: $fallback--link;
|
||||
color: var(--postLink, $fallback--link);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nsfw-placeholder {
|
||||
|
|
17
src/components/bookmark_timeline/bookmark_timeline.js
Normal file
17
src/components/bookmark_timeline/bookmark_timeline.js
Normal 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
|
9
src/components/bookmark_timeline/bookmark_timeline.vue
Normal file
9
src/components/bookmark_timeline/bookmark_timeline.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<Timeline
|
||||
:title="$t('nav.bookmarks')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'bookmarks'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./bookmark_timeline.js"></script>
|
|
@ -431,6 +431,7 @@ const EmojiInput = {
|
|||
const offsetBottom = offsetTop + offsetHeight
|
||||
|
||||
panel.style.top = offsetBottom + 'px'
|
||||
if (!picker) return
|
||||
picker.$el.style.top = offsetBottom + 'px'
|
||||
picker.$el.style.bottom = 'auto'
|
||||
}
|
||||
|
|
|
@ -34,6 +34,16 @@ const ExtraButtons = {
|
|||
navigator.clipboard.writeText(this.statusLink)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
bookmarkStatus () {
|
||||
this.$store.dispatch('bookmark', { id: this.status.id })
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
unbookmarkStatus () {
|
||||
this.$store.dispatch('unbookmark', { id: this.status.id })
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -40,6 +40,22 @@
|
|||
>
|
||||
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!status.bookmarked"
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="bookmarkStatus"
|
||||
@click="close"
|
||||
>
|
||||
<i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="status.bookmarked"
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="unbookmarkStatus"
|
||||
@click="close"
|
||||
>
|
||||
<i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
|
|
|
@ -50,9 +50,7 @@
|
|||
align-content: stretch;
|
||||
}
|
||||
|
||||
// FIXME: specificity problem with this and .attachments.attachment
|
||||
// we shouldn't have the need for .image here
|
||||
.attachment.image {
|
||||
.gallery-row-inner .attachment {
|
||||
margin: 0 0.5em 0 0;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
|
|
15
src/components/global_notice_list/global_notice_list.js
Normal file
15
src/components/global_notice_list/global_notice_list.js
Normal 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
|
77
src/components/global_notice_list/global_notice_list.vue
Normal file
77
src/components/global_notice_list/global_notice_list.vue
Normal 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>
|
|
@ -8,6 +8,8 @@
|
|||
v-if="type === 'image'"
|
||||
class="modal-image"
|
||||
:src="currentMedia.url"
|
||||
:alt="currentMedia.description"
|
||||
:title="currentMedia.description"
|
||||
@touchstart.stop="mediaTouchStart"
|
||||
@touchmove.stop="mediaTouchMove"
|
||||
@click="hide"
|
||||
|
@ -18,6 +20,14 @@
|
|||
:attachment="currentMedia"
|
||||
:controls="true"
|
||||
/>
|
||||
<audio
|
||||
v-if="type === 'audio'"
|
||||
class="modal-image"
|
||||
:src="currentMedia.url"
|
||||
:alt="currentMedia.description"
|
||||
:title="currentMedia.description"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
v-if="canNavigate"
|
||||
:title="$t('media_modal.previous')"
|
||||
|
|
|
@ -17,6 +17,11 @@
|
|||
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser">
|
||||
<router-link :to="{ name: 'bookmarks'}">
|
||||
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && currentUser.locked">
|
||||
<router-link :to="{ name: 'friend-requests' }">
|
||||
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
|
||||
|
|
|
@ -27,6 +27,11 @@ const Notifications = {
|
|||
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
|
||||
}
|
||||
},
|
||||
created () {
|
||||
const store = this.$store
|
||||
const credentials = store.state.users.currentUser.credentials
|
||||
notificationsFetcher.fetchAndUpdate({ store, credentials })
|
||||
},
|
||||
computed: {
|
||||
mainClass () {
|
||||
return this.minimalMode ? '' : 'panel panel-default'
|
||||
|
@ -56,11 +61,6 @@ const Notifications = {
|
|||
components: {
|
||||
Notification
|
||||
},
|
||||
created () {
|
||||
const { dispatch } = this.$store
|
||||
|
||||
dispatch('fetchAndUpdateNotifications')
|
||||
},
|
||||
watch: {
|
||||
unseenCount (count) {
|
||||
if (count > 0) {
|
||||
|
|
|
@ -118,6 +118,11 @@
|
|||
flex: 1;
|
||||
padding-left: 0.8em;
|
||||
min-width: 0;
|
||||
|
||||
.timeago {
|
||||
min-width: 3em;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-reaction-emoji {
|
||||
|
|
|
@ -3,9 +3,11 @@ import MediaUpload from '../media_upload/media_upload.vue'
|
|||
import ScopeSelector from '../scope_selector/scope_selector.vue'
|
||||
import EmojiInput from '../emoji_input/emoji_input.vue'
|
||||
import PollForm from '../poll/poll_form.vue'
|
||||
import Attachment from '../attachment/attachment.vue'
|
||||
import StatusContent from '../status_content/status_content.vue'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
|
||||
import { reject, map, uniqBy } from 'lodash'
|
||||
import { reject, map, uniqBy, debounce } from 'lodash'
|
||||
import suggestor from '../emoji_input/suggestor.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
|
@ -38,7 +40,9 @@ const PostStatusForm = {
|
|||
EmojiInput,
|
||||
PollForm,
|
||||
ScopeSelector,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
Attachment,
|
||||
StatusContent
|
||||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
|
@ -78,13 +82,16 @@ const PostStatusForm = {
|
|||
nsfw: false,
|
||||
files: [],
|
||||
poll: {},
|
||||
mediaDescriptions: {},
|
||||
visibility: scope,
|
||||
contentType
|
||||
},
|
||||
caret: 0,
|
||||
pollFormVisible: false,
|
||||
showDropIcon: 'hide',
|
||||
dropStopTimeout: null
|
||||
dropStopTimeout: null,
|
||||
preview: null,
|
||||
previewLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -163,18 +170,29 @@ const PostStatusForm = {
|
|||
this.newStatus.poll &&
|
||||
this.newStatus.poll.error
|
||||
},
|
||||
showPreview () {
|
||||
return !!this.preview || this.previewLoading
|
||||
},
|
||||
emptyStatus () {
|
||||
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
},
|
||||
watch: {
|
||||
'newStatus.contentType': function () {
|
||||
this.autoPreview()
|
||||
},
|
||||
'newStatus.spoilerText': function () {
|
||||
this.autoPreview()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
postStatus (newStatus) {
|
||||
async postStatus (newStatus) {
|
||||
if (this.posting) { return }
|
||||
if (this.submitDisabled) { return }
|
||||
|
||||
if (this.newStatus.status === '') {
|
||||
if (this.newStatus.files.length === 0) {
|
||||
this.error = 'Cannot post an empty status with no files'
|
||||
return
|
||||
}
|
||||
if (this.emptyStatus) {
|
||||
this.error = this.$t('post_status.empty_status_error')
|
||||
return
|
||||
}
|
||||
|
||||
const poll = this.pollFormVisible ? this.newStatus.poll : {}
|
||||
|
@ -184,7 +202,16 @@ const PostStatusForm = {
|
|||
}
|
||||
|
||||
this.posting = true
|
||||
statusPoster.postStatus({
|
||||
|
||||
try {
|
||||
await this.setAllMediaDescriptions()
|
||||
} catch (e) {
|
||||
this.error = this.$t('post_status.media_description_error')
|
||||
this.posting = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await statusPoster.postStatus({
|
||||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
|
@ -194,29 +221,83 @@ const PostStatusForm = {
|
|||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType,
|
||||
poll
|
||||
}).then((data) => {
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted')
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
this.posting = false
|
||||
})
|
||||
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
mediaDescriptions: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted')
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
if (this.preview) this.previewStatus()
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
|
||||
this.posting = false
|
||||
},
|
||||
previewStatus () {
|
||||
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
|
||||
this.preview = { error: this.$t('post_status.preview_empty') }
|
||||
this.previewLoading = false
|
||||
return
|
||||
}
|
||||
const newStatus = this.newStatus
|
||||
this.previewLoading = true
|
||||
statusPoster.postStatus({
|
||||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
sensitive: newStatus.nsfw,
|
||||
media: [],
|
||||
store: this.$store,
|
||||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
preview: true
|
||||
}).then((data) => {
|
||||
// Don't apply preview if not loading, because it means
|
||||
// user has closed the preview manually.
|
||||
if (!this.previewLoading) return
|
||||
if (!data.error) {
|
||||
this.preview = data
|
||||
} else {
|
||||
this.preview = { error: data.error }
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.preview = { error }
|
||||
}).finally(() => {
|
||||
this.previewLoading = false
|
||||
})
|
||||
},
|
||||
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
|
||||
autoPreview () {
|
||||
if (!this.preview) return
|
||||
this.previewLoading = true
|
||||
this.debouncePreviewStatus()
|
||||
},
|
||||
closePreview () {
|
||||
this.preview = null
|
||||
this.previewLoading = false
|
||||
},
|
||||
togglePreview () {
|
||||
if (this.showPreview) {
|
||||
this.closePreview()
|
||||
} else {
|
||||
this.previewStatus()
|
||||
}
|
||||
},
|
||||
addMediaFile (fileInfo) {
|
||||
this.newStatus.files.push(fileInfo)
|
||||
|
@ -239,6 +320,7 @@ const PostStatusForm = {
|
|||
return fileTypeService.fileType(fileInfo.mimetype)
|
||||
},
|
||||
paste (e) {
|
||||
this.autoPreview()
|
||||
this.resize(e)
|
||||
if (e.clipboardData.files.length > 0) {
|
||||
// prevent pasting of file as text
|
||||
|
@ -273,6 +355,7 @@ const PostStatusForm = {
|
|||
}
|
||||
},
|
||||
onEmojiInputInput (e) {
|
||||
this.autoPreview()
|
||||
this.$nextTick(() => {
|
||||
this.resize(this.$refs['textarea'])
|
||||
})
|
||||
|
@ -388,6 +471,15 @@ const PostStatusForm = {
|
|||
},
|
||||
dismissScopeNotice () {
|
||||
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
|
||||
},
|
||||
setMediaDescription (id) {
|
||||
const description = this.newStatus.mediaDescriptions[id]
|
||||
if (!description || description.trim() === '') return
|
||||
return statusPoster.setMediaDescription({ store: this.$store, id, description })
|
||||
},
|
||||
setAllMediaDescriptions () {
|
||||
const ids = this.newStatus.files.map(file => file.id)
|
||||
return Promise.all(ids.map(id => this.setMediaDescription(id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,6 +69,44 @@
|
|||
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
|
||||
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
|
||||
</p>
|
||||
<div class="preview-heading faint">
|
||||
<a
|
||||
class="preview-toggle faint"
|
||||
@click.stop.prevent="togglePreview"
|
||||
>
|
||||
{{ $t('post_status.preview') }}
|
||||
<i
|
||||
class="icon-down-open"
|
||||
:style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
|
||||
/>
|
||||
</a>
|
||||
<i
|
||||
v-show="previewLoading"
|
||||
class="icon-spin3 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="preview-container"
|
||||
>
|
||||
<div
|
||||
v-if="!preview"
|
||||
class="preview-status"
|
||||
>
|
||||
{{ $t('general.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="preview.error"
|
||||
class="preview-status preview-error"
|
||||
>
|
||||
{{ preview.error }}
|
||||
</div>
|
||||
<StatusContent
|
||||
v-else
|
||||
:status="preview"
|
||||
class="preview-status"
|
||||
/>
|
||||
</div>
|
||||
<EmojiInput
|
||||
v-if="newStatus.spoilerText || alwaysShowSubject"
|
||||
v-model="newStatus.spoilerText"
|
||||
|
@ -77,7 +115,6 @@
|
|||
class="form-control"
|
||||
>
|
||||
<input
|
||||
|
||||
v-model="newStatus.spoilerText"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.content_warning')"
|
||||
|
@ -245,27 +282,18 @@
|
|||
class="fa button-icon icon-cancel"
|
||||
@click="removeMediaFile(file)"
|
||||
/>
|
||||
<div class="media-upload-container attachment">
|
||||
<img
|
||||
v-if="type(file) === 'image'"
|
||||
class="thumbnail media-upload"
|
||||
:src="file.url"
|
||||
>
|
||||
<video
|
||||
v-if="type(file) === 'video'"
|
||||
:src="file.url"
|
||||
controls
|
||||
/>
|
||||
<audio
|
||||
v-if="type(file) === 'audio'"
|
||||
:src="file.url"
|
||||
controls
|
||||
/>
|
||||
<a
|
||||
v-if="type(file) === 'unknown'"
|
||||
:href="file.url"
|
||||
>{{ file.url }}</a>
|
||||
</div>
|
||||
<attachment
|
||||
:attachment="file"
|
||||
:set-media="() => $store.dispatch('setMedia', newStatus.files)"
|
||||
size="small"
|
||||
allow-play="false"
|
||||
/>
|
||||
<input
|
||||
v-model="newStatus.mediaDescriptions[file.id]"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.media_description')"
|
||||
@keydown.enter.prevent=""
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
@ -302,14 +330,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.post-status-form {
|
||||
.visibility-tray {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-status-form {
|
||||
.form-bottom {
|
||||
display: flex;
|
||||
|
@ -336,6 +356,48 @@
|
|||
max-width: 10em;
|
||||
}
|
||||
|
||||
.preview-heading {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.icon-spin3 {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-toggle {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-down-open {
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
font-style: italic;
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
border: 1px solid $fallback--border;
|
||||
border: 1px solid var(--border, $fallback--border);
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.text-format {
|
||||
.only-format {
|
||||
color: $fallback--faint;
|
||||
|
@ -343,6 +405,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.visibility-tray {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.media-upload-icon, .poll-icon, .emoji-icon {
|
||||
font-size: 26px;
|
||||
flex: 1;
|
||||
|
@ -381,11 +449,9 @@
|
|||
}
|
||||
|
||||
.media-upload-wrapper {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 50px;
|
||||
margin-right: .2em;
|
||||
margin-bottom: .5em;
|
||||
width: 18em;
|
||||
|
||||
.icon-cancel {
|
||||
display: inline-block;
|
||||
|
@ -399,6 +465,20 @@
|
|||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
img, video {
|
||||
object-fit: contain;
|
||||
max-height: 10em;
|
||||
}
|
||||
|
||||
.video {
|
||||
max-height: 10em;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.status-input-wrapper {
|
||||
|
@ -408,28 +488,13 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
.media-upload-wrapper .attachments {
|
||||
padding: 0 0.5em;
|
||||
|
||||
.attachment {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid $fallback--border;
|
||||
border: 1px solid var(--border, $fallback--border);
|
||||
text-align: center;
|
||||
|
||||
audio {
|
||||
min-width: 300px;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
padding: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
height: 100vh;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
>.panel-body {
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
|
||||
|
|
|
@ -37,6 +37,9 @@ const FilteringTab = {
|
|||
})
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
replyVisibility () {
|
||||
this.$store.dispatch('queueFlushAll')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import unescape from 'lodash/unescape'
|
||||
import merge from 'lodash/merge'
|
||||
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
|
||||
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
|
||||
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
|
||||
|
@ -16,6 +17,7 @@ const ProfileTab = {
|
|||
newLocked: this.$store.state.users.currentUser.locked,
|
||||
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
|
||||
newDefaultScope: this.$store.state.users.currentUser.default_scope,
|
||||
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
|
||||
hideFollows: this.$store.state.users.currentUser.hide_follows,
|
||||
hideFollowers: this.$store.state.users.currentUser.hide_followers,
|
||||
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
|
||||
|
@ -23,6 +25,7 @@ const ProfileTab = {
|
|||
showRole: this.$store.state.users.currentUser.show_role,
|
||||
role: this.$store.state.users.currentUser.role,
|
||||
discoverable: this.$store.state.users.currentUser.discoverable,
|
||||
bot: this.$store.state.users.currentUser.bot,
|
||||
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
|
||||
pickAvatarBtnVisible: true,
|
||||
bannerUploading: false,
|
||||
|
@ -62,6 +65,45 @@ const ProfileTab = {
|
|||
...this.$store.state.instance.emoji,
|
||||
...this.$store.state.instance.customEmoji
|
||||
] })
|
||||
},
|
||||
userSuggestor () {
|
||||
return suggestor({
|
||||
users: this.$store.state.users.users,
|
||||
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
|
||||
})
|
||||
},
|
||||
fieldsLimits () {
|
||||
return this.$store.state.instance.fieldsLimits
|
||||
},
|
||||
maxFields () {
|
||||
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
|
||||
},
|
||||
defaultAvatar () {
|
||||
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
|
||||
},
|
||||
defaultBanner () {
|
||||
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
|
||||
},
|
||||
isDefaultAvatar () {
|
||||
const baseAvatar = this.$store.state.instance.defaultAvatar
|
||||
return !(this.$store.state.users.currentUser.profile_image_url) ||
|
||||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
|
||||
},
|
||||
isDefaultBanner () {
|
||||
const baseBanner = this.$store.state.instance.defaultBanner
|
||||
return !(this.$store.state.users.currentUser.cover_photo) ||
|
||||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
|
||||
},
|
||||
isDefaultBackground () {
|
||||
return !(this.$store.state.users.currentUser.background_image)
|
||||
},
|
||||
avatarImgSrc () {
|
||||
const src = this.$store.state.users.currentUser.profile_image_url_original
|
||||
return (!src) ? this.defaultAvatar : src
|
||||
},
|
||||
bannerImgSrc () {
|
||||
const src = this.$store.state.users.currentUser.cover_photo
|
||||
return (!src) ? this.defaultBanner : src
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -74,17 +116,21 @@ const ProfileTab = {
|
|||
// Backend notation.
|
||||
/* eslint-disable camelcase */
|
||||
display_name: this.newName,
|
||||
fields_attributes: this.newFields.filter(el => el != null),
|
||||
default_scope: this.newDefaultScope,
|
||||
no_rich_text: this.newNoRichText,
|
||||
hide_follows: this.hideFollows,
|
||||
hide_followers: this.hideFollowers,
|
||||
discoverable: this.discoverable,
|
||||
bot: this.bot,
|
||||
allow_following_move: this.allowFollowingMove,
|
||||
hide_follows_count: this.hideFollowsCount,
|
||||
hide_followers_count: this.hideFollowersCount,
|
||||
show_role: this.showRole
|
||||
/* eslint-enable camelcase */
|
||||
} }).then((user) => {
|
||||
this.newFields.splice(user.fields.length)
|
||||
merge(this.newFields, user.fields)
|
||||
this.$store.commit('addNewUsers', [user])
|
||||
this.$store.commit('setCurrentUser', user)
|
||||
})
|
||||
|
@ -92,6 +138,16 @@ const ProfileTab = {
|
|||
changeVis (visibility) {
|
||||
this.newDefaultScope = visibility
|
||||
},
|
||||
addField () {
|
||||
if (this.newFields.length < this.maxFields) {
|
||||
this.newFields.push({ name: '', value: '' })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
deleteField (index, event) {
|
||||
this.$delete(this.newFields, index)
|
||||
},
|
||||
uploadFile (slot, e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) { return }
|
||||
|
@ -121,11 +177,29 @@ const ProfileTab = {
|
|||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
resetAvatar () {
|
||||
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
|
||||
if (confirmed) {
|
||||
this.submitAvatar(undefined, '')
|
||||
}
|
||||
},
|
||||
resetBanner () {
|
||||
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
|
||||
if (confirmed) {
|
||||
this.submitBanner('')
|
||||
}
|
||||
},
|
||||
resetBackground () {
|
||||
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
|
||||
if (confirmed) {
|
||||
this.submitBackground('')
|
||||
}
|
||||
},
|
||||
submitAvatar (cropper, file) {
|
||||
const that = this
|
||||
return new Promise((resolve, reject) => {
|
||||
function updateAvatar (avatar) {
|
||||
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
|
||||
that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
|
||||
.then((user) => {
|
||||
that.$store.commit('addNewUsers', [user])
|
||||
that.$store.commit('setCurrentUser', user)
|
||||
|
@ -143,11 +217,11 @@ const ProfileTab = {
|
|||
}
|
||||
})
|
||||
},
|
||||
submitBanner () {
|
||||
if (!this.bannerPreview) { return }
|
||||
submitBanner (banner) {
|
||||
if (!this.bannerPreview && banner !== '') { return }
|
||||
|
||||
this.bannerUploading = true
|
||||
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
|
||||
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
|
||||
.then((user) => {
|
||||
this.$store.commit('addNewUsers', [user])
|
||||
this.$store.commit('setCurrentUser', user)
|
||||
|
@ -158,11 +232,11 @@ const ProfileTab = {
|
|||
})
|
||||
.then(() => { this.bannerUploading = false })
|
||||
},
|
||||
submitBg () {
|
||||
if (!this.backgroundPreview) { return }
|
||||
let background = this.background
|
||||
submitBackground (background) {
|
||||
if (!this.backgroundPreview && background !== '') { return }
|
||||
|
||||
this.backgroundUploading = true
|
||||
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
|
||||
this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
|
||||
if (!data.error) {
|
||||
this.$store.commit('addNewUsers', [data])
|
||||
this.$store.commit('setCurrentUser', data)
|
||||
|
|
|
@ -13,8 +13,14 @@
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.banner {
|
||||
.banner-background-preview {
|
||||
max-width: 100%;
|
||||
width: 300px;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.uploading {
|
||||
|
@ -26,18 +32,40 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.bg {
|
||||
max-width: 100%;
|
||||
.current-avatar-container {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.current-avatar {
|
||||
display: block;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
position: absolute;
|
||||
top: 0.2em;
|
||||
right: 0.2em;
|
||||
border-radius: $fallback--tooltipRadius;
|
||||
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0.7;
|
||||
color: white;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
text-align: center;
|
||||
line-height: 1.5em;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-tokens {
|
||||
width: 100%;
|
||||
|
||||
|
@ -79,4 +107,22 @@
|
|||
.setting-subitem {
|
||||
margin-left: 1.75em;
|
||||
}
|
||||
|
||||
.profile-fields {
|
||||
display: flex;
|
||||
|
||||
&>.emoji-input {
|
||||
flex: 1 1 auto;
|
||||
margin: 0 .2em .5em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&>.icon-container {
|
||||
width: 20px;
|
||||
|
||||
&>.icon-cancel {
|
||||
vertical-align: sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,6 +95,59 @@
|
|||
{{ $t('settings.discoverable') }}
|
||||
</Checkbox>
|
||||
</p>
|
||||
<div v-if="maxFields > 0">
|
||||
<p>{{ $t('settings.profile_fields.label') }}</p>
|
||||
<div
|
||||
v-for="(_, i) in newFields"
|
||||
:key="i"
|
||||
class="profile-fields"
|
||||
>
|
||||
<EmojiInput
|
||||
v-model="newFields[i].name"
|
||||
enable-emoji-picker
|
||||
hide-emoji-button
|
||||
:suggest="userSuggestor"
|
||||
>
|
||||
<input
|
||||
v-model="newFields[i].name"
|
||||
:placeholder="$t('settings.profile_fields.name')"
|
||||
>
|
||||
</EmojiInput>
|
||||
<EmojiInput
|
||||
v-model="newFields[i].value"
|
||||
enable-emoji-picker
|
||||
hide-emoji-button
|
||||
:suggest="userSuggestor"
|
||||
>
|
||||
<input
|
||||
v-model="newFields[i].value"
|
||||
:placeholder="$t('settings.profile_fields.value')"
|
||||
>
|
||||
</EmojiInput>
|
||||
<div
|
||||
class="icon-container"
|
||||
>
|
||||
<i
|
||||
v-show="newFields.length > 1"
|
||||
class="icon-cancel"
|
||||
@click="deleteField(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="newFields.length < maxFields"
|
||||
class="add-field faint"
|
||||
@click="addField"
|
||||
>
|
||||
<i class="icon-plus" />
|
||||
{{ $t("settings.profile_fields.add_field") }}
|
||||
</a>
|
||||
</div>
|
||||
<p>
|
||||
<Checkbox v-model="bot">
|
||||
{{ $t('settings.bot') }}
|
||||
</Checkbox>
|
||||
</p>
|
||||
<button
|
||||
:disabled="newName && newName.length === 0"
|
||||
class="btn btn-default"
|
||||
|
@ -108,11 +161,19 @@
|
|||
<p class="visibility-notice">
|
||||
{{ $t('settings.avatar_size_instruction') }}
|
||||
</p>
|
||||
<p>{{ $t('settings.current_avatar') }}</p>
|
||||
<img
|
||||
:src="user.profile_image_url_original"
|
||||
class="current-avatar"
|
||||
>
|
||||
<div class="current-avatar-container">
|
||||
<img
|
||||
:src="user.profile_image_url_original"
|
||||
class="current-avatar"
|
||||
>
|
||||
<i
|
||||
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
|
||||
:title="$t('settings.reset_avatar')"
|
||||
class="reset-button icon-cancel"
|
||||
type="button"
|
||||
@click="resetAvatar"
|
||||
/>
|
||||
</div>
|
||||
<p>{{ $t('settings.set_new_avatar') }}</p>
|
||||
<button
|
||||
v-show="pickAvatarBtnVisible"
|
||||
|
@ -131,15 +192,20 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<h2>{{ $t('settings.profile_banner') }}</h2>
|
||||
<p>{{ $t('settings.current_profile_banner') }}</p>
|
||||
<img
|
||||
:src="user.cover_photo"
|
||||
class="banner"
|
||||
>
|
||||
<div class="banner-background-preview">
|
||||
<img :src="user.cover_photo">
|
||||
<i
|
||||
v-if="!isDefaultBanner"
|
||||
:title="$t('settings.reset_profile_banner')"
|
||||
class="reset-button icon-cancel"
|
||||
type="button"
|
||||
@click="resetBanner"
|
||||
/>
|
||||
</div>
|
||||
<p>{{ $t('settings.set_new_profile_banner') }}</p>
|
||||
<img
|
||||
v-if="bannerPreview"
|
||||
class="banner"
|
||||
class="banner-background-preview"
|
||||
:src="bannerPreview"
|
||||
>
|
||||
<div>
|
||||
|
@ -155,7 +221,7 @@
|
|||
<button
|
||||
v-else-if="bannerPreview"
|
||||
class="btn btn-default"
|
||||
@click="submitBanner"
|
||||
@click="submitBanner(banner)"
|
||||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
|
@ -172,10 +238,20 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<h2>{{ $t('settings.profile_background') }}</h2>
|
||||
<div class="banner-background-preview">
|
||||
<img :src="user.background_image">
|
||||
<i
|
||||
v-if="!isDefaultBackground"
|
||||
:title="$t('settings.reset_profile_background')"
|
||||
class="reset-button icon-cancel"
|
||||
type="button"
|
||||
@click="resetBackground"
|
||||
/>
|
||||
</div>
|
||||
<p>{{ $t('settings.set_new_profile_background') }}</p>
|
||||
<img
|
||||
v-if="backgroundPreview"
|
||||
class="bg"
|
||||
class="banner-background-preview"
|
||||
:src="backgroundPreview"
|
||||
>
|
||||
<div>
|
||||
|
@ -191,7 +267,7 @@
|
|||
<button
|
||||
v-else-if="backgroundPreview"
|
||||
class="btn btn-default"
|
||||
@click="submitBg"
|
||||
@click="submitBackground(background)"
|
||||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
|
|
|
@ -65,6 +65,14 @@
|
|||
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'bookmarks'}">
|
||||
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser && currentUser.locked"
|
||||
@click="toggleDrawer"
|
||||
|
|
|
@ -2,6 +2,10 @@ import map from 'lodash/map'
|
|||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
|
||||
const StaffPanel = {
|
||||
created () {
|
||||
const nicknames = this.$store.state.instance.staffAccounts
|
||||
nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname))
|
||||
},
|
||||
components: {
|
||||
BasicUserCard
|
||||
},
|
||||
|
|
|
@ -141,7 +141,7 @@ const Status = {
|
|||
return this.mergedConfig.hideFilteredStatuses
|
||||
},
|
||||
hideStatus () {
|
||||
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
|
||||
return this.deleted || (this.muted && this.hideFilteredStatuses)
|
||||
},
|
||||
isFocused () {
|
||||
// retweet or root of an expanded conversation
|
||||
|
@ -164,37 +164,6 @@ const Status = {
|
|||
return user && user.screen_name
|
||||
}
|
||||
},
|
||||
hideReply () {
|
||||
if (this.mergedConfig.replyVisibility === 'all') {
|
||||
return false
|
||||
}
|
||||
if (this.inConversation || !this.isReply) {
|
||||
return false
|
||||
}
|
||||
if (this.status.user.id === this.currentUser.id) {
|
||||
return false
|
||||
}
|
||||
if (this.status.type === 'retweet') {
|
||||
return false
|
||||
}
|
||||
const checkFollowing = this.mergedConfig.replyVisibility === 'following'
|
||||
for (var i = 0; i < this.status.attentions.length; ++i) {
|
||||
if (this.status.user.id === this.status.attentions[i].id) {
|
||||
continue
|
||||
}
|
||||
// There's zero guarantee of this working. If we happen to have that user and their
|
||||
// relationship in store then it will work, but there's kinda little chance of having
|
||||
// them for people you're not following.
|
||||
const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
|
||||
if (checkFollowing && relationship && relationship.following) {
|
||||
return false
|
||||
}
|
||||
if (this.status.attentions[i].id === this.currentUser.id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return this.status.attentions.length > 0
|
||||
},
|
||||
replySubject () {
|
||||
if (!this.status.summary) return ''
|
||||
const decodedSummary = unescape(this.status.summary)
|
||||
|
|
|
@ -197,7 +197,7 @@
|
|||
>
|
||||
<StatusPopover
|
||||
v-if="!isPreview"
|
||||
:status-id="status.in_reply_to_status_id"
|
||||
:status-id="status.parent_visible && status.in_reply_to_status_id"
|
||||
class="reply-to-popover"
|
||||
style="min-width: 0"
|
||||
>
|
||||
|
@ -208,7 +208,12 @@
|
|||
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
|
||||
>
|
||||
<i class="button-icon icon-reply" />
|
||||
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
|
||||
<span
|
||||
class="faint-link reply-to-text"
|
||||
:class="{ 'strikethrough': !status.parent_visible }"
|
||||
>
|
||||
{{ $t('status.reply_to') }}
|
||||
</span>
|
||||
</a>
|
||||
</StatusPopover>
|
||||
<span
|
||||
|
@ -372,9 +377,6 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.status-el {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
border-left-width: 0px;
|
||||
min-width: 0;
|
||||
border-color: $fallback--border;
|
||||
|
@ -418,7 +420,7 @@ $status-margin: 0.75em;
|
|||
max-width: 85%;
|
||||
font-weight: bold;
|
||||
|
||||
img {
|
||||
img.emoji {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
|
@ -526,6 +528,10 @@ $status-margin: 0.75em;
|
|||
margin: 0 0.4em 0 0.2em;
|
||||
}
|
||||
|
||||
.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.replies-separator {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
|
|
|
@ -44,14 +44,14 @@ const StatusContent = {
|
|||
return lengthScore > 20
|
||||
},
|
||||
longSubject () {
|
||||
return this.status.summary.length > 900
|
||||
return this.status.summary.length > 240
|
||||
},
|
||||
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
|
||||
mightHideBecauseSubject () {
|
||||
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
|
||||
return !!this.status.summary && this.localCollapseSubjectDefault
|
||||
},
|
||||
mightHideBecauseTall () {
|
||||
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
|
||||
return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
|
||||
},
|
||||
hideSubjectStatus () {
|
||||
return this.mightHideBecauseSubject && !this.expandingSubject
|
||||
|
@ -99,15 +99,8 @@ const StatusContent = {
|
|||
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
|
||||
)
|
||||
},
|
||||
hasImageAttachments () {
|
||||
return this.status.attachments.some(
|
||||
file => fileType.fileType(file.mimetype) === 'image'
|
||||
)
|
||||
},
|
||||
hasVideoAttachments () {
|
||||
return this.status.attachments.some(
|
||||
file => fileType.fileType(file.mimetype) === 'video'
|
||||
)
|
||||
attachmentTypes () {
|
||||
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
|
||||
},
|
||||
maxThumbnails () {
|
||||
return this.mergedConfig.maxThumbnails
|
||||
|
@ -142,12 +135,6 @@ const StatusContent = {
|
|||
return html
|
||||
}
|
||||
},
|
||||
contentHtml () {
|
||||
if (!this.status.summary_html) {
|
||||
return this.postBodyHtml
|
||||
}
|
||||
return this.status.summary_html + '<br />' + this.postBodyHtml
|
||||
},
|
||||
...mapGetters(['mergedConfig']),
|
||||
...mapState({
|
||||
betterShadow: state => state.interface.browserSupport.cssFilter,
|
||||
|
|
|
@ -3,45 +3,32 @@
|
|||
<div class="status-body">
|
||||
<slot name="header" />
|
||||
<div
|
||||
v-if="longSubject"
|
||||
class="status-content-wrapper"
|
||||
:class="{ 'tall-status': !showingLongSubject }"
|
||||
v-if="status.summary_html"
|
||||
class="summary-wrapper"
|
||||
:class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
|
||||
>
|
||||
<div
|
||||
class="media-body summary"
|
||||
@click.prevent="linkClicked"
|
||||
v-html="status.summary_html"
|
||||
/>
|
||||
<a
|
||||
v-if="!showingLongSubject"
|
||||
class="tall-status-hider"
|
||||
:class="{ 'tall-status-hider_focused': focused }"
|
||||
v-if="longSubject && showingLongSubject"
|
||||
href="#"
|
||||
class="tall-subject-hider"
|
||||
@click.prevent="showingLongSubject=false"
|
||||
>{{ $t("status.hide_full_subject") }}</a>
|
||||
<a
|
||||
v-else-if="longSubject"
|
||||
class="tall-subject-hider"
|
||||
:class="{ 'tall-subject-hider_focused': focused }"
|
||||
href="#"
|
||||
@click.prevent="showingLongSubject=true"
|
||||
>
|
||||
{{ $t("general.show_more") }}
|
||||
<span
|
||||
v-if="hasImageAttachments"
|
||||
class="icon-picture"
|
||||
/>
|
||||
<span
|
||||
v-if="hasVideoAttachments"
|
||||
class="icon-video"
|
||||
/>
|
||||
<span
|
||||
v-if="status.card"
|
||||
class="icon-link"
|
||||
/>
|
||||
{{ $t("status.show_full_subject") }}
|
||||
</a>
|
||||
<div
|
||||
class="status-content media-body"
|
||||
@click.prevent="linkClicked"
|
||||
v-html="contentHtml"
|
||||
/>
|
||||
<a
|
||||
v-if="showingLongSubject"
|
||||
href="#"
|
||||
class="status-unhider"
|
||||
@click.prevent="showingLongSubject=false"
|
||||
>{{ $t("general.show_less") }}</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="{'tall-status': hideTallStatus}"
|
||||
class="status-content-wrapper"
|
||||
>
|
||||
|
@ -51,31 +38,51 @@
|
|||
:class="{ 'tall-status-hider_focused': focused }"
|
||||
href="#"
|
||||
@click.prevent="toggleShowMore"
|
||||
>{{ $t("general.show_more") }}</a>
|
||||
>
|
||||
{{ $t("general.show_more") }}
|
||||
</a>
|
||||
<div
|
||||
v-if="!hideSubjectStatus"
|
||||
class="status-content media-body"
|
||||
@click.prevent="linkClicked"
|
||||
v-html="contentHtml"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="status-content media-body"
|
||||
@click.prevent="linkClicked"
|
||||
v-html="status.summary_html"
|
||||
v-html="postBodyHtml"
|
||||
/>
|
||||
<a
|
||||
v-if="hideSubjectStatus"
|
||||
href="#"
|
||||
class="cw-status-hider"
|
||||
@click.prevent="toggleShowMore"
|
||||
>{{ $t("general.show_more") }}</a>
|
||||
>
|
||||
{{ $t("status.show_content") }}
|
||||
<span
|
||||
v-if="attachmentTypes.includes('image')"
|
||||
class="icon-picture"
|
||||
/>
|
||||
<span
|
||||
v-if="attachmentTypes.includes('video')"
|
||||
class="icon-video"
|
||||
/>
|
||||
<span
|
||||
v-if="attachmentTypes.includes('audio')"
|
||||
class="icon-music"
|
||||
/>
|
||||
<span
|
||||
v-if="attachmentTypes.includes('unknown')"
|
||||
class="icon-doc"
|
||||
/>
|
||||
<span
|
||||
v-if="status.card"
|
||||
class="icon-link"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="showingMore"
|
||||
href="#"
|
||||
class="status-unhider"
|
||||
@click.prevent="toggleShowMore"
|
||||
>{{ $t("general.show_less") }}</a>
|
||||
>
|
||||
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="status.poll && status.poll.options">
|
||||
|
@ -129,6 +136,12 @@ $status-margin: 0.75em;
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.status-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.tall-status {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
|
@ -136,7 +149,7 @@ $status-margin: 0.75em;
|
|||
overflow-y: hidden;
|
||||
z-index: 1;
|
||||
.status-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
|
||||
linear-gradient(to top, white, white);
|
||||
/* Autoprefixed seem to ignore this one, and also syntax is different */
|
||||
|
@ -176,10 +189,45 @@ $status-margin: 0.75em;
|
|||
}
|
||||
}
|
||||
|
||||
.summary-wrapper {
|
||||
margin-bottom: 0.5em;
|
||||
border-style: solid;
|
||||
border-width: 0 0 1px 0;
|
||||
border-color: var(--border, $fallback--border);
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-style: italic;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.tall-subject {
|
||||
position: relative;
|
||||
.summary {
|
||||
max-height: 2em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.tall-subject-hider {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
// position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
font-family: var(--postFont, sans-serif);
|
||||
line-height: 1.4em;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
blockquote {
|
||||
margin: 0.2em 0 0.2em 2em;
|
||||
|
|
|
@ -22,6 +22,10 @@ const StatusPopover = {
|
|||
methods: {
|
||||
enter () {
|
||||
if (!this.status) {
|
||||
if (!this.statusId) {
|
||||
this.error = true
|
||||
return
|
||||
}
|
||||
this.$store.dispatch('fetchStatus', this.statusId)
|
||||
.then(data => (this.error = false))
|
||||
.catch(e => (this.error = true))
|
||||
|
|
|
@ -4,7 +4,8 @@ const StillImage = {
|
|||
'referrerpolicy',
|
||||
'mimetype',
|
||||
'imageLoadError',
|
||||
'imageLoadHandler'
|
||||
'imageLoadHandler',
|
||||
'alt'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
<img
|
||||
ref="src"
|
||||
:key="src"
|
||||
:alt="alt"
|
||||
:title="alt"
|
||||
:src="src"
|
||||
:referrerpolicy="referrerpolicy"
|
||||
@load="onLoad"
|
||||
|
|
|
@ -45,11 +45,15 @@ const Timeline = {
|
|||
newStatusCount () {
|
||||
return this.timeline.newStatusCount
|
||||
},
|
||||
newStatusCountStr () {
|
||||
showLoadButton () {
|
||||
if (this.timelineError || this.errorData) return false
|
||||
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
|
||||
},
|
||||
loadButtonString () {
|
||||
if (this.timeline.flushMarker !== 0) {
|
||||
return ''
|
||||
return this.$t('timeline.reload')
|
||||
} else {
|
||||
return ` (${this.newStatusCount})`
|
||||
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
|
||||
}
|
||||
},
|
||||
classes () {
|
||||
|
@ -112,8 +116,6 @@ const Timeline = {
|
|||
if (e.key === '.') this.showNewStatuses()
|
||||
},
|
||||
showNewStatuses () {
|
||||
if (this.newStatusCount === 0) return
|
||||
|
||||
if (this.timeline.flushMarker !== 0) {
|
||||
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
|
||||
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
|
||||
|
@ -135,7 +137,7 @@ const Timeline = {
|
|||
showImmediately: true,
|
||||
userId: this.userId,
|
||||
tag: this.tag
|
||||
}).then(statuses => {
|
||||
}).then(({ statuses }) => {
|
||||
store.commit('setLoading', { timeline: this.timelineName, value: false })
|
||||
if (statuses && statuses.length === 0) {
|
||||
this.bottomedOut = true
|
||||
|
|
|
@ -19,14 +19,14 @@
|
|||
{{ errorData.statusText }}
|
||||
</div>
|
||||
<button
|
||||
v-if="timeline.newStatusCount > 0 && !timelineError && !errorData"
|
||||
v-else-if="showLoadButton"
|
||||
class="loadmore-button"
|
||||
@click.prevent="showNewStatuses"
|
||||
>
|
||||
{{ $t('timeline.show_new') }}{{ newStatusCountStr }}
|
||||
{{ loadButtonString }}
|
||||
</button>
|
||||
<div
|
||||
v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData"
|
||||
v-else
|
||||
class="loadmore-text faint"
|
||||
@click.prevent
|
||||
>
|
||||
|
|
|
@ -8,26 +8,20 @@ const UserAvatar = {
|
|||
],
|
||||
data () {
|
||||
return {
|
||||
showPlaceholder: false
|
||||
showPlaceholder: false,
|
||||
defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`
|
||||
}
|
||||
},
|
||||
components: {
|
||||
StillImage
|
||||
},
|
||||
computed: {
|
||||
imgSrc () {
|
||||
return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
imgSrc (src) {
|
||||
return (!src || this.showPlaceholder) ? this.defaultAvatar : src
|
||||
},
|
||||
imageLoadError () {
|
||||
this.showPlaceholder = true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src () {
|
||||
this.showPlaceholder = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
class="avatar"
|
||||
:alt="user.screen_name"
|
||||
:title="user.screen_name"
|
||||
:src="imgSrc"
|
||||
:src="imgSrc(user.profile_image_url_original)"
|
||||
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
|
||||
:image-load-error="imageLoadError"
|
||||
/>
|
||||
|
|
|
@ -70,10 +70,20 @@
|
|||
>
|
||||
@{{ user.screen_name }}
|
||||
</router-link>
|
||||
<span
|
||||
v-if="!hideBio && !!visibleRole"
|
||||
class="alert staff"
|
||||
>{{ visibleRole }}</span>
|
||||
<template v-if="!hideBio">
|
||||
<span
|
||||
v-if="!!visibleRole"
|
||||
class="alert user-role"
|
||||
>
|
||||
{{ visibleRole }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.bot"
|
||||
class="alert user-role"
|
||||
>
|
||||
bot
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="user.locked"><i class="icon icon-lock" /></span>
|
||||
<span
|
||||
v-if="!mergedConfig.hideUserStats && !hideBio"
|
||||
|
@ -458,7 +468,7 @@
|
|||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
.staff {
|
||||
.user-role {
|
||||
flex: none;
|
||||
text-transform: capitalize;
|
||||
color: $fallback--text;
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
:hide-bio="true"
|
||||
rounded="top"
|
||||
/>
|
||||
<div class="panel-footer">
|
||||
<PostStatusForm />
|
||||
</div>
|
||||
<PostStatusForm />
|
||||
</div>
|
||||
<auth-form
|
||||
v-else
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
:src="attachment.url"
|
||||
:loop="loopVideo"
|
||||
:controls="controls"
|
||||
:alt="attachment.description"
|
||||
:title="attachment.description"
|
||||
playsinline
|
||||
@loadeddata="onVideoDataLoad"
|
||||
/>
|
||||
|
|
|
@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) {
|
|||
|
||||
panel.usersToFollow.forEach((toFollow, index) => {
|
||||
let user = shuffled[index]
|
||||
let img = user.avatar || '/images/avi.png'
|
||||
let img = user.avatar || this.$store.state.instance.defaultAvatar
|
||||
let name = user.acct
|
||||
|
||||
toFollow.img = img
|
||||
|
@ -38,13 +38,7 @@ function getWhoToFollow (panel) {
|
|||
|
||||
const WhoToFollowPanel = {
|
||||
data: () => ({
|
||||
usersToFollow: new Array(3).fill().map(x => (
|
||||
{
|
||||
img: '/images/avi.png',
|
||||
name: '',
|
||||
id: 0
|
||||
}
|
||||
))
|
||||
usersToFollow: []
|
||||
}),
|
||||
computed: {
|
||||
user: function () {
|
||||
|
@ -68,6 +62,13 @@ const WhoToFollowPanel = {
|
|||
},
|
||||
mounted:
|
||||
function () {
|
||||
this.usersToFollow = new Array(3).fill().map(x => (
|
||||
{
|
||||
img: this.$store.state.instance.defaultAvatar,
|
||||
name: '',
|
||||
id: 0
|
||||
}
|
||||
))
|
||||
if (this.suggestionsEnabled) {
|
||||
getWhoToFollow(this)
|
||||
}
|
||||
|
|
|
@ -120,6 +120,7 @@
|
|||
"public_tl": "Public Timeline",
|
||||
"timeline": "Timeline",
|
||||
"twkn": "The Whole Known Network",
|
||||
"bookmarks": "Bookmarks",
|
||||
"user_search": "User Search",
|
||||
"search": "Search",
|
||||
"who_to_follow": "Who to follow",
|
||||
|
@ -163,6 +164,9 @@
|
|||
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
|
||||
"load_all": "Loading all {emojiAmount} emoji"
|
||||
},
|
||||
"errors": {
|
||||
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
|
||||
},
|
||||
"interactions": {
|
||||
"favs_repeats": "Repeats and Favorites",
|
||||
"follows": "New follows",
|
||||
|
@ -174,6 +178,7 @@
|
|||
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
|
||||
"account_not_locked_warning_link": "locked",
|
||||
"attachments_sensitive": "Mark attachments as sensitive",
|
||||
"media_description": "Media description",
|
||||
"content_type": {
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
|
@ -185,6 +190,10 @@
|
|||
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
|
||||
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
|
||||
"posting": "Posting",
|
||||
"preview": "Preview",
|
||||
"preview_empty": "Empty",
|
||||
"empty_status_error": "Can't post an empty status with no files",
|
||||
"media_description_error": "Failed to update media, try again",
|
||||
"scope_notice": {
|
||||
"public": "This post will be visible to everyone",
|
||||
"private": "This post will be visible to your followers only",
|
||||
|
@ -266,6 +275,7 @@
|
|||
"block_import_error": "Error importing blocks",
|
||||
"blocks_imported": "Blocks imported! Processing them will take a while.",
|
||||
"blocks_tab": "Blocks",
|
||||
"bot": "This is a bot account",
|
||||
"btnRadius": "Buttons",
|
||||
"cBlue": "Blue (Reply, follow)",
|
||||
"cGreen": "Green (Retweet)",
|
||||
|
@ -283,7 +293,6 @@
|
|||
"current_avatar": "Your current avatar",
|
||||
"current_mascot": "Your current mascot",
|
||||
"current_password": "Current password",
|
||||
"current_profile_banner": "Your current profile banner",
|
||||
"mutes_and_blocks": "Mutes and Blocks",
|
||||
"data_import_export_tab": "Data Import / Export",
|
||||
"default_vis": "Default visibility scope",
|
||||
|
@ -334,6 +343,12 @@
|
|||
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
|
||||
"mutes_tab": "Mutes",
|
||||
"play_videos_in_modal": "Play videos in a popup frame",
|
||||
"profile_fields": {
|
||||
"label": "Profile metadata",
|
||||
"add_field": "Add Field",
|
||||
"name": "Label",
|
||||
"value": "Content"
|
||||
},
|
||||
"use_contain_fit": "Don't crop the attachment in thumbnails",
|
||||
"name": "Name",
|
||||
"name_bio": "Name & Bio",
|
||||
|
@ -386,6 +401,12 @@
|
|||
"set_new_mascot": "Set new mascot",
|
||||
"set_new_profile_background": "Set new profile background",
|
||||
"set_new_profile_banner": "Set new profile banner",
|
||||
"reset_avatar": "Reset avatar",
|
||||
"reset_profile_background": "Reset profile background",
|
||||
"reset_profile_banner": "Reset profile banner",
|
||||
"reset_avatar_confirm": "Do you really want to reset the avatar?",
|
||||
"reset_banner_confirm": "Do you really want to reset the banner?",
|
||||
"reset_background_confirm": "Do you really want to reset the background?",
|
||||
"settings": "Settings",
|
||||
"subject_input_always_show": "Always show subject field",
|
||||
"subject_line_behavior": "Copy subject when replying",
|
||||
|
@ -613,6 +634,7 @@
|
|||
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
|
||||
"repeated": "repeated",
|
||||
"show_new": "Show new",
|
||||
"reload": "Reload",
|
||||
"up_to_date": "Up-to-date",
|
||||
"no_more_statuses": "No more statuses",
|
||||
"no_statuses": "No statuses"
|
||||
|
@ -624,6 +646,8 @@
|
|||
"pin": "Pin on profile",
|
||||
"unpin": "Unpin from profile",
|
||||
"pinned": "Pinned",
|
||||
"bookmark": "Bookmark",
|
||||
"unbookmark": "Unbookmark",
|
||||
"delete_confirm": "Do you really want to delete this status?",
|
||||
"reply_to": "Reply to",
|
||||
"replies_list": "Replies:",
|
||||
|
@ -632,7 +656,11 @@
|
|||
"status_unavailable": "Status unavailable",
|
||||
"copy_link": "Copy link to status",
|
||||
"thread_muted": "Thread muted",
|
||||
"thread_muted_and_words": ", has words:"
|
||||
"thread_muted_and_words": ", has words:",
|
||||
"show_full_subject": "Show full subject",
|
||||
"hide_full_subject": "Hide full subject",
|
||||
"show_content": "Show content",
|
||||
"hide_content": "Hide content"
|
||||
},
|
||||
"user_card": {
|
||||
"approve": "Approve",
|
||||
|
@ -715,7 +743,8 @@
|
|||
"add_reaction": "Add Reaction",
|
||||
"user_settings": "User Settings",
|
||||
"accept_follow_request": "Accept follow request",
|
||||
"reject_follow_request": "Reject follow request"
|
||||
"reject_follow_request": "Reject follow request",
|
||||
"bookmark": "Bookmark"
|
||||
},
|
||||
"upload": {
|
||||
"error": {
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
"search": "Haku"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Viestiä ei löydetty...",
|
||||
"broken_favorite": "Viestiä ei löydetty…",
|
||||
"favorited_you": "tykkäsi viestistäsi",
|
||||
"followed_you": "seuraa sinua",
|
||||
"load_older": "Lataa vanhempia ilmoituksia",
|
||||
|
@ -101,7 +101,7 @@
|
|||
},
|
||||
"post_status": {
|
||||
"new_status": "Uusi viesti",
|
||||
"account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi",
|
||||
"account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.",
|
||||
"account_not_locked_warning_link": "lukittu",
|
||||
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
|
||||
"content_type": {
|
||||
|
@ -288,7 +288,7 @@
|
|||
"authentication_methods": "Todennus",
|
||||
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
|
||||
"recovery_codes": "Palautuskoodit.",
|
||||
"waiting_a_recovery_codes": "Odotetaan palautuskoodeja...",
|
||||
"waiting_a_recovery_codes": "Odotetaan palautuskoodeja…",
|
||||
"recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.",
|
||||
"scan": {
|
||||
"title": "Skannaa",
|
||||
|
@ -575,7 +575,7 @@
|
|||
"statuses": "Viestit",
|
||||
"hidden": "Piilotettu",
|
||||
"media": "Media",
|
||||
"block_progress": "Estetään...",
|
||||
"block_progress": "Estetään…",
|
||||
"admin_menu": {
|
||||
"grant_admin": "Anna Ylläpitöoikeudet",
|
||||
"force_nsfw": "Merkitse kaikki viestit NSFW:nä",
|
||||
|
@ -601,10 +601,10 @@
|
|||
"subscribe": "Tilaa",
|
||||
"unsubscribe": "Poista tilaus",
|
||||
"unblock": "Poista esto",
|
||||
"unblock_progress": "Postetaan estoa...",
|
||||
"unblock_progress": "Postetaan estoa…",
|
||||
"unmute": "Poista mykistys",
|
||||
"unmute_progress": "Poistetaan mykistystä...",
|
||||
"mute_progress": "Mykistetään...",
|
||||
"unmute_progress": "Poistetaan mykistystä…",
|
||||
"mute_progress": "Mykistetään…",
|
||||
"hide_repeats": "Piilota toistot",
|
||||
"show_repeats": "Näytä toistot"
|
||||
},
|
||||
|
@ -674,8 +674,8 @@
|
|||
"domain_mute_card": {
|
||||
"mute": "Mykistä",
|
||||
"unmute": "Poista mykistys",
|
||||
"mute_progress": "Mykistetään...",
|
||||
"unmute_progress": "Poistetaan mykistyst..."
|
||||
"mute_progress": "Mykistetään…",
|
||||
"unmute_progress": "Poistetaan mykistyst…"
|
||||
},
|
||||
"exporter": {
|
||||
"export": "Vie",
|
||||
|
|
126
src/i18n/it.json
126
src/i18n/it.json
|
@ -34,7 +34,8 @@
|
|||
"user_search": "Ricerca utenti",
|
||||
"search": "Ricerca",
|
||||
"who_to_follow": "Chi seguire",
|
||||
"preferences": "Preferenze"
|
||||
"preferences": "Preferenze",
|
||||
"bookmarks": "Segnalibri"
|
||||
},
|
||||
"notifications": {
|
||||
"followed_you": "ti segue",
|
||||
|
@ -255,7 +256,8 @@
|
|||
"top_bar": "Barra superiore",
|
||||
"panel_header": "Titolo pannello",
|
||||
"badge_notification": "Notifica",
|
||||
"popover": "Suggerimenti, menù, sbalzi"
|
||||
"popover": "Suggerimenti, menù, sbalzi",
|
||||
"toggled": "Scambiato"
|
||||
},
|
||||
"common_colors": {
|
||||
"rgbo": "Icone, accenti, medaglie",
|
||||
|
@ -270,10 +272,59 @@
|
|||
"shadow_id": "Ombra numero {value}",
|
||||
"override": "Sostituisci",
|
||||
"component": "Componente",
|
||||
"_tab_label": "Luci ed ombre"
|
||||
"_tab_label": "Luci ed ombre",
|
||||
"components": {
|
||||
"avatarStatus": "Icona utente (vista messaggio)",
|
||||
"avatar": "Icona utente (vista profilo)",
|
||||
"topBar": "Barra superiore",
|
||||
"panelHeader": "Intestazione pannello",
|
||||
"panel": "Pannello",
|
||||
"input": "Campo d'immissione",
|
||||
"buttonPressedHover": "Pulsante (puntato e premuto)",
|
||||
"buttonPressed": "Pulsante (premuto)",
|
||||
"buttonHover": "Pulsante (puntato)",
|
||||
"button": "Pulsante",
|
||||
"popup": "Sbalzi e suggerimenti"
|
||||
},
|
||||
"filter_hint": {
|
||||
"inset_classic": "Le ombre incluse usano {0}",
|
||||
"spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre",
|
||||
"avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
|
||||
"drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
|
||||
"always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta."
|
||||
},
|
||||
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore."
|
||||
},
|
||||
"radii": {
|
||||
"_tab_label": "Raggio"
|
||||
},
|
||||
"fonts": {
|
||||
"_tab_label": "Font",
|
||||
"custom": "Personalizzato",
|
||||
"weight": "Peso (grassettatura)",
|
||||
"size": "Dimensione (in pixel)",
|
||||
"family": "Nome font",
|
||||
"components": {
|
||||
"postCode": "Font a spaziatura fissa incluso in un messaggio",
|
||||
"post": "Testo del messaggio",
|
||||
"input": "Campi d'immissione",
|
||||
"interface": "Interfaccia"
|
||||
},
|
||||
"help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema."
|
||||
},
|
||||
"preview": {
|
||||
"link": "un bel collegamentino",
|
||||
"checkbox": "Ho dato uno sguardo a termini e condizioni",
|
||||
"header_faint": "Tutto bene",
|
||||
"fine_print": "Leggi il nostro {0} per imparare un bel niente!",
|
||||
"faint_link": "utilissimo manuale",
|
||||
"input": "Sono appena atterrato a Fiumicino.",
|
||||
"mono": "contenuto",
|
||||
"text": "Altro {0} e {1}",
|
||||
"content": "Contenuto",
|
||||
"button": "Pulsante",
|
||||
"error": "Errore d'esempio",
|
||||
"header": "Anteprima"
|
||||
}
|
||||
},
|
||||
"enable_web_push_notifications": "Abilita notifiche web push",
|
||||
|
@ -335,7 +386,19 @@
|
|||
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
|
||||
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
|
||||
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.",
|
||||
"mutes_and_blocks": "Zittiti e bloccati"
|
||||
"mutes_and_blocks": "Zittiti e bloccati",
|
||||
"profile_fields": {
|
||||
"value": "Contenuto",
|
||||
"name": "Etichetta",
|
||||
"add_field": "Aggiungi campo",
|
||||
"label": "Metadati profilo"
|
||||
},
|
||||
"bot": "Questo profilo è di un robot",
|
||||
"version": {
|
||||
"frontend_version": "Versione interfaccia",
|
||||
"backend_version": "Versione backend",
|
||||
"title": "Versione"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"error_fetching": "Errore nell'aggiornamento",
|
||||
|
@ -345,7 +408,10 @@
|
|||
"collapse": "Riduci",
|
||||
"conversation": "Conversazione",
|
||||
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
|
||||
"repeated": "condiviso"
|
||||
"repeated": "condiviso",
|
||||
"no_statuses": "Nessun messaggio",
|
||||
"no_more_statuses": "Fine dei messaggi",
|
||||
"reload": "Ricarica"
|
||||
},
|
||||
"user_card": {
|
||||
"follow": "Segui",
|
||||
|
@ -424,7 +490,10 @@
|
|||
},
|
||||
"direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.",
|
||||
"direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.",
|
||||
"new_status": "Nuovo messaggio"
|
||||
"new_status": "Nuovo messaggio",
|
||||
"empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati",
|
||||
"preview_empty": "Vuoto",
|
||||
"preview": "Anteprima"
|
||||
},
|
||||
"registration": {
|
||||
"bio": "Introduzione",
|
||||
|
@ -547,5 +616,50 @@
|
|||
"error": "Non trovato.",
|
||||
"searching_for": "Cerco",
|
||||
"remote_user_resolver": "Cerca utenti remoti"
|
||||
},
|
||||
"errors": {
|
||||
"storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie."
|
||||
},
|
||||
"status": {
|
||||
"pinned": "Intestato",
|
||||
"unpin": "De-intesta",
|
||||
"pin": "Intesta al profilo",
|
||||
"delete": "Elimina messaggio",
|
||||
"repeats": "Condivisi",
|
||||
"favorites": "Preferiti"
|
||||
},
|
||||
"time": {
|
||||
"years_short": "{0}a",
|
||||
"year_short": "{0}a",
|
||||
"years": "{0} anni",
|
||||
"year": "{0} anno",
|
||||
"weeks_short": "{0}set",
|
||||
"week_short": "{0}set",
|
||||
"seconds_short": "{0}sec",
|
||||
"second_short": "{0}sec",
|
||||
"weeks": "{0} settimane",
|
||||
"week": "{0} settimana",
|
||||
"seconds": "{0} secondi",
|
||||
"second": "{0} secondo",
|
||||
"now_short": "ora",
|
||||
"now": "adesso",
|
||||
"months_short": "{0}me",
|
||||
"month_short": "{0}me",
|
||||
"months": "{0} mesi",
|
||||
"month": "{0} mese",
|
||||
"minutes_short": "{0}min",
|
||||
"minute_short": "{0}min",
|
||||
"minutes": "{0} minuti",
|
||||
"minute": "{0} minuto",
|
||||
"in_past": "{0} fa",
|
||||
"in_future": "fra {0}",
|
||||
"hours_short": "{0}h",
|
||||
"days_short": "{0}g",
|
||||
"hour_short": "{0}h",
|
||||
"hours": "{0} ore",
|
||||
"hour": "{0} ora",
|
||||
"day_short": "{0}g",
|
||||
"days": "{0} giorni",
|
||||
"day": "{0} giorno"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,12 @@
|
|||
"enable": "Inschakelen",
|
||||
"confirm": "Bevestigen",
|
||||
"verify": "Verifiëren",
|
||||
"generic_error": "Er is een fout opgetreden"
|
||||
"generic_error": "Er is een fout opgetreden",
|
||||
"peek": "Spiek",
|
||||
"close": "Sluiten",
|
||||
"retry": "Opnieuw proberen",
|
||||
"error_retry": "Probeer het opnieuw",
|
||||
"loading": "Laden…"
|
||||
},
|
||||
"login": {
|
||||
"login": "Log in",
|
||||
|
@ -90,7 +95,7 @@
|
|||
"text/bbcode": "BBCode"
|
||||
},
|
||||
"content_warning": "Onderwerp (optioneel)",
|
||||
"default": "Zojuist geland in L.A.",
|
||||
"default": "Tijd voor anime!",
|
||||
"direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
|
||||
"posting": "Plaatsen",
|
||||
"scope": {
|
||||
|
@ -377,7 +382,7 @@
|
|||
"button": "Knop",
|
||||
"text": "Nog een boel andere {0} en {1}",
|
||||
"mono": "inhoud",
|
||||
"input": "Zojuist geland in L.A.",
|
||||
"input": "Tijd voor anime!",
|
||||
"faint_link": "handige gebruikershandleiding",
|
||||
"fine_print": "Lees onze {0} om niets nuttig te leren!",
|
||||
"header_faint": "Alles komt goed",
|
||||
|
@ -451,7 +456,7 @@
|
|||
"user_mutes": "Gebruikers",
|
||||
"useStreamingApi": "Berichten en meldingen in real-time ontvangen",
|
||||
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)",
|
||||
"type_domains_to_mute": "Voer domeinen in om te negeren",
|
||||
"type_domains_to_mute": "Zoek domeinen om te negeren",
|
||||
"upload_a_photo": "Upload een foto",
|
||||
"fun": "Plezier",
|
||||
"greentext": "Meme pijlen",
|
||||
|
@ -470,7 +475,15 @@
|
|||
"frontend_version": "Frontend Versie",
|
||||
"backend_version": "Backend Versie",
|
||||
"title": "Versie"
|
||||
}
|
||||
},
|
||||
"mutes_and_blocks": "Negeringen en Blokkades",
|
||||
"profile_fields": {
|
||||
"value": "Inhoud",
|
||||
"name": "Label",
|
||||
"add_field": "Veld Toevoegen",
|
||||
"label": "Profiel metadata"
|
||||
},
|
||||
"bot": "Dit is een bot account"
|
||||
},
|
||||
"timeline": {
|
||||
"collapse": "Inklappen",
|
||||
|
@ -708,7 +721,9 @@
|
|||
"unpin": "Van profiel losmaken",
|
||||
"delete": "Status verwijderen",
|
||||
"repeats": "Herhalingen",
|
||||
"favorites": "Favorieten"
|
||||
"favorites": "Favorieten",
|
||||
"thread_muted_and_words": ", heeft woorden:",
|
||||
"thread_muted": "Thread genegeerd"
|
||||
},
|
||||
"time": {
|
||||
"years_short": "{0}j",
|
||||
|
|
|
@ -45,7 +45,8 @@
|
|||
"timeline": "Лента",
|
||||
"twkn": "Федеративная лента",
|
||||
"search": "Поиск",
|
||||
"friend_requests": "Запросы на чтение"
|
||||
"friend_requests": "Запросы на чтение",
|
||||
"bookmarks": "Закладки"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Неизвестный статус, ищем...",
|
||||
|
@ -130,6 +131,7 @@
|
|||
"background": "Фон",
|
||||
"bio": "Описание",
|
||||
"btnRadius": "Кнопки",
|
||||
"bot": "Это аккаунт бота",
|
||||
"cBlue": "Ответить, читать",
|
||||
"cGreen": "Повторить",
|
||||
"cOrange": "Нравится",
|
||||
|
@ -365,6 +367,10 @@
|
|||
"show_new": "Показать новые",
|
||||
"up_to_date": "Обновлено"
|
||||
},
|
||||
"status": {
|
||||
"bookmark": "В закладки",
|
||||
"unbookmark": "Удалить из закладок"
|
||||
},
|
||||
"user_card": {
|
||||
"block": "Заблокировать",
|
||||
"blocked": "Заблокирован",
|
||||
|
|
35
src/i18n/service_worker_messages.js
Normal file
35
src/i18n/service_worker_messages.js
Normal 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
|
|
@ -85,7 +85,7 @@
|
|||
"administration": "管理员"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "未知的状态,正在搜索中...",
|
||||
"broken_favorite": "未知的状态,正在搜索中…",
|
||||
"favorited_you": "收藏了你的状态",
|
||||
"followed_you": "关注了你",
|
||||
"load_older": "加载更早的通知",
|
||||
|
@ -185,7 +185,7 @@
|
|||
"generate_new_recovery_codes": "生成新的恢复码",
|
||||
"warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。",
|
||||
"recovery_codes": "恢复码。",
|
||||
"waiting_a_recovery_codes": "正在接收备份码……",
|
||||
"waiting_a_recovery_codes": "正在接收备份码…",
|
||||
"recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app,也丢失了你的恢复码,你的账号就再也无法登录了。",
|
||||
"authentication_methods": "身份验证方法",
|
||||
"scan": {
|
||||
|
@ -564,11 +564,11 @@
|
|||
"subscribe": "订阅",
|
||||
"unsubscribe": "退订",
|
||||
"unblock": "取消拉黑",
|
||||
"unblock_progress": "取消拉黑中...",
|
||||
"block_progress": "拉黑中...",
|
||||
"unblock_progress": "取消拉黑中…",
|
||||
"block_progress": "拉黑中…",
|
||||
"unmute": "取消隐藏",
|
||||
"unmute_progress": "取消隐藏中...",
|
||||
"mute_progress": "隐藏中...",
|
||||
"unmute_progress": "取消隐藏中…",
|
||||
"mute_progress": "隐藏中…",
|
||||
"admin_menu": {
|
||||
"moderation": "权限",
|
||||
"grant_admin": "赋予管理权限",
|
||||
|
@ -690,9 +690,9 @@
|
|||
}
|
||||
},
|
||||
"domain_mute_card": {
|
||||
"unmute_progress": "正在取消隐藏……",
|
||||
"unmute_progress": "正在取消隐藏…",
|
||||
"unmute": "取消隐藏",
|
||||
"mute_progress": "隐藏中……",
|
||||
"mute_progress": "隐藏中…",
|
||||
"mute": "隐藏"
|
||||
}
|
||||
}
|
||||
|
|
12
src/lib/notification-i18n-loader.js
Normal file
12
src/lib/notification-i18n-loader.js
Normal 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)
|
||||
}
|
16
src/main.js
16
src/main.js
|
@ -62,7 +62,15 @@ const persistedStateOptions = {
|
|||
};
|
||||
|
||||
(async () => {
|
||||
const persistedState = await createPersistedState(persistedStateOptions)
|
||||
let storageError = false
|
||||
const plugins = [pushNotifications]
|
||||
try {
|
||||
const persistedState = await createPersistedState(persistedStateOptions)
|
||||
plugins.push(persistedState)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
storageError = true
|
||||
}
|
||||
const store = new Vuex.Store({
|
||||
modules: {
|
||||
i18n: {
|
||||
|
@ -85,11 +93,13 @@ const persistedStateOptions = {
|
|||
polls: pollsModule,
|
||||
postStatus: postStatusModule
|
||||
},
|
||||
plugins: [persistedState, pushNotifications],
|
||||
plugins,
|
||||
strict: false // Socket modifies itself, let's ignore this for now.
|
||||
// strict: process.env.NODE_ENV !== 'production'
|
||||
})
|
||||
|
||||
if (storageError) {
|
||||
store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
|
||||
}
|
||||
afterStoreSetup({ store, i18n })
|
||||
})()
|
||||
|
||||
|
|
|
@ -138,9 +138,6 @@ const api = {
|
|||
if (!fetcher) return
|
||||
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
|
||||
},
|
||||
fetchAndUpdateNotifications (store) {
|
||||
store.state.backendInteractor.fetchAndUpdateNotifications({ store })
|
||||
},
|
||||
|
||||
// Follow requests
|
||||
startFetchingFollowRequests (store) {
|
||||
|
|
|
@ -15,6 +15,8 @@ const defaultState = {
|
|||
|
||||
// Stuff from static/config.json
|
||||
alwaysShowSubjectInput: true,
|
||||
defaultAvatar: '/images/avi.png',
|
||||
defaultBanner: '/images/banner.png',
|
||||
background: '/static/aurora_borealis.jpg',
|
||||
collapseMessageWithSubject: false,
|
||||
disableChat: false,
|
||||
|
|
|
@ -14,7 +14,8 @@ const defaultState = {
|
|||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
|
||||
)
|
||||
},
|
||||
mobileLayout: false
|
||||
mobileLayout: false,
|
||||
globalNotices: []
|
||||
}
|
||||
|
||||
const interfaceMod = {
|
||||
|
@ -58,6 +59,12 @@ const interfaceMod = {
|
|||
if (!state.settingsModalLoaded) {
|
||||
state.settingsModalLoaded = true
|
||||
}
|
||||
},
|
||||
pushGlobalNotice (state, notice) {
|
||||
state.globalNotices.push(notice)
|
||||
},
|
||||
removeGlobalNotice (state, notice) {
|
||||
state.globalNotices = state.globalNotices.filter(n => n !== notice)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
@ -81,6 +88,28 @@ const interfaceMod = {
|
|||
},
|
||||
togglePeekSettingsModal ({ commit }) {
|
||||
commit('togglePeekSettingsModal')
|
||||
},
|
||||
pushGlobalNotice (
|
||||
{ commit, dispatch },
|
||||
{
|
||||
messageKey,
|
||||
messageArgs = {},
|
||||
level = 'error',
|
||||
timeout = 0
|
||||
}) {
|
||||
const notice = {
|
||||
messageKey,
|
||||
messageArgs,
|
||||
level
|
||||
}
|
||||
if (timeout) {
|
||||
setTimeout(() => dispatch('removeGlobalNotice', notice), timeout)
|
||||
}
|
||||
commit('pushGlobalNotice', notice)
|
||||
return notice
|
||||
},
|
||||
removeGlobalNotice ({ commit }, notice) {
|
||||
commit('removeGlobalNotice', notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ const mediaViewer = {
|
|||
setMedia ({ commit }, attachments) {
|
||||
const media = attachments.filter(attachment => {
|
||||
const type = fileTypeService.fileType(attachment.mimetype)
|
||||
return type === 'image' || type === 'video'
|
||||
return type === 'image' || type === 'video' || type === 'audio'
|
||||
})
|
||||
commit('setMedia', media)
|
||||
},
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
omitBy
|
||||
} from 'lodash'
|
||||
import { set } from 'vue'
|
||||
import { isStatusNotification } from '../services/notification_utils/notification_utils.js'
|
||||
import { isStatusNotification, prepareNotificationObject } from '../services/notification_utils/notification_utils.js'
|
||||
import apiService from '../services/api/api.service.js'
|
||||
import { muteWordHits } from '../services/status_parser/status_parser.js'
|
||||
|
||||
|
@ -62,7 +62,8 @@ export const defaultState = () => ({
|
|||
publicAndExternal: emptyTl(),
|
||||
friends: emptyTl(),
|
||||
tag: emptyTl(),
|
||||
dms: emptyTl()
|
||||
dms: emptyTl(),
|
||||
bookmarks: emptyTl()
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -163,8 +164,7 @@ const removeStatusFromGlobalStorage = (state, status) => {
|
|||
}
|
||||
}
|
||||
|
||||
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {},
|
||||
noIdUpdate = false, userId }) => {
|
||||
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
|
||||
// Sanity check
|
||||
if (!isArray(statuses)) {
|
||||
return false
|
||||
|
@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
|
|||
const allStatuses = state.allStatuses
|
||||
const timelineObject = state.timelines[timeline]
|
||||
|
||||
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0
|
||||
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0
|
||||
// Mismatch between API pagination and our internal minId/maxId tracking systems:
|
||||
// pagination.maxId is the oldest of the returned statuses when fetching older,
|
||||
// and pagination.minId is the newest when fetching newer. The names come directly
|
||||
// from the arguments they're supposed to be passed as for the next fetch.
|
||||
const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
|
||||
const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
|
||||
|
||||
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
|
||||
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
|
||||
|
||||
|
@ -315,7 +320,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
|
|||
})
|
||||
|
||||
// Keep the visible statuses sorted
|
||||
if (timeline) {
|
||||
if (timeline && !(timeline === 'bookmarks')) {
|
||||
sortTimeline(timelineObject)
|
||||
}
|
||||
}
|
||||
|
@ -344,42 +349,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
|
|||
state.notifications.idStore[notification.id] = notification
|
||||
|
||||
if ('Notification' in window && window.Notification.permission === 'granted') {
|
||||
const notifObj = {}
|
||||
const status = notification.status
|
||||
const title = notification.from_profile.name
|
||||
notifObj.icon = notification.from_profile.profile_image_url
|
||||
let i18nString
|
||||
switch (notification.type) {
|
||||
case 'like':
|
||||
i18nString = 'favorited_you'
|
||||
break
|
||||
case 'repeat':
|
||||
i18nString = 'repeated_you'
|
||||
break
|
||||
case 'follow':
|
||||
i18nString = 'followed_you'
|
||||
break
|
||||
case 'move':
|
||||
i18nString = 'migrated_to'
|
||||
break
|
||||
case 'follow_request':
|
||||
i18nString = 'follow_request'
|
||||
break
|
||||
}
|
||||
|
||||
if (notification.type === 'pleroma:emoji_reaction') {
|
||||
notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
|
||||
} else if (i18nString) {
|
||||
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
|
||||
} else if (isStatusNotification(notification.type)) {
|
||||
notifObj.body = notification.status.text
|
||||
}
|
||||
|
||||
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
|
||||
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
|
||||
status.attachments[0].mimetype.startsWith('image/')) {
|
||||
notifObj.image = status.attachments[0].url
|
||||
}
|
||||
const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
|
||||
|
||||
const reasonsToMuteNotif = (
|
||||
notification.seen ||
|
||||
|
@ -393,7 +363,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
|
|||
)
|
||||
)
|
||||
if (!reasonsToMuteNotif) {
|
||||
let desktopNotification = new window.Notification(title, notifObj)
|
||||
let desktopNotification = new window.Notification(notifObj.title, notifObj)
|
||||
// Chrome is known for not closing notifications automatically
|
||||
// according to MDN, anyway.
|
||||
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
|
||||
|
@ -498,6 +468,14 @@ export const mutations = {
|
|||
newStatus.rebloggedBy.push(user)
|
||||
}
|
||||
},
|
||||
setBookmarked (state, { status, value }) {
|
||||
const newStatus = state.allStatusesObject[status.id]
|
||||
newStatus.bookmarked = value
|
||||
},
|
||||
setBookmarkedConfirm (state, { status }) {
|
||||
const newStatus = state.allStatusesObject[status.id]
|
||||
newStatus.bookmarked = status.bookmarked
|
||||
},
|
||||
setDeleted (state, { status }) {
|
||||
const newStatus = state.allStatusesObject[status.id]
|
||||
newStatus.deleted = true
|
||||
|
@ -550,6 +528,11 @@ export const mutations = {
|
|||
queueFlush (state, { timeline, id }) {
|
||||
state.timelines[timeline].flushMarker = id
|
||||
},
|
||||
queueFlushAll (state) {
|
||||
Object.keys(state.timelines).forEach((timeline) => {
|
||||
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
|
||||
})
|
||||
},
|
||||
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
|
||||
const newStatus = state.allStatusesObject[id]
|
||||
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
|
||||
|
@ -620,8 +603,8 @@ export const mutations = {
|
|||
const statuses = {
|
||||
state: defaultState(),
|
||||
actions: {
|
||||
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) {
|
||||
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId })
|
||||
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
|
||||
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
|
||||
},
|
||||
addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
|
||||
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
|
||||
|
@ -696,9 +679,26 @@ const statuses = {
|
|||
rootState.api.backendInteractor.unretweet({ id: status.id })
|
||||
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
|
||||
},
|
||||
bookmark ({ rootState, commit }, status) {
|
||||
commit('setBookmarked', { status, value: true })
|
||||
rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
|
||||
.then(status => {
|
||||
commit('setBookmarkedConfirm', { status })
|
||||
})
|
||||
},
|
||||
unbookmark ({ rootState, commit }, status) {
|
||||
commit('setBookmarked', { status, value: false })
|
||||
rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
|
||||
.then(status => {
|
||||
commit('setBookmarkedConfirm', { status })
|
||||
})
|
||||
},
|
||||
queueFlush ({ rootState, commit }, { timeline, id }) {
|
||||
commit('queueFlush', { timeline, id })
|
||||
},
|
||||
queueFlushAll ({ rootState, commit }) {
|
||||
commit('queueFlushAll')
|
||||
},
|
||||
markNotificationsAsSeen ({ rootState, commit }) {
|
||||
commit('markNotificationsAsSeen')
|
||||
apiService.markNotificationsAsSeen({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
|
||||
import oauthApi from '../services/new_api/oauth.js'
|
||||
import { compact, map, each, merge, last, concat, uniq } from 'lodash'
|
||||
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
|
||||
import { set } from 'vue'
|
||||
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
|
||||
|
||||
|
@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => {
|
|||
const oldItem = obj[item.id]
|
||||
if (oldItem) {
|
||||
// We already have this, so only merge the new info.
|
||||
merge(oldItem, item)
|
||||
mergeWith(oldItem, item, mergeArrayLength)
|
||||
return { item: oldItem, new: false }
|
||||
} else {
|
||||
// This is a new item, prepare it
|
||||
|
@ -23,6 +23,13 @@ export const mergeOrAdd = (arr, obj, item) => {
|
|||
}
|
||||
}
|
||||
|
||||
const mergeArrayLength = (oldValue, newValue) => {
|
||||
if (isArray(oldValue) && isArray(newValue)) {
|
||||
oldValue.length = newValue.length
|
||||
return mergeWith(oldValue, newValue, mergeArrayLength)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationPermission = () => {
|
||||
const Notification = window.Notification
|
||||
|
||||
|
@ -120,7 +127,7 @@ export const mutations = {
|
|||
},
|
||||
setCurrentUser (state, user) {
|
||||
state.lastLoginName = user.screen_name
|
||||
state.currentUser = merge(state.currentUser || {}, user)
|
||||
state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
|
||||
},
|
||||
clearCurrentUser (state) {
|
||||
state.currentUser = false
|
||||
|
@ -266,6 +273,11 @@ const users = {
|
|||
mutations,
|
||||
getters,
|
||||
actions: {
|
||||
fetchUserIfMissing (store, id) {
|
||||
if (!store.getters.findUser(id)) {
|
||||
store.dispatch('fetchUser', id)
|
||||
}
|
||||
},
|
||||
fetchUser (store, id) {
|
||||
return store.rootState.api.backendInteractor.fetchUser({ id })
|
||||
.then((user) => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { each, map, concat, last, get } from 'lodash'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import { RegistrationError, StatusCodeError } from '../errors/errors'
|
||||
|
||||
/* eslint-env browser */
|
||||
|
@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
|
|||
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
|
||||
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
|
||||
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
|
||||
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
|
||||
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
|
||||
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
|
||||
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
|
||||
|
@ -58,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
|
|||
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
|
||||
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
|
||||
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
|
||||
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
|
||||
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
|
||||
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
|
||||
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
|
||||
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
|
||||
|
@ -139,20 +142,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
|
|||
}).then((data) => data.json())
|
||||
}
|
||||
|
||||
const updateAvatar = ({ credentials, avatar }) => {
|
||||
const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
|
||||
const form = new FormData()
|
||||
form.append('avatar', avatar)
|
||||
return fetch(MASTODON_PROFILE_UPDATE_URL, {
|
||||
headers: authHeaders(credentials),
|
||||
method: 'PATCH',
|
||||
body: form
|
||||
}).then((data) => data.json())
|
||||
.then((data) => parseUser(data))
|
||||
}
|
||||
|
||||
const updateBg = ({ credentials, background }) => {
|
||||
const form = new FormData()
|
||||
form.append('pleroma_background_image', background)
|
||||
if (avatar !== null) form.append('avatar', avatar)
|
||||
if (banner !== null) form.append('header', banner)
|
||||
if (background !== null) form.append('pleroma_background_image', background)
|
||||
return fetch(MASTODON_PROFILE_UPDATE_URL, {
|
||||
headers: authHeaders(credentials),
|
||||
method: 'PATCH',
|
||||
|
@ -162,17 +156,6 @@ const updateBg = ({ credentials, background }) => {
|
|||
.then((data) => parseUser(data))
|
||||
}
|
||||
|
||||
const updateBanner = ({ credentials, banner }) => {
|
||||
const form = new FormData()
|
||||
form.append('header', banner)
|
||||
return fetch(MASTODON_PROFILE_UPDATE_URL, {
|
||||
headers: authHeaders(credentials),
|
||||
method: 'PATCH',
|
||||
body: form
|
||||
}).then((data) => data.json())
|
||||
.then((data) => parseUser(data))
|
||||
}
|
||||
|
||||
const updateProfile = ({ credentials, params }) => {
|
||||
return promisedRequest({
|
||||
url: MASTODON_PROFILE_UPDATE_URL,
|
||||
|
@ -499,7 +482,8 @@ const fetchTimeline = ({
|
|||
until = false,
|
||||
userId = false,
|
||||
tag = false,
|
||||
withMuted = false
|
||||
withMuted = false,
|
||||
replyVisibility = 'all'
|
||||
}) => {
|
||||
const timelineUrls = {
|
||||
public: MASTODON_PUBLIC_TIMELINE,
|
||||
|
@ -510,7 +494,8 @@ const fetchTimeline = ({
|
|||
user: MASTODON_USER_TIMELINE_URL,
|
||||
media: MASTODON_USER_TIMELINE_URL,
|
||||
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
|
||||
tag: MASTODON_TAG_TIMELINE_URL
|
||||
tag: MASTODON_TAG_TIMELINE_URL,
|
||||
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
|
||||
}
|
||||
const isNotifications = timeline === 'notifications'
|
||||
const params = []
|
||||
|
@ -539,9 +524,12 @@ const fetchTimeline = ({
|
|||
if (timeline === 'public' || timeline === 'publicAndExternal') {
|
||||
params.push(['only_media', false])
|
||||
}
|
||||
if (timeline !== 'favorites') {
|
||||
if (timeline !== 'favorites' && timeline !== 'bookmarks') {
|
||||
params.push(['with_muted', withMuted])
|
||||
}
|
||||
if (replyVisibility !== 'all') {
|
||||
params.push(['reply_visibility', replyVisibility])
|
||||
}
|
||||
|
||||
params.push(['limit', 20])
|
||||
|
||||
|
@ -549,16 +537,20 @@ const fetchTimeline = ({
|
|||
url += `?${queryString}`
|
||||
let status = ''
|
||||
let statusText = ''
|
||||
let pagination = {}
|
||||
return fetch(url, { headers: authHeaders(credentials) })
|
||||
.then((data) => {
|
||||
status = data.status
|
||||
statusText = data.statusText
|
||||
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
|
||||
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
|
||||
})
|
||||
return data
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((data) => {
|
||||
if (!data.error) {
|
||||
return data.map(isNotifications ? parseNotification : parseStatus)
|
||||
return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
|
||||
} else {
|
||||
data.status = status
|
||||
data.statusText = statusText
|
||||
|
@ -609,6 +601,22 @@ const unretweet = ({ id, credentials }) => {
|
|||
.then((data) => parseStatus(data))
|
||||
}
|
||||
|
||||
const bookmarkStatus = ({ id, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: MASTODON_BOOKMARK_STATUS_URL(id),
|
||||
headers: authHeaders(credentials),
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
const unbookmarkStatus = ({ id, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
|
||||
headers: authHeaders(credentials),
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
const postStatus = ({
|
||||
credentials,
|
||||
status,
|
||||
|
@ -618,7 +626,8 @@ const postStatus = ({
|
|||
poll,
|
||||
mediaIds = [],
|
||||
inReplyToStatusId,
|
||||
contentType
|
||||
contentType,
|
||||
preview
|
||||
}) => {
|
||||
const form = new FormData()
|
||||
const pollOptions = poll.options || []
|
||||
|
@ -648,6 +657,9 @@ const postStatus = ({
|
|||
if (inReplyToStatusId) {
|
||||
form.append('in_reply_to_id', inReplyToStatusId)
|
||||
}
|
||||
if (preview) {
|
||||
form.append('preview', 'true')
|
||||
}
|
||||
|
||||
return fetch(MASTODON_POST_STATUS_URL, {
|
||||
body: form,
|
||||
|
@ -655,13 +667,7 @@ const postStatus = ({
|
|||
headers: authHeaders(credentials)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
} else {
|
||||
return {
|
||||
error: response
|
||||
}
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((data) => data.error ? data : parseStatus(data))
|
||||
}
|
||||
|
@ -683,6 +689,17 @@ const uploadMedia = ({ formData, credentials }) => {
|
|||
.then((data) => parseAttachment(data))
|
||||
}
|
||||
|
||||
const setMediaDescription = ({ id, description, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
|
||||
method: 'PUT',
|
||||
headers: authHeaders(credentials),
|
||||
payload: {
|
||||
description
|
||||
}
|
||||
}).then((data) => parseAttachment(data))
|
||||
}
|
||||
|
||||
const importBlocks = ({ file, credentials }) => {
|
||||
const formData = new FormData()
|
||||
formData.append('list', file)
|
||||
|
@ -1161,9 +1178,12 @@ const apiService = {
|
|||
unfavorite,
|
||||
retweet,
|
||||
unretweet,
|
||||
bookmarkStatus,
|
||||
unbookmarkStatus,
|
||||
postStatus,
|
||||
deleteStatus,
|
||||
uploadMedia,
|
||||
setMediaDescription,
|
||||
fetchMutes,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
|
@ -1181,10 +1201,8 @@ const apiService = {
|
|||
deactivateUser,
|
||||
register,
|
||||
getCaptcha,
|
||||
updateAvatar,
|
||||
updateBg,
|
||||
updateProfileImages,
|
||||
updateProfile,
|
||||
updateBanner,
|
||||
importBlocks,
|
||||
importFollows,
|
||||
deleteAccount,
|
||||
|
|
|
@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({
|
|||
return notificationsFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
||||
fetchAndUpdateNotifications ({ store }) {
|
||||
return notificationsFetcher.fetchAndUpdate({ store, credentials })
|
||||
},
|
||||
|
||||
startFetchingFollowRequests ({ store }) {
|
||||
return followRequestFetcher.startFetching({ store, credentials })
|
||||
},
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import escape from 'escape-html'
|
||||
import parseLinkHeader from 'parse-link-header'
|
||||
import { isStatusNotification } from '../notification_utils/notification_utils.js'
|
||||
|
||||
const qvitterStatusType = (status) => {
|
||||
|
@ -232,6 +233,8 @@ export const parseStatus = (data) => {
|
|||
output.repeated = data.reblogged
|
||||
output.repeat_num = data.reblogs_count
|
||||
|
||||
output.bookmarked = data.bookmarked
|
||||
|
||||
output.type = data.reblog ? 'retweet' : 'status'
|
||||
output.nsfw = data.sensitive
|
||||
|
||||
|
@ -248,6 +251,7 @@ export const parseStatus = (data) => {
|
|||
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
|
||||
output.thread_muted = pleroma.thread_muted
|
||||
output.emoji_reactions = pleroma.emoji_reactions
|
||||
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
|
||||
} else {
|
||||
output.text = data.content
|
||||
output.summary = data.spoiler_text
|
||||
|
@ -381,3 +385,16 @@ const isNsfw = (status) => {
|
|||
const nsfwRegex = /#nsfw/i
|
||||
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
|
||||
}
|
||||
|
||||
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
|
||||
const flakeId = opts.flakeId
|
||||
const parsedLinkHeader = parseLinkHeader(linkHeader)
|
||||
if (!parsedLinkHeader) return
|
||||
const maxId = parsedLinkHeader.next.max_id
|
||||
const minId = parsedLinkHeader.prev.min_id
|
||||
|
||||
return {
|
||||
maxId: flakeId ? maxId : parseInt(maxId, 10),
|
||||
minId: flakeId ? minId : parseInt(minId, 10)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => {
|
|||
return apiService.fetchFollowRequests({ credentials })
|
||||
.then((requests) => {
|
||||
store.commit('setFollowRequests', requests)
|
||||
store.commit('addNewUsers', requests)
|
||||
}, () => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
|
|
@ -43,3 +43,47 @@ export const filteredNotificationsFromStore = (store, types) => {
|
|||
|
||||
export const unseenNotificationsFromStore = store =>
|
||||
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
|
||||
|
||||
export const prepareNotificationObject = (notification, i18n) => {
|
||||
const notifObj = {
|
||||
tag: notification.id
|
||||
}
|
||||
const status = notification.status
|
||||
const title = notification.from_profile.name
|
||||
notifObj.title = title
|
||||
notifObj.icon = notification.from_profile.profile_image_url
|
||||
let i18nString
|
||||
switch (notification.type) {
|
||||
case 'like':
|
||||
i18nString = 'favorited_you'
|
||||
break
|
||||
case 'repeat':
|
||||
i18nString = 'repeated_you'
|
||||
break
|
||||
case 'follow':
|
||||
i18nString = 'followed_you'
|
||||
break
|
||||
case 'move':
|
||||
i18nString = 'migrated_to'
|
||||
break
|
||||
case 'follow_request':
|
||||
i18nString = 'follow_request'
|
||||
break
|
||||
}
|
||||
|
||||
if (notification.type === 'pleroma:emoji_reaction') {
|
||||
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
|
||||
} else if (i18nString) {
|
||||
notifObj.body = i18n.t('notifications.' + i18nString)
|
||||
} else if (isStatusNotification(notification.type)) {
|
||||
notifObj.body = notification.status.text
|
||||
}
|
||||
|
||||
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
|
||||
if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
|
||||
status.attachments[0].mimetype.startsWith('image/')) {
|
||||
notifObj.image = status.attachments[0].url
|
||||
}
|
||||
|
||||
return notifObj
|
||||
}
|
||||
|
|
|
@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
|
|||
}
|
||||
const result = fetchNotifications({ store, args, older })
|
||||
|
||||
// load unread notifications repeatedly to provide consistency between browser tabs
|
||||
// If there's any unread notifications, try fetch notifications since
|
||||
// the newest read notification to check if any of the unread notifs
|
||||
// have changed their 'seen' state (marked as read in another session), so
|
||||
// we can update the state in this session to mark them as read as well.
|
||||
// The normal maxId-check does not tell if older notifications have changed
|
||||
const notifications = timelineData.data
|
||||
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
|
||||
if (readNotifsIds.length) {
|
||||
const numUnseenNotifs = notifications.length - readNotifsIds.length
|
||||
if (numUnseenNotifs > 0) {
|
||||
args['since'] = Math.max(...readNotifsIds)
|
||||
fetchNotifications({ store, args, older })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNotifications = ({ store, args, older }) => {
|
||||
return apiService.fetchTimeline(args)
|
||||
.then((notifications) => {
|
||||
.then(({ data: notifications }) => {
|
||||
update({ store, notifications, older })
|
||||
return notifications
|
||||
}, () => store.dispatch('setNotificationsError', { value: true }))
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { map } from 'lodash'
|
||||
import apiService from '../api/api.service.js'
|
||||
|
||||
const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
|
||||
const postStatus = ({
|
||||
store,
|
||||
status,
|
||||
spoilerText,
|
||||
visibility,
|
||||
sensitive,
|
||||
poll,
|
||||
media = [],
|
||||
inReplyToStatusId = undefined,
|
||||
contentType = 'text/plain',
|
||||
preview = false
|
||||
}) => {
|
||||
const mediaIds = map(media, 'id')
|
||||
|
||||
return apiService.postStatus({
|
||||
|
@ -13,9 +24,11 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
|
|||
mediaIds,
|
||||
inReplyToStatusId,
|
||||
contentType,
|
||||
poll })
|
||||
poll,
|
||||
preview
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data.error) {
|
||||
if (!data.error && !preview) {
|
||||
store.dispatch('addNewStatuses', {
|
||||
statuses: [data],
|
||||
timeline: 'friends',
|
||||
|
@ -34,13 +47,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
|
|||
|
||||
const uploadMedia = ({ store, formData }) => {
|
||||
const credentials = store.state.users.currentUser.credentials
|
||||
|
||||
return apiService.uploadMedia({ credentials, formData })
|
||||
}
|
||||
|
||||
const setMediaDescription = ({ store, id, description }) => {
|
||||
const credentials = store.state.users.currentUser.credentials
|
||||
return apiService.setMediaDescription({ credentials, id, description })
|
||||
}
|
||||
|
||||
const statusPosterService = {
|
||||
postStatus,
|
||||
uploadMedia
|
||||
uploadMedia,
|
||||
setMediaDescription
|
||||
}
|
||||
|
||||
export default statusPosterService
|
||||
|
|
|
@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
|
|||
alert: 0.5,
|
||||
input: 0.5,
|
||||
faint: 0.5,
|
||||
underlay: 0.15
|
||||
underlay: 0.15,
|
||||
alertPopup: 0.95
|
||||
}
|
||||
|
||||
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
|
||||
|
@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
|
|||
textColor: true
|
||||
},
|
||||
|
||||
alertPopupError: {
|
||||
depends: ['alertError'],
|
||||
opacity: 'alertPopup'
|
||||
},
|
||||
alertPopupErrorText: {
|
||||
depends: ['alertErrorText'],
|
||||
layer: 'popover',
|
||||
variant: 'alertPopupError',
|
||||
textColor: true
|
||||
},
|
||||
|
||||
alertPopupWarning: {
|
||||
depends: ['alertWarning'],
|
||||
opacity: 'alertPopup'
|
||||
},
|
||||
alertPopupWarningText: {
|
||||
depends: ['alertWarningText'],
|
||||
layer: 'popover',
|
||||
variant: 'alertPopupWarning',
|
||||
textColor: true
|
||||
},
|
||||
|
||||
alertPopupNeutral: {
|
||||
depends: ['alertNeutral'],
|
||||
opacity: 'alertPopup'
|
||||
},
|
||||
alertPopupNeutralText: {
|
||||
depends: ['alertNeutralText'],
|
||||
layer: 'popover',
|
||||
variant: 'alertPopupNeutral',
|
||||
textColor: true
|
||||
},
|
||||
|
||||
badgeNotification: '--cRed',
|
||||
badgeNotificationText: {
|
||||
depends: ['text', 'badgeNotification'],
|
||||
|
|
|
@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
|
|||
|
||||
import apiService from '../api/api.service.js'
|
||||
|
||||
const update = ({ store, statuses, timeline, showImmediately, userId }) => {
|
||||
const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
|
||||
const ccTimeline = camelCase(timeline)
|
||||
|
||||
store.dispatch('setError', { value: false })
|
||||
|
@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
|
|||
timeline: ccTimeline,
|
||||
userId,
|
||||
statuses,
|
||||
showImmediately
|
||||
showImmediately,
|
||||
pagination
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -30,7 +31,8 @@ const fetchAndUpdate = ({
|
|||
const rootState = store.rootState || store.state
|
||||
const { getters } = store
|
||||
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
|
||||
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
|
||||
const { hideMutedPosts, replyVisibility } = getters.mergedConfig
|
||||
const loggedIn = !!rootState.users.currentUser
|
||||
|
||||
if (older) {
|
||||
args['until'] = until || timelineData.minId
|
||||
|
@ -41,20 +43,23 @@ const fetchAndUpdate = ({
|
|||
args['userId'] = userId
|
||||
args['tag'] = tag
|
||||
args['withMuted'] = !hideMutedPosts
|
||||
if (loggedIn) args['replyVisibility'] = replyVisibility
|
||||
|
||||
const numStatusesBeforeFetch = timelineData.statuses.length
|
||||
|
||||
return apiService.fetchTimeline(args)
|
||||
.then((statuses) => {
|
||||
if (statuses.error) {
|
||||
store.dispatch('setErrorData', { value: statuses })
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
store.dispatch('setErrorData', { value: response })
|
||||
return
|
||||
}
|
||||
|
||||
const { data: statuses, pagination } = response
|
||||
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
|
||||
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
|
||||
}
|
||||
update({ store, statuses, timeline, showImmediately, userId })
|
||||
return statuses
|
||||
update({ store, statuses, timeline, showImmediately, userId, pagination })
|
||||
return { statuses, pagination }
|
||||
}, () => store.dispatch('setError', { value: true }))
|
||||
}
|
||||
|
||||
|
|
47
src/sw.js
47
src/sw.js
|
@ -1,6 +1,19 @@
|
|||
/* eslint-env serviceworker */
|
||||
|
||||
import localForage from 'localforage'
|
||||
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
|
||||
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import messages from './i18n/service_worker_messages.js'
|
||||
|
||||
Vue.use(VueI18n)
|
||||
const i18n = new VueI18n({
|
||||
// By default, use the browser locale, we will update it if neccessary
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
|
||||
function isEnabled () {
|
||||
return localForage.getItem('vuex-lz')
|
||||
|
@ -12,15 +25,33 @@ function getWindowClients () {
|
|||
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (event.data) {
|
||||
event.waitUntil(isEnabled().then((isEnabled) => {
|
||||
return isEnabled && getWindowClients().then((list) => {
|
||||
const data = event.data.json()
|
||||
const setLocale = async () => {
|
||||
const state = await localForage.getItem('vuex-lz')
|
||||
const locale = state.config.interfaceLanguage || 'en'
|
||||
i18n.locale = locale
|
||||
}
|
||||
|
||||
if (list.length === 0) return self.registration.showNotification(data.title, data)
|
||||
})
|
||||
}))
|
||||
const maybeShowNotification = async (event) => {
|
||||
const enabled = await isEnabled()
|
||||
const activeClients = await getWindowClients()
|
||||
await setLocale()
|
||||
if (enabled && (activeClients.length === 0)) {
|
||||
const data = event.data.json()
|
||||
|
||||
const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
|
||||
const notification = await fetch(url, { headers: { Authorization: 'Bearer ' + data.access_token } })
|
||||
const notificationJson = await notification.json()
|
||||
const parsedNotification = parseNotification(notificationJson)
|
||||
|
||||
const res = prepareNotificationObject(parsedNotification, i18n)
|
||||
|
||||
self.registration.showNotification(res.title, res)
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('push', async (event) => {
|
||||
if (event.data) {
|
||||
event.waitUntil(maybeShowNotification(event))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
24
static/fontello.json
Executable file → Normal file
24
static/fontello.json
Executable file → Normal file
|
@ -375,6 +375,30 @@
|
|||
"css": "download",
|
||||
"code": 59429,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "f04a5d24e9e659145b966739c4fde82a",
|
||||
"css": "bookmark",
|
||||
"code": 59430,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "2f5ef6f6b7aaebc56458ab4e865beff5",
|
||||
"css": "bookmark-empty",
|
||||
"code": 61591,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "9ea0a737ccc45d6c510dcbae56058849",
|
||||
"css": "music",
|
||||
"code": 59432,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "1b5a5d7b7e3c71437f5a26befdd045ed",
|
||||
"css": "doc",
|
||||
"code": 59433,
|
||||
"src": "fontawesome"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -18,6 +18,42 @@ describe('The users module', () => {
|
|||
expect(state.users).to.eql([user])
|
||||
expect(state.users[0].name).to.eql('Dude')
|
||||
})
|
||||
|
||||
it('merging array field in new information for old users', () => {
|
||||
const state = cloneDeep(defaultState)
|
||||
const user = {
|
||||
id: '1',
|
||||
fields: [
|
||||
{ name: 'Label 1', value: 'Content 1' }
|
||||
]
|
||||
}
|
||||
const firstModUser = {
|
||||
id: '1',
|
||||
fields: [
|
||||
{ name: 'Label 2', value: 'Content 2' },
|
||||
{ name: 'Label 3', value: 'Content 3' }
|
||||
]
|
||||
}
|
||||
const secondModUser = {
|
||||
id: '1',
|
||||
fields: [
|
||||
{ name: 'Label 4', value: 'Content 4' }
|
||||
]
|
||||
}
|
||||
|
||||
mutations.addNewUsers(state, [user])
|
||||
expect(state.users[0].fields).to.have.length(1)
|
||||
expect(state.users[0].fields[0].name).to.eql('Label 1')
|
||||
|
||||
mutations.addNewUsers(state, [firstModUser])
|
||||
expect(state.users[0].fields).to.have.length(2)
|
||||
expect(state.users[0].fields[0].name).to.eql('Label 2')
|
||||
expect(state.users[0].fields[1].name).to.eql('Label 3')
|
||||
|
||||
mutations.addNewUsers(state, [secondModUser])
|
||||
expect(state.users[0].fields).to.have.length(1)
|
||||
expect(state.users[0].fields[0].name).to.eql('Label 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findUser', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
|
||||
import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
|
||||
import mastoapidata from '../../../../fixtures/mastoapi.json'
|
||||
import qvitterapidata from '../../../../fixtures/statuses.json'
|
||||
|
||||
|
@ -383,4 +383,24 @@ describe('API Entities normalizer', () => {
|
|||
expect(result).to.include('title=\':[a-z] {|}*:\'')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Link header pagination', () => {
|
||||
it('Parses min and max ids as integers', () => {
|
||||
const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
|
||||
const result = parseLinkHeaderPagination(linkHeader)
|
||||
expect(result).to.eql({
|
||||
'maxId': 861676,
|
||||
'minId': 861741
|
||||
})
|
||||
})
|
||||
|
||||
it('Parses min and max ids as flakes', () => {
|
||||
const linkHeader = '<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
|
||||
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
|
||||
expect(result).to.eql({
|
||||
'maxId': '9waQx5IIS48qVue2Ai',
|
||||
'minId': '9wi61nIPnfn674xgie'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -5751,6 +5751,13 @@ parse-json@^4.0.0:
|
|||
error-ex "^1.3.1"
|
||||
json-parse-better-errors "^1.0.1"
|
||||
|
||||
parse-link-header@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7"
|
||||
integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc=
|
||||
dependencies:
|
||||
xtend "~4.0.1"
|
||||
|
||||
parseqs@0.0.5:
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
|
||||
|
|
Loading…
Reference in a new issue