Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
408 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/App.js b/src/App.js
index 33645c63d5..6da2be461a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,269 +1,270 @@
import { throttle } from 'lodash'
import { defineAsyncComponent } from 'vue'
import { mapGetters } from 'vuex'
+import { useInstanceStore } from 'src/stores/instance.js'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import UserPanel from './components/user_panel/user_panel.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { useInterfaceStore } from './stores/interface'
import { useShoutStore } from './stores/shout'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('./components/notifications/notifications.vue'),
),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(
() => import('./components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() => import('./components/update_notification/update_notification.vue'),
),
UserReportingModal,
PostStatusModal,
EditStatusModal,
StatusHistoryModal,
GlobalNoticeList,
},
data: () => ({
mobileActivePanel: 'timeline',
}),
watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
// Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (useInterfaceStore().themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
themeApplied() {
return useInterfaceStore().themeApplied
},
currentTheme() {
if (useInterfaceStore().styleDataUsed) {
const styleMeta = useInterfaceStore().styleDataUsed.find(
(x) => x.component === '@meta',
)
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
return '-' + this.layoutType
},
classes() {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
},
'-' + this.layoutType,
]
},
navClasses() {
const { navbarColumnStretch } = this.$store.getters.mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
instanceBackground() {
return this.mergedConfig.hideInstanceWallpaper
? null
- : this.$store.state.instance.background
+ : useInstanceStore().background
},
background() {
return this.userBackground || this.instanceBackground
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shout() {
return useShoutStore().joined
},
suggestionsEnabled() {
- return this.$store.state.instance.suggestionsEnabled
+ return useInstanceStore().suggestionsEnabled
},
showInstanceSpecificPanel() {
return (
- this.$store.state.instance.showInstanceSpecificPanel &&
+ useInstanceStore().showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
- this.$store.state.instance.instanceSpecificPanelContent
+ useInstanceStore().instanceSpecificPanelContent
)
},
isChats() {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit() {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
if (this.isChats) return false
if (this.isListEdit) return false
return (
this.$store.getters.mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
},
showFeaturesPanel() {
- return this.$store.state.instance.showFeaturesPanel
+ return useInstanceStore().showFeaturesPanel
},
editingAvailable() {
- return this.$store.state.instance.editingAvailable
+ return useInstanceStore().editingAvailable
},
shoutboxPosition() {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
hideShoutbox() {
return this.$store.getters.mergedConfig.hideShoutbox
},
layoutType() {
return useInterfaceStore().layoutType
},
privateMode() {
- return this.$store.state.instance.private
+ return useInstanceStore().private
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
this.$store.getters.mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
}
},
noSticky() {
return this.$store.getters.mergedConfig.disableStickyHeaders
},
showScrollbars() {
return this.$store.getters.mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
...mapGetters(['mergedConfig']),
},
methods: {
resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
},
}
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 3296755a1b..8dfc726a85 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,223 +1,224 @@
import About from 'components/about/about.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import Chat from 'components/chat/chat.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import Drafts from 'components/drafts/drafts.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import Interactions from 'components/interactions/interactions.vue'
import Lists from 'components/lists/lists.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import Notifications from 'components/notifications/notifications.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import Registration from 'components/registration/registration.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Search from 'components/search/search.vue'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue'
import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue'
import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) {
next()
} else {
- next(store.state.instance.redirectRootNoLogin || '/main/all')
+ next(useInstanceStore().redirectRootNoLogin || '/main/all')
}
}
let routes = [
{
name: 'root',
path: '/',
redirect: () => {
return (
(store.state.users.currentUser
- ? store.state.instance.redirectRootLogin
- : store.state.instance.redirectRootNoLogin) || '/main/all'
+ ? useInstanceStore().redirectRootLogin
+ : useInstanceStore().redirectRootNoLogin) || '/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline,
},
{
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: 'bubble', path: '/bubble', component: BubbleTimeline },
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: UserProfile,
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: Interactions,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'registration', path: '/registration', component: Registration },
{
name: 'password-reset',
path: '/password-reset',
component: PasswordReset,
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: Registration,
},
{
name: 'friend-requests',
path: '/friend-requests',
component: FollowRequests,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: Notifications,
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'login', path: '/login', component: AuthForm },
{
name: 'shout-panel',
path: '/shout-panel',
component: ShoutPanel,
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: OAuthCallback,
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: Search,
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: WhoToFollow,
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'about', path: '/about', component: About },
{
name: 'announcements',
path: '/announcements',
component: AnnouncementsPage,
},
{ name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: 'lists-new', path: '/lists/new', component: ListsEdit },
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: BookmarkFolders,
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: BookmarkFolderEdit,
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: BookmarkTimeline,
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: BookmarkFolderEdit,
},
]
- if (store.state.instance.pleromaChatMessagesAvailable) {
+ if (useInstanceStore().pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: Chat,
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: ChatList,
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
])
}
return routes
}
diff --git a/src/components/about/about.js b/src/components/about/about.js
index d091dfd0a1..79445584b9 100644
--- a/src/components/about/about.js
+++ b/src/components/about/about.js
@@ -1,29 +1,30 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import FeaturesPanel from '../features_panel/features_panel.vue'
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
const About = {
components: {
InstanceSpecificPanel,
FeaturesPanel,
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel,
},
computed: {
showFeaturesPanel() {
- return this.$store.state.instance.showFeaturesPanel
+ return useInstanceStore().showFeaturesPanel
},
showInstanceSpecificPanel() {
return (
- this.$store.state.instance.showInstanceSpecificPanel &&
+ useInstanceStore().showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
- this.$store.state.instance.instanceSpecificPanelContent
+ useInstanceStore().instanceSpecificPanelContent
)
},
},
}
export default About
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index ee94dc5448..e829ce0700 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,103 +1,104 @@
import { mapState } from 'vuex'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useReportsStore } from 'src/stores/reports'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Popover from '../popover/popover.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisV)
const AccountActions = {
props: ['user', 'relationship'],
data() {
return {
showingConfirmBlock: false,
showingConfirmRemoveFollower: false,
}
},
components: {
ProgressButton,
Popover,
UserListMenu,
ConfirmModal,
UserTimedFilterModal,
},
methods: {
showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true
},
hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false
},
hideConfirmBlock() {
this.showingConfirmBlock = false
},
showRepeats() {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats() {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser() {
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showingConfirmBlock = true
}
}
},
doBlockUser() {
this.$store.dispatch('blockUser', { id: this.user.id })
this.hideConfirmBlock()
},
unblockUser() {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers() {
if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers()
} else {
this.showConfirmRemoveUserFromFollowers()
}
},
doRemoveUserFromFollowers() {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
this.hideConfirmRemoveUserFromFollowers()
},
reportUser() {
useReportsStore().openUserReportingModal({ userId: this.user.id })
},
openChat() {
this.$router.push({
name: 'chat',
params: {
username: this.$store.state.users.currentUser.screen_name,
recipient_id: this.user.id,
},
})
},
},
computed: {
shouldConfirmBlock() {
return this.$store.getters.mergedConfig.modalOnBlock
},
shouldConfirmRemoveUserFromFollowers() {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
},
...mapState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (state) => useInstanceStore().blockExpiration,
pleromaChatMessagesAvailable: (state) =>
- state.instance.pleromaChatMessagesAvailable,
+ useInstanceStore().pleromaChatMessagesAvailable,
}),
},
}
export default AccountActions
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 31fceba603..d20db63ca1 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,217 +1,218 @@
import { mapGetters } from 'vuex'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
import Flash from '../flash/flash.vue'
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faImage,
faMusic,
faPencilAlt,
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight,
)
const Attachment = {
props: [
'attachment',
'compact',
'description',
'hideDescription',
'nsfw',
'size',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit',
],
data() {
return {
localDescription: this.description || this.attachment.description,
- nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
+ nsfwImage: useInstanceStore().nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img:
fileTypeService.fileType(this.attachment.mimetype) === 'image' &&
document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
showDescription: false,
}
},
components: {
Flash,
StillImage,
VideoAttachment,
},
computed: {
classNames() {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact,
},
'-type-' + this.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
]
},
usePlaceholder() {
return this.size === 'hide'
},
useContainFit() {
return this.$store.getters.mergedConfig.useContainFit
},
placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
if (this.type === 'image') return 'image'
if (this.type === 'video') return 'video'
if (this.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
- return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
+ return useInstanceStore().mediaProxyAvailable ? '' : 'no-referrer'
},
type() {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden() {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.type === 'html' && !this.attachment.oembed
},
useModal() {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.type)
},
videoTag() {
return this.useModal ? 'button' : 'span'
},
...mapGetters(['mergedConfig']),
},
watch: {
'attachment.description'(newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
this.onEdit(newVal)
},
},
methods: {
linkClicked({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal() {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce() {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit(event) {
this.edit && this.edit(this.attachment, event)
},
onRemove() {
this.remove && this.remove(this.attachment)
},
onShiftUp() {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn() {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash() {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
this.flashLoaded = event
},
toggleDescription() {
this.showDescription = !this.showDescription
},
toggleHidden(event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
},
}
export default Attachment
diff --git a/src/components/avatar_list/avatar_list.js b/src/components/avatar_list/avatar_list.js
index aad1576867..9387132077 100644
--- a/src/components/avatar_list/avatar_list.js
+++ b/src/components/avatar_list/avatar_list.js
@@ -1,25 +1,26 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import UserAvatar from '../user_avatar/user_avatar.vue'
const AvatarList = {
props: ['users'],
computed: {
slicedUsers() {
return this.users ? this.users.slice(0, 15) : []
},
},
components: {
UserAvatar,
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
},
}
export default AvatarList
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index cc4cbce444..8d2b17b7d8 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,26 +1,27 @@
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import UserPopover from '../user_popover/user_popover.vue'
const BasicUserCard = {
props: ['user'],
components: {
UserPopover,
UserAvatar,
RichContent,
UserLink,
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
},
}
export default BasicUserCard
diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js
index 7301bf0c7c..c75bc03e11 100644
--- a/src/components/block_card/block_card.js
+++ b/src/components/block_card/block_card.js
@@ -1,48 +1,49 @@
-import { mapState } from 'vuex'
+import { mapState } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = {
props: ['userId'],
computed: {
user() {
return this.$store.getters.findUser(this.userId)
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
blocked() {
return this.relationship.blocking
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at === false
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
...mapState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (store) => store.blockExpiration,
}),
},
components: {
BasicUserCard,
},
methods: {
unblockUser() {
this.$store.dispatch('unblockUser', this.user.id)
},
blockUser() {
if (this.blockExpirationSupported) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
this.$store.dispatch('blockUser', { id: this.user.id })
}
},
},
}
export default BlockCard
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index f3cc495c27..3be2e76fe4 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,115 +1,116 @@
import { mapState as mapPiniaState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapGetters, mapState } from 'vuex'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import Attachment from '../attachment/attachment.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import Popover from '../popover/popover.vue'
import StatusContent from '../status_content/status_content.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain',
],
emits: ['hover'],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
UserPopover: defineAsyncComponent(
() => import('../user_popover/user_popover.vue'),
),
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt() {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
isCurrentUser() {
return this.message.account_id === this.currentUser.id
},
message() {
return this.chatViewItem.data
},
isMessage() {
return this.chatViewItem.type === 'message'
},
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments,
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
- restrictedNicknames: (state) => state.instance.restrictedNicknames,
+ restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapGetters(['mergedConfig', 'findUser']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
})
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 8ab54b3dfb..e79fa79eb9 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,137 +1,138 @@
import SearchBar from 'components/search_bar/search_bar.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faHome,
faInfoCircle,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
)
export default {
components: {
SearchBar,
ConfirmModal,
},
data: () => ({
searchBarHidden: true,
supportsMask:
window.CSS &&
window.CSS.supports &&
(window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')),
showingConfirmLogout: false,
}),
computed: {
enableMask() {
- return this.supportsMask && this.$store.state.instance.logoMask
+ return this.supportsMask && useInstanceStore().logoMask
},
logoStyle() {
return {
visibility: this.enableMask ? 'hidden' : 'visible',
}
},
logoMaskStyle() {
return this.enableMask
? {
- 'mask-image': `url(${this.$store.state.instance.logo})`,
+ 'mask-image': `url(${useInstanceStore().logo})`,
}
: {
'background-color': this.enableMask ? '' : 'transparent',
}
},
logoBgStyle() {
return Object.assign(
{
- margin: `${this.$store.state.instance.logoMargin} 0`,
+ margin: `${useInstanceStore().logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0,
},
this.enableMask
? {}
: {
'background-color': this.enableMask ? '' : 'transparent',
},
)
},
logo() {
- return this.$store.state.instance.logo
+ return useInstanceStore().logo
},
sitename() {
- return this.$store.state.instance.name
+ return useInstanceStore().name
},
hideSitename() {
- return this.$store.state.instance.hideSitename
+ return useInstanceStore().hideSitename
},
logoLeft() {
- return this.$store.state.instance.logoLeft
+ return useInstanceStore().logoLeft
},
currentUser() {
return this.$store.state.users.currentUser
},
privateMode() {
- return this.$store.state.instance.private
+ return useInstanceStore().private
},
shouldConfirmLogout() {
return this.$store.getters.mergedConfig.modalOnLogout
},
},
methods: {
scrollToTop() {
window.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
onSearchBarToggled(hidden) {
this.searchBarHidden = hidden
},
openSettingsModal() {
useInterfaceStore().openSettingsModal('user')
},
openAdminModal() {
useInterfaceStore().openSettingsModal('admin')
},
},
}
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index e4221f7065..b267af935a 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,433 +1,434 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Checkbox from '../checkbox/checkbox.vue'
import StillImage from '../still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...keywords.concat([name]).filter((k) => k))
})
}
return res
}
const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false,
},
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(
() => import('../sticker_picker/sticker_picker.vue'),
),
Checkbox,
StillImage,
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
this.$emit('show')
},
onPopoverClosed() {
this.$emit('close')
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return this.$store.getters.mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
- if (this.$store.state.instance.stickers) {
- return this.$store.state.instance.stickers.length > 0
+ if (useInstanceStore().stickers) {
+ return useInstanceStore().stickers.length > 0
}
return 0
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = this.$store.getters.groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return this.$store.getters.standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
- return (this.$store.state.instance.stickers || []).length !== 0
+ return (useInstanceStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups
.map((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
.reduce((a, c) => a.concat(c), [])
},
languages() {
return ensureFinalFallback(
this.$store.getters.mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index e92cb975e8..3b46e6f684 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -1,34 +1,35 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const FeaturesPanel = {
computed: {
shout: function () {
- return this.$store.state.instance.shoutAvailable
+ return useInstanceStore().shoutAvailable
},
pleromaChatMessages: function () {
- return this.$store.state.instance.pleromaChatMessagesAvailable
+ return useInstanceStore().pleromaChatMessagesAvailable
},
gopher: function () {
- return this.$store.state.instance.gopherAvailable
+ return useInstanceStore().gopherAvailable
},
whoToFollow: function () {
- return this.$store.state.instance.suggestionsEnabled
+ return useInstanceStore().suggestionsEnabled
},
mediaProxy: function () {
- return this.$store.state.instance.mediaProxyAvailable
+ return useInstanceStore().mediaProxyAvailable
},
minimalScopesMode: function () {
- return this.$store.state.instance.minimalScopesMode
+ return useInstanceStore().minimalScopesMode
},
textlimit: function () {
- return this.$store.state.instance.textlimit
+ return useInstanceStore().textlimit
},
uploadlimit: function () {
return fileSizeFormatService.fileSizeFormat(
- this.$store.state.instance.uploadlimit,
+ useInstanceStore().uploadlimit,
)
},
},
}
export default FeaturesPanel
diff --git a/src/components/instance_specific_panel/instance_specific_panel.js b/src/components/instance_specific_panel/instance_specific_panel.js
index eead52f409..c9e2e280f1 100644
--- a/src/components/instance_specific_panel/instance_specific_panel.js
+++ b/src/components/instance_specific_panel/instance_specific_panel.js
@@ -1,9 +1,11 @@
+import { useInstanceStore } from 'src/stores/instance.js'
+
const InstanceSpecificPanel = {
computed: {
instanceSpecificPanelContent() {
- return this.$store.state.instance.instanceSpecificPanelContent
+ return useInstanceStore().instanceSpecificPanelContent
},
},
}
export default InstanceSpecificPanel
diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js
index 7574c4c3f0..57ddf52a96 100644
--- a/src/components/lists_menu/lists_menu_content.js
+++ b/src/components/lists_menu/lists_menu_content.js
@@ -1,25 +1,26 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import { getListEntries } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useListsStore } from 'src/stores/lists'
export const ListsMenuContent = {
props: ['showPin'],
components: {
NavigationEntry,
},
computed: {
...mapPiniaState(useListsStore, {
lists: getListEntries,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
- privateMode: (state) => state.instance.private,
- federating: (state) => state.instance.federating,
+ privateMode: (state) => useInstanceStore().private,
+ federating: (state) => useInstanceStore().federating,
}),
},
}
export default ListsMenuContent
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 7c43923c96..705de1d3d2 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,108 +1,109 @@
import { mapActions, mapState as mapPiniaState, mapStores } from 'pinia'
import { mapState } from 'vuex'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import oauthApi from '../../services/new_api/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const LoginForm = {
data: () => ({
user: {},
error: false,
}),
computed: {
isPasswordAuth() {
return this.requiredPassword
},
isTokenAuth() {
return this.requiredToken
},
...mapStores(useOAuthStore),
...mapState({
- registrationOpen: (state) => state.instance.registrationOpen,
- instance: (state) => state.instance,
+ registrationOpen: (state) => useInstanceStore().registrationOpen,
+ instance: (state) => useInstanceStore(),
loggingIn: (state) => state.users.loggingIn,
}),
...mapPiniaState(useAuthFlowStore, [
'requiredPassword',
'requiredToken',
'requiredMFA',
]),
},
methods: {
...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
submit() {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken() {
const data = {
instance: this.instance.server,
commit: this.$store.commit,
}
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi.login({ ...app, ...data })
})
},
submitPassword() {
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.oauthStore.ensureAppToken().then(() => {
const app = {
clientId: this.oauthStore.clientId,
clientSecret: this.oauthStore.clientSecret,
}
oauthApi
.getTokenWithCredentials({
...app,
instance: this.instance.server,
username: this.user.username,
password: this.user.password,
})
.then((result) => {
if (result.error) {
if (result.error === 'mfa_required') {
this.requireMFA({ settings: result })
} else if (result.identifier === 'password_reset_required') {
this.$router.push({
name: 'password-reset',
params: { passwordResetRequested: true },
})
} else {
this.error = result.error
this.focusOnPasswordInput()
}
return
}
this.login(result).then(() => {
this.$router.push({ name: 'friends' })
})
})
})
},
clearError() {
this.error = false
},
focusOnPasswordInput() {
const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
},
},
}
export default LoginForm
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index 8dc3d6c653..ee61fa369e 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -1,198 +1,198 @@
-/* eslint-env browser */
-
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch, faUpload } from '@fortawesome/free-solid-svg-icons'
library.add(faUpload, faCircleNotch)
const mediaUpload = {
data() {
return {
uploadCount: 0,
uploadReady: true,
}
},
computed: {
uploading() {
return this.uploadCount > 0
},
},
methods: {
onClick() {
if (this.uploadReady) {
this.$refs.input.click()
}
},
async resizeImage(file) {
// Skip if not an image or if it's a GIF
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file
}
// Skip if image compression is disabled
if (!this.$store.getters.mergedConfig.imageCompression) {
return file
}
// For PNGs, check if animated
if (file.type === 'image/png') {
const isAnimated = await this.isAnimatedPng(file)
if (isAnimated) {
return file
}
}
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
// Calculate new dimensions
let width = img.width
let height = img.height
const maxSize = 2048
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width)
width = maxSize
} else {
width = Math.round((width * maxSize) / height)
height = maxSize
}
}
// Create canvas and resize
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, width, height)
// Check WebP support by trying to create a WebP canvas
const testCanvas = document.createElement('canvas')
const supportsWebP = testCanvas
.toDataURL('image/webp')
.startsWith('data:image/webp')
// Convert to WebP if supported and alwaysUseJpeg is false, otherwise JPEG
const type =
!this.$store.getters.mergedConfig.alwaysUseJpeg && supportsWebP
? 'image/webp'
: 'image/jpeg'
const extension = type === 'image/webp' ? '.webp' : '.jpg'
// Remove the original extension and add new one
const newFileName = file.name.replace(/\.[^/.]+$/, '') + extension
canvas.toBlob(
(blob) => {
resolve(
new File([blob], newFileName, {
type,
lastModified: Date.now(),
}),
)
},
type,
0.85,
)
}
img.src = URL.createObjectURL(file)
})
},
async isAnimatedPng(file) {
const buffer = await file.arrayBuffer()
const view = new Uint8Array(buffer)
// Look for animated PNG chunks (acTL)
for (let i = 0; i < view.length - 8; i++) {
if (
view[i] === 0x61 && // a
view[i + 1] === 0x63 && // c
view[i + 2] === 0x54 && // T
view[i + 3] === 0x4c
) {
// L
return true
}
}
return false
},
async uploadFile(file) {
const self = this
const store = this.$store
- if (file.size > store.state.instance.uploadlimit) {
+ if (file.size > useInstanceStore().uploadlimit) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
- store.state.instance.uploadlimit,
+ useInstanceStore().uploadlimit,
)
self.$emit('upload-failed', 'file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
})
return
}
// Resize image if needed
const processedFile = await this.resizeImage(file)
const formData = new FormData()
formData.append('file', processedFile)
self.$emit('uploading')
self.uploadCount++
statusPosterService.uploadMedia({ store, formData }).then(
(fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
},
(error) => {
console.error('Error uploading file', error)
self.$emit('upload-failed', 'default')
self.decreaseUploadCount()
},
)
},
decreaseUploadCount() {
this.uploadCount--
if (this.uploadCount === 0) {
this.$emit('all-uploaded')
}
},
clearFile() {
this.uploadReady = false
this.$nextTick(() => {
this.uploadReady = true
})
},
multiUpload(files) {
for (const file of files) {
this.uploadFile(file)
}
},
change({ target }) {
this.multiUpload(target.files)
},
},
props: {
dropFiles: Object,
disabled: Boolean,
normalButton: Boolean,
acceptTypes: {
type: String,
default: '*/*',
},
},
watch: {
dropFiles: function (fileInfos) {
if (!this.uploading) {
this.multiUpload(fileInfos)
}
},
},
}
export default mediaUpload
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 873bdc40db..eeb828d8db 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -1,154 +1,155 @@
import { mapState } from 'pinia'
import { mapGetters } from 'vuex'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import GestureService from '../../services/gesture_service/gesture_service'
import {
countExtraNotifications,
unseenNotificationsFromStore,
} from '../../services/notification_utils/notification_utils'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Notifications from '../notifications/notifications.vue'
import SideDrawer from '../side_drawer/side_drawer.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faBars,
faBell,
faCheckDouble,
faMinus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faBell, faBars, faArrowUp, faMinus, faCheckDouble)
const MobileNav = {
components: {
SideDrawer,
Notifications,
NavigationPins,
ConfirmModal,
},
data: () => ({
notificationsCloseGesture: undefined,
notificationsOpen: false,
notificationsAtTop: true,
showingConfirmLogout: false,
}),
created() {
this.notificationsCloseGesture = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
() => this.closeMobileNotifications(true),
50,
)
},
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
unseenNotifications() {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount() {
return (
this.unseenNotifications.length + countExtraNotifications(this.$store)
)
},
unseenCount() {
return this.unseenNotifications.length
},
unseenCountBadgeText() {
return `${this.unseenCount ? this.unseenCount : ''}`
},
hideSitename() {
- return this.$store.state.instance.hideSitename
+ return useInstanceStore().hideSitename
},
sitename() {
- return this.$store.state.instance.name
+ return useInstanceStore().name
},
isChat() {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useServerSideStorageStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
}),
shouldConfirmLogout() {
return this.$store.getters.mergedConfig.modalOnLogout
},
closingDrawerMarksAsSeen() {
return this.$store.getters.mergedConfig.closingDrawerMarksAsSeen
},
...mapGetters(['unreadChatCount']),
},
methods: {
toggleMobileSidebar() {
this.$refs.sideDrawer.toggleDrawer()
},
openMobileNotifications() {
this.notificationsOpen = true
},
closeMobileNotifications(markRead) {
if (this.notificationsOpen) {
// make sure to mark notifs seen only when the notifs were open and not
// from close-calls.
this.notificationsOpen = false
if (markRead && this.closingDrawerMarksAsSeen) {
this.markNotificationsAsSeen()
}
}
},
notificationsTouchStart(e) {
GestureService.beginSwipe(e, this.notificationsCloseGesture)
},
notificationsTouchMove(e) {
GestureService.updateSwipe(e, this.notificationsCloseGesture)
},
scrollToTop() {
window.scrollTo(0, 0)
},
scrollMobileNotificationsToTop() {
this.$refs.mobileNotifications.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
markNotificationsAsSeen() {
this.$store.dispatch('markNotificationsAsSeen')
},
onScroll({ target: { scrollTop, clientHeight, scrollHeight } }) {
this.notificationsAtTop = scrollTop > 0
if (scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications()
}
},
},
watch: {
$route() {
// handles closing notificaitons when you press any router-link on the
// notifications.
this.closeMobileNotifications()
},
},
}
export default MobileNav
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index fdb8a74b64..f4c5e04709 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -1,148 +1,149 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown)
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip'
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
const SANDBOX = 'mrf_tag:sandbox'
const QUARANTINE = 'mrf_tag:quarantine'
const ModerationTools = {
props: ['user'],
data() {
return {
tags: {
FORCE_NSFW,
STRIP_MEDIA,
FORCE_UNLISTED,
DISABLE_REMOTE_SUBSCRIPTION,
DISABLE_ANY_SUBSCRIPTION,
SANDBOX,
QUARANTINE,
},
showDeleteUserDialog: false,
toggled: false,
}
},
components: {
DialogModal,
Popover,
},
computed: {
tagsSet() {
return new Set(this.user.tags)
},
canGrantRole() {
return (
this.user.is_local &&
!this.user.deactivated &&
this.$store.state.users.currentUser.role === 'admin'
)
},
canChangeActivationState() {
return this.privileged('users_manage_activation_state')
},
canDeleteAccount() {
return this.privileged('users_delete')
},
canUseTagPolicy() {
return (
- this.$store.state.instance.tagPolicyAvailable &&
+ useInstanceStore().tagPolicyAvailable &&
this.privileged('users_manage_tags')
)
},
},
methods: {
hasTag(tagName) {
return this.tagsSet.has(tagName)
},
privileged(privilege) {
return this.$store.state.users.currentUser.privileges.includes(privilege)
},
toggleTag(tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
store.state.api.backendInteractor
.untagUser({ user: this.user, tag })
.then((response) => {
if (!response.ok) {
return
}
store.commit('untagUser', { user: this.user, tag })
})
} else {
store.state.api.backendInteractor
.tagUser({ user: this.user, tag })
.then((response) => {
if (!response.ok) {
return
}
store.commit('tagUser', { user: this.user, tag })
})
}
},
toggleRight(right) {
const store = this.$store
if (this.user.rights[right]) {
store.state.api.backendInteractor
.deleteRight({ user: this.user, right })
.then((response) => {
if (!response.ok) {
return
}
store.commit('updateRight', {
user: this.user,
right,
value: false,
})
})
} else {
store.state.api.backendInteractor
.addRight({ user: this.user, right })
.then((response) => {
if (!response.ok) {
return
}
store.commit('updateRight', { user: this.user, right, value: true })
})
}
},
toggleActivationStatus() {
this.$store.dispatch('toggleActivationStatus', { user: this.user })
},
deleteUserDialog(show) {
this.showDeleteUserDialog = show
},
deleteUser() {
const store = this.$store
const user = this.user
const { id, name } = user
store.state.api.backendInteractor.deleteUser({ user }).then(() => {
this.$store.dispatch(
'markStatusesAsDeleted',
(status) => user.id === status.user.id,
)
const isProfile =
this.$route.name === 'external-user-profile' ||
this.$route.name === 'user-profile'
const isTargetUser =
this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
})
},
setToggled(value) {
this.toggled = value
},
},
}
export default ModerationTools
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index d80cd0a5de..874089a64f 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,164 +1,166 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import { filterNavigation } from 'src/components/navigation/filter.js'
import { ROOT_ITEMS, TIMELINES } from 'src/components/navigation/navigation.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBookmark,
faBullhorn,
faChevronDown,
faChevronUp,
faCity,
faComments,
faEnvelope,
faFilePen,
faGlobe,
faInfoCircle,
faList,
faStream,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faChevronDown,
faChevronUp,
faComments,
faBell,
faInfoCircle,
faStream,
faList,
faBullhorn,
faFilePen,
)
const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
components: {
BookmarkFoldersMenuContent,
ListsMenuContent,
NavigationEntry,
NavigationPins,
Checkbox,
},
data() {
return {
editMode: false,
showTimelines: false,
showLists: false,
showBookmarkFolders: false,
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({
...v,
name: k,
})),
rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k })),
}
},
methods: {
toggleTimelines() {
this.showTimelines = !this.showTimelines
},
toggleLists() {
this.showLists = !this.showLists
},
toggleBookmarkFolders() {
this.showBookmarkFolders = !this.showBookmarkFolders
},
toggleEditMode() {
this.editMode = !this.editMode
},
toggleCollapse() {
useServerSideStorageStore().setPreference({
path: 'simple.collapseNav',
value: !this.collapsed,
})
useServerSideStorageStore().pushServerSideStorage()
},
isPinned(item) {
return this.pinnedItems.has(item)
},
togglePin(item) {
if (this.isPinned(item)) {
useServerSideStorageStore().removeCollectionPreference({
path: 'collections.pinnedNavItems',
value: item,
})
} else {
useServerSideStorageStore().addCollectionPreference({
path: 'collections.pinnedNavItems',
value: item,
})
}
useServerSideStorageStore().pushServerSideStorage()
},
},
computed: {
...mapPiniaState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: (store) => store.supportsAnnouncements,
}),
...mapPiniaState(useServerSideStorageStore, {
collapsed: (store) => store.prefsStorage.simple.collapseNav,
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),
}),
...mapState({
currentUser: (state) => state.users.currentUser,
followRequestCount: (state) => state.api.followRequests.length,
- privateMode: (state) => state.instance.private,
- federating: (state) => state.instance.federating,
+ privateMode: (state) => useInstanceStore().private,
+ federating: (state) => useInstanceStore().federating,
pleromaChatMessagesAvailable: (state) =>
- state.instance.pleromaChatMessagesAvailable,
+ useInstanceStore().pleromaChatMessagesAvailable,
bookmarkFolders: (state) =>
- state.instance.pleromaBookmarkFoldersAvailable,
- bubbleTimeline: (state) => state.instance.localBubbleInstances.length > 0,
+ useInstanceStore().pleromaBookmarkFoldersAvailable,
+ bubbleTimeline: (state) =>
+ useInstanceStore().localBubbleInstances.length > 0,
}),
timelinesItems() {
return filterNavigation(
Object.entries({ ...TIMELINES })
// do not show in timeliens list since it's in a better place now
.filter(([key]) => key !== 'bookmarks')
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders,
},
)
},
rootItems() {
return filterNavigation(
Object.entries({ ...ROOT_ITEMS }).map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarkFolders,
},
)
},
...mapGetters(['unreadChatCount']),
},
}
export default NavPanel
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
index e14edce144..810c8a04ad 100644
--- a/src/components/navigation/navigation_pins.js
+++ b/src/components/navigation/navigation_pins.js
@@ -1,127 +1,129 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import {
filterNavigation,
getBookmarkFolderEntries,
getListEntries,
} from 'src/components/navigation/filter.js'
import {
ROOT_ITEMS,
routeTo,
TIMELINES,
} from 'src/components/navigation/navigation.js'
import StillImage from 'src/components/still-image/still-image.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useListsStore } from 'src/stores/lists'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBookmark,
faCity,
faComments,
faEnvelope,
faGlobe,
faInfoCircle,
faList,
faStream,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faCity,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList,
)
const NavPanel = {
props: ['limit'],
methods: {
getRouteTo(item) {
return routeTo(item, this.currentUser)
},
},
components: {
StillImage,
},
computed: {
getters() {
return this.$store.getters
},
...mapPiniaState(useListsStore, {
lists: getListEntries,
}),
...mapPiniaState(useAnnouncementsStore, {
supportsAnnouncements: (store) => store.supportsAnnouncements,
}),
...mapPiniaState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries,
}),
...mapPiniaState(useServerSideStorageStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems),
}),
...mapState({
currentUser: (state) => state.users.currentUser,
followRequestCount: (state) => state.api.followRequests.length,
- privateMode: (state) => state.instance.private,
- federating: (state) => state.instance.federating,
+ privateMode: (state) => useInstanceStore().private,
+ federating: (state) => useInstanceStore().federating,
pleromaChatMessagesAvailable: (state) =>
- state.instance.pleromaChatMessagesAvailable,
- bubbleTimeline: (state) => state.instance.localBubbleInstances.length > 0,
+ useInstanceStore().pleromaChatMessagesAvailable,
+ bubbleTimeline: (state) =>
+ useInstanceStore().localBubbleInstances.length > 0,
}),
pinnedList() {
if (!this.currentUser) {
return filterNavigation(
[
{ ...TIMELINES.public, name: 'public' },
{ ...TIMELINES.twkn, name: 'twkn' },
{ ...ROOT_ITEMS.about, name: 'about' },
],
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks,
},
)
}
return filterNavigation(
[
...Object.entries({ ...TIMELINES })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
...this.lists.filter((k) => this.pinnedItems.has(k.name)),
...this.bookmarks.filter((k) => this.pinnedItems.has(k.name)),
...Object.entries({ ...ROOT_ITEMS })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
],
{
hasChats: this.pleromaChatMessagesAvailable,
hasAnnouncements: this.supportsAnnouncements,
supportsBubbleTimeline: this.bubbleTimeline,
supportsBookmarkFolders: this.bookmarks,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
},
).slice(0, this.limit)
},
},
}
export default NavPanel
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 275827c43e..dc6aee6f76 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,223 +1,224 @@
import { mapState } from 'vuex'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Report from '../report/report.vue'
import Status from '../status/status.vue'
import StatusContent from '../status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import UserLink from '../user_link/user_link.vue'
import UserPopover from '../user_popover/user_popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCompressAlt,
faExpandAlt,
faEyeSlash,
faRetweet,
faStar,
faSuitcaseRolling,
faTimes,
faUser,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCheck,
faTimes,
faStar,
faRetweet,
faUserPlus,
faUser,
faEyeSlash,
faSuitcaseRolling,
faExpandAlt,
faCompressAlt,
)
const Notification = {
data() {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
props: ['notification'],
emits: ['interacted'],
components: {
StatusContent,
UserAvatar,
UserCard,
Timeago,
Status,
Report,
RichContent,
UserPopover,
UserLink,
ConfirmModal,
},
mounted() {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted() {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded() {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect() {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within =
this.$refs.root.contains(anchorNode) ||
this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick(e) {
if (
!this.selecting &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
getUser(notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
},
interacted() {
this.$emit('interacted')
},
toggleMute() {
this.unmuted = !this.unmuted
},
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
this.$emit('interacted')
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', {
id: this.notification.id,
})
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: (notification) => {
notification.type = 'follow'
},
})
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
this.$emit('interacted')
this.$store.state.api.backendInteractor
.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', {
id: this.notification.id,
})
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
userClass() {
return highlightClass(this.notification.from_profile)
},
userStyle() {
const highlight = this.$store.getters.mergedConfig.highlight
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
expandable() {
return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has(
this.notification.type,
)
},
user() {
return this.$store.getters.findUser(this.notification.from_profile.id)
},
userProfileLink() {
return this.generateUserProfileLink(this.user)
},
targetUser() {
return this.$store.getters.findUser(this.notification.target.id)
},
targetUserProfileLink() {
return this.generateUserProfileLink(this.targetUser)
},
needMute() {
return this.$store.getters.relationship(this.user.id).muting
},
isStatusNotification() {
return isStatusNotification(this.notification.type)
},
mergedConfig() {
return this.$store.getters.mergedConfig
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default Notification
diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js
index 82c3db047c..3a1195b149 100644
--- a/src/components/oauth_callback/oauth_callback.js
+++ b/src/components/oauth_callback/oauth_callback.js
@@ -1,27 +1,28 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import oauth from '../../services/new_api/oauth.js'
const oac = {
props: ['code'],
mounted() {
if (this.code) {
const oauthStore = useOAuthStore()
const { clientId, clientSecret } = oauthStore
oauth
.getToken({
clientId,
clientSecret,
- instance: this.$store.state.instance.server,
+ instance: useInstanceStore().server,
code: this.code,
})
.then((result) => {
oauthStore.setToken(result.access_token)
this.$store.dispatch('loginUser', result.access_token)
this.$router.push({ name: 'friends' })
})
}
},
}
export default oac
diff --git a/src/components/password_reset/password_reset.js b/src/components/password_reset/password_reset.js
index 4fd53f8dd7..173f19a35a 100644
--- a/src/components/password_reset/password_reset.js
+++ b/src/components/password_reset/password_reset.js
@@ -1,71 +1,73 @@
+import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
+import { useInstanceStore } from 'src/stores/instance.js'
import passwordResetApi from '../../services/new_api/password_reset.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const passwordReset = {
data: () => ({
user: {
email: '',
},
isPending: false,
success: false,
throttled: false,
error: null,
}),
computed: {
...mapState({
signedIn: (state) => !!state.users.currentUser,
- instance: (state) => state.instance,
}),
+ ...mapPiniaState(useInstanceStore, ['server']),
mailerEnabled() {
return this.instance.mailerEnabled
},
},
created() {
if (this.signedIn) {
this.$router.push({ name: 'root' })
}
},
props: {
passwordResetRequested: {
default: false,
type: Boolean,
},
},
methods: {
dismissError() {
this.error = null
},
submit() {
this.isPending = true
const email = this.user.email
- const instance = this.instance.server
+ const server = this.server
- passwordResetApi({ instance, email })
+ passwordResetApi({ server, email })
.then(({ status }) => {
this.isPending = false
this.user.email = ''
if (status === 204) {
this.success = true
this.error = null
} else if (status === 429) {
this.throttled = true
this.error = this.$t('password_reset.too_many_requests')
}
})
.catch(() => {
this.isPending = false
this.user.email = ''
this.error = this.$t('general.generic_error')
})
},
},
}
export default passwordReset
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index 92b471314a..6cd3de2a34 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -1,138 +1,139 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { pollFallback } from 'src/services/poll/poll.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import Select from '../select/select.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faPlus)
export default {
components: {
Select,
},
name: 'PollForm',
props: {
visible: {},
params: {
type: Object,
required: true,
},
},
computed: {
pollType: {
get() {
return pollFallback(this.params, 'pollType')
},
set(newVal) {
this.params.pollType = newVal
},
},
options() {
const hasOptions = !!this.params.options
if (!hasOptions) {
this.params.options = pollFallback(this.params, 'options')
}
return this.params.options
},
expiryAmount: {
get() {
return pollFallback(this.params, 'expiryAmount')
},
set(newVal) {
this.params.expiryAmount = newVal
},
},
expiryUnit: {
get() {
return pollFallback(this.params, 'expiryUnit')
},
set(newVal) {
this.params.expiryUnit = newVal
},
},
pollLimits() {
- return this.$store.state.instance.pollLimits
+ return useInstanceStore().pollLimits
},
maxOptions() {
return this.pollLimits.max_options
},
maxLength() {
return this.pollLimits.max_option_chars
},
expiryUnits() {
const allUnits = ['minutes', 'hours', 'days']
const expiry = this.convertExpiryFromUnit
return allUnits.filter(
(unit) => this.pollLimits.max_expiration >= expiry(unit, 1),
)
},
minExpirationInCurrentUnit() {
return Math.ceil(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.min_expiration,
),
)
},
maxExpirationInCurrentUnit() {
return Math.floor(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.max_expiration,
),
)
},
},
methods: {
clear() {
this.pollType = 'single'
this.options = ['', '']
this.expiryAmount = 10
this.expiryUnit = 'minutes'
},
nextOption(index) {
const element = this.$el.querySelector(`#poll-${index + 1}`)
if (element) {
element.focus()
} else {
// Try adding an option and try focusing on it
const addedOption = this.addOption()
if (addedOption) {
this.$nextTick(function () {
this.nextOption(index)
})
}
}
},
addOption() {
if (this.options.length < this.maxOptions) {
this.options.push('')
return true
}
return false
},
deleteOption(index) {
if (this.options.length > 2) {
this.options.splice(index, 1)
}
},
convertExpiryToUnit(unit, amount) {
// Note: we want seconds and not milliseconds
return DateUtils.secondsToUnit(unit, amount)
},
convertExpiryFromUnit(unit, amount) {
return DateUtils.unitToSeconds(unit, amount)
},
expiryAmountChange() {
this.expiryAmount = Math.max(
this.minExpirationInCurrentUnit,
this.expiryAmount,
)
this.expiryAmount = Math.min(
this.maxExpirationInCurrentUnit,
this.expiryAmount,
)
},
},
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 396aa94f0e..6fd4c9fabd 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,929 +1,930 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import Popover from 'src/components/popover/popover.vue'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import Attachment from '../attachment/attachment.vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js'
import MediaUpload from '../media_upload/media_upload.vue'
import PollForm from '../poll/poll_form.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (profileMention) {
return ['mention', profileMention]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = {
props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'disableDraft',
'hideDraft',
'closeable',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
'profileMention',
'draftId',
],
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'can-close',
'update',
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
data() {
const preset = this.$route.query.message
let statusText = preset || ''
const { scopeCopy } = this.$store.getters.mergedConfig
const [statusType, refId] = typeAndRefId({
replyTo: this.replyTo,
profileMention: this.profileMention && this.repliedUser?.id,
statusId: this.statusId,
})
// If we are starting a new post, do not associate it with old drafts
let statusParams =
!this.disableDraft && (this.draftId || statusType !== 'new')
? this.getDraft(statusType, refId)
: null
if (!statusParams) {
if (statusType === 'reply' || statusType === 'mention') {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString(
{ user: this.repliedUser, attentions: this.attentions },
currentUser,
)
}
const scope =
(this.copyMessageScope && scopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } =
this.$store.getters.mergedConfig
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
hasPoll: false,
mediaDescriptions: {},
visibility: scope,
contentType,
quoting: false,
}
if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
hasPoll: false,
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType,
}
}
}
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
newStatus: statusParams,
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
computed: {
users() {
return this.$store.state.users.users
},
userDefaultScope() {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
- ...this.$store.state.instance.customEmoji,
+ ...useInstanceStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
- ...this.$store.state.instance.customEmoji,
+ ...useInstanceStore().customEmoji,
],
})
},
emoji() {
return this.$store.getters.standardEmojiList || []
},
customEmoji() {
- return this.$store.state.instance.customEmoji || []
+ return useInstanceStore().customEmoji || []
},
statusLength() {
return this.newStatus.status.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
- return this.$store.state.instance.textlimit
+ return useInstanceStore().textlimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
minimalScopesMode() {
- return this.$store.state.instance.minimalScopesMode
+ return useInstanceStore().minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats() {
- return this.$store.state.instance.postFormats || []
+ return useInstanceStore().postFormats || []
},
safeDMEnabled() {
- return this.$store.state.instance.safeDM
+ return useInstanceStore().safeDM
},
pollsAvailable() {
return (
- this.$store.state.instance.pollsAvailable &&
- this.$store.state.instance.pollLimits.max_options >= 2 &&
+ useInstanceStore().pollsAvailable &&
+ useInstanceStore().pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideScopeNotice() {
return (
this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
)
},
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotable() {
- if (!this.$store.state.instance.quotingAvailable) {
+ if (!useInstanceStore().quotingAvailable) {
return false
}
if (!this.replyTo) {
return false
}
const repliedStatus =
this.$store.state.statuses.allStatusesObject[this.replyTo]
if (!repliedStatus) {
return false
}
if (
repliedStatus.visibility === 'public' ||
repliedStatus.visibility === 'unlisted' ||
repliedStatus.visibility === 'local'
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
}
return false
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible() {
return this.newStatus.hasPoll
},
shouldAutoSaveDraft() {
return this.$store.getters.mergedConfig.autoSaveDraft
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
)
)
},
...mapGetters(['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
hasPoll: false,
mediaDescriptions: {},
quoting: false,
}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event, newStatus) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.emptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = this.newStatus.hasPoll
? pollFormToMasto(this.newStatus.poll)
: {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
[replyOrQuoteAttr]: this.replyTo,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
} 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
const replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
statusPoster
.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
[replyOrQuoteAttr]: 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)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
type(fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll(poll) {
this.newStatus.poll = poll
},
clearPollForm() {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
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)))
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
propsToNative(props) {
return propsToNative(props)
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('can-close')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('can-close')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
},
}
export default PostStatusForm
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index 8d1081acb2..14bb2de9f8 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,158 +1,160 @@
import useVuelidate from '@vuelidate/core'
import { required, requiredIf, sameAs } from '@vuelidate/validators'
+import { mapState as mapPiniaState } from 'pinia'
import { mapActions, mapState } from 'vuex'
import { DAY } from 'src/services/date_utils/date_utils.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import localeService from '../../services/locale/locale.service.js'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
const registration = {
setup() {
return { v$: useVuelidate() }
},
data: () => ({
user: {
email: '',
fullname: '',
username: '',
password: '',
confirm: '',
birthday: '',
reason: '',
language: [''],
},
captcha: {},
}),
components: {
InterfaceLanguageSwitcher,
TermsOfServicePanel,
},
validations() {
return {
user: {
email: { required: requiredIf(() => this.accountActivationRequired) },
username: { required },
fullname: { required },
password: { required },
confirm: {
required,
sameAs: sameAs(this.user.password),
},
birthday: {
required: requiredIf(() => this.birthdayRequired),
maxValue: (value) => {
return (
!this.birthdayRequired ||
new Date(value).getTime() <= this.birthdayMin.getTime()
)
},
},
reason: { required: requiredIf(() => this.accountApprovalRequired) },
language: {},
},
}
},
created() {
if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push({ name: 'root' })
}
this.setCaptcha()
},
computed: {
token() {
return this.$route.params.token
},
bioPlaceholder() {
return this.replaceNewlines(this.$t('registration.bio_placeholder'))
},
reasonPlaceholder() {
return this.replaceNewlines(this.$t('registration.reason_placeholder'))
},
birthdayMin() {
const minAge = this.birthdayMinAge
const today = new Date()
today.setUTCMilliseconds(0)
today.setUTCSeconds(0)
today.setUTCMinutes(0)
today.setUTCHours(0)
const minDate = new Date()
minDate.setTime(today.getTime() - minAge * DAY)
return minDate
},
birthdayMinAttr() {
return this.birthdayMin.toJSON().replace(/T.+$/, '')
},
birthdayMinFormatted() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.birthdayMin)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
+ ...mapPiniaState(useInstanceStore, {
+ registrationOpen: (store) => store.registrationOpen,
+ embeddedToS: (store) => store.embeddedToS,
+ termsOfService: (store) => store.tos,
+ accountActivationRequired: (store) => store.accountActivationRequired,
+ accountApprovalRequired: (store) => store.accountApprovalRequired,
+ birthdayRequired: (store) => store.birthdayRequired,
+ birthdayMinAge: (store) => store.birthdayMinAge,
+ }),
...mapState({
- registrationOpen: (state) => state.instance.registrationOpen,
signedIn: (state) => !!state.users.currentUser,
isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors,
signUpNotice: (state) => state.users.signUpNotice,
hasSignUpNotice: (state) => !!state.users.signUpNotice.message,
- termsOfService: (state) => state.instance.tos,
- embeddedToS: (state) => state.instance.embeddedToS,
- accountActivationRequired: (state) =>
- state.instance.accountActivationRequired,
- accountApprovalRequired: (state) =>
- state.instance.accountApprovalRequired,
- birthdayRequired: (state) => state.instance.birthdayRequired,
- birthdayMinAge: (state) => state.instance.birthdayMinAge,
}),
},
methods: {
...mapActions(['signUp', 'getCaptcha']),
async submit() {
this.user.nickname = this.user.username
this.user.token = this.token
this.user.captcha_solution = this.captcha.solution
this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
if (this.user.language) {
this.user.language = localeService.internalToBackendLocaleMulti(
this.user.language.filter((k) => k),
)
}
this.v$.$touch()
if (!this.v$.$invalid) {
try {
const status = await this.signUp(this.user)
if (status === 'ok') {
this.$router.push({ name: 'friends' })
}
// If status is not 'ok' (i.e. it needs further actions to be done
// before you can login), display sign up notice, do not switch anywhere
} catch (error) {
console.warn('Registration failed: ', error)
this.setCaptcha()
}
}
},
setCaptcha() {
this.getCaptcha().then((cpt) => {
this.captcha = cpt
})
},
replaceNewlines(str) {
return str.replace(/\s*\n\s*/g, ' \n')
},
},
}
export default registration
diff --git a/src/components/report/report.js b/src/components/report/report.js
index a4df8eb6c5..d6b4a14b57 100644
--- a/src/components/report/report.js
+++ b/src/components/report/report.js
@@ -1,43 +1,44 @@
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useReportsStore } from 'src/stores/reports'
import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue'
const Report = {
props: ['reportId'],
components: {
Select,
StatusContent,
Timeago,
RichContent,
},
computed: {
report() {
return useReportsStore().reports[this.reportId] || {}
},
state: {
get: function () {
return this.report.state
},
set: function (val) {
this.setReportState(val)
},
},
},
methods: {
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
setReportState(state) {
return useReportsStore().setReportState({ id: this.report.id, state })
},
},
}
export default Report
diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js
index e53c2f2191..a066776b89 100644
--- a/src/components/settings_modal/admin_tabs/emoji_tab.js
+++ b/src/components/settings_modal/admin_tabs/emoji_tab.js
@@ -1,349 +1,350 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import Popover from 'components/popover/popover.vue'
import Select from 'components/select/select.vue'
import StillImage from 'components/still-image/still-image.vue'
import { assign, clone } from 'lodash'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faDownload,
faFolderOpen,
faServer,
} from '@fortawesome/free-solid-svg-icons'
library.add(faArrowsRotate, faFolderOpen, faDownload, faServer)
const EmojiTab = {
components: {
TabSwitcher,
StringSetting,
Checkbox,
StillImage,
Select,
Popover,
ConfirmModal,
ModifiedIndicator,
EmojiEditingPopover,
},
data() {
return {
knownLocalPacks: {},
knownRemotePacks: {},
editedMetadata: {},
packName: '',
newPackName: '',
deleteModalVisible: false,
remotePackInstance: '',
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null,
}
},
provide() {
return { emojiAddr: this.emojiAddr }
},
computed: {
...SharedComputedObject(),
pack() {
return this.packName !== '' ? this.knownPacks[this.packName] : undefined
},
packMeta() {
if (this.packName === '') return {}
if (this.editedMetadata[this.packName] === undefined) {
this.editedMetadata[this.packName] = clone(this.pack.pack)
}
return this.editedMetadata[this.packName]
},
knownPacks() {
// Copy the object itself but not the children, so they are still passed by reference and modified
const result = clone(this.knownLocalPacks)
for (const instName in this.knownRemotePacks) {
for (const instPack in this.knownRemotePacks[instName]) {
result[`${instPack}@${instName}`] =
this.knownRemotePacks[instName][instPack]
}
}
return result
},
downloadWillReplaceLocal() {
return (
(this.remotePackDownloadAs.trim() === '' &&
this.pack.remote &&
this.pack.remote.baseName in this.knownLocalPacks) ||
this.remotePackDownloadAs in this.knownLocalPacks
)
},
},
methods: {
reloadEmoji() {
this.$store.state.api.backendInteractor.reloadEmoji()
},
importFromFS() {
this.$store.state.api.backendInteractor.importEmojiFromFS()
},
emojiAddr(name) {
if (this.pack.remote !== undefined) {
// Remote pack
return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}`
} else {
- return `${this.$store.state.instance.server}/emoji/${encodeURIComponent(this.packName)}/${name}`
+ return `${useInstanceStore().server}/emoji/${encodeURIComponent(this.packName)}/${name}`
}
},
createEmojiPack() {
this.$store.state.api.backendInteractor
.createEmojiPack({ name: this.newPackName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
})
},
deleteEmojiPack() {
this.$store.state.api.backendInteractor
.deleteEmojiPack({ name: this.packName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
delete this.editedMetadata[this.packName]
this.deleteModalVisible = false
this.packName = ''
})
},
metaEdited(prop) {
if (!this.pack) return
const def = this.pack.pack[prop] || ''
const edited = this.packMeta[prop] || ''
return edited !== def
},
savePackMetadata() {
this.$store.state.api.backendInteractor
.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
},
updatePackFiles(newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
loadPacksPaginated(listFunction) {
const pageSize = 25
const allPacks = {}
return listFunction({
instance: this.remotePackInstance,
page: 1,
pageSize: 0,
})
.then((data) => data.json())
.then((data) => {
if (data.error !== undefined) {
return Promise.reject(data.error)
}
let resultingPromise = Promise.resolve({})
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
resultingPromise = resultingPromise
.then(() =>
listFunction({
instance: this.remotePackInstance,
page: i,
pageSize,
}),
)
.then((data) => data.json())
.then((pageData) => {
if (pageData.error !== undefined) {
return Promise.reject(pageData.error)
}
assign(allPacks, pageData.packs)
})
}
return resultingPromise
})
.then(() => allPacks)
.catch((data) => {
this.displayError(data)
})
},
refreshPackList() {
this.loadPacksPaginated(
this.$store.state.api.backendInteractor.listEmojiPacks,
).then((allPacks) => {
this.knownLocalPacks = allPacks
for (const name of Object.keys(this.knownLocalPacks)) {
this.sortPackFiles(name)
}
})
},
listRemotePacks() {
this.loadPacksPaginated(
this.$store.state.api.backendInteractor.listRemoteEmojiPacks,
)
.then((allPacks) => {
let inst = this.remotePackInstance
if (!inst.startsWith('http')) {
inst = 'https://' + inst
}
const instUrl = new URL(inst)
inst = instUrl.host
for (const packName in allPacks) {
allPacks[packName].remote = {
baseName: packName,
instance: instUrl.origin,
}
}
this.knownRemotePacks[inst] = allPacks
for (const pack in this.knownRemotePacks[inst]) {
this.sortPackFiles(`${pack}@${inst}`)
}
})
.catch((data) => {
this.displayError(data)
})
},
downloadRemotePack() {
if (this.remotePackDownloadAs.trim() === '') {
this.remotePackDownloadAs = this.pack.remote.baseName
}
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPack({
instance: this.pack.remote.instance,
packName: this.pack.remote.baseName,
as: this.remotePackDownloadAs,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.remotePackDownloadAs
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack() {
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPackZIP({
url: this.remotePackURL,
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack() {
this.$store.state.api.backendInteractor
.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0],
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error',
})
},
sortPackFiles(nameOfPack) {
// Sort by key
const sorted = Object.keys(this.knownPacks[nameOfPack].files)
.sort()
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
this.knownPacks[nameOfPack].files = sorted
},
},
mounted() {
this.refreshPackList()
},
}
export default EmojiTab
diff --git a/src/components/settings_modal/helpers/attachment_setting.js b/src/components/settings_modal/helpers/attachment_setting.js
index 61e00a353a..694c168e0a 100644
--- a/src/components/settings_modal/helpers/attachment_setting.js
+++ b/src/components/settings_modal/helpers/attachment_setting.js
@@ -1,46 +1,45 @@
import Attachment from 'src/components/attachment/attachment.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import { fileTypeExt } from 'src/services/file_type/file_type.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import Setting from './setting.js'
export default {
...Setting,
props: {
...Setting.props,
compact: Boolean,
acceptTypes: {
type: String,
required: false,
default: 'image/*',
},
},
components: {
...Setting.components,
MediaUpload,
Attachment,
},
computed: {
...Setting.computed,
attachment() {
const path = this.realDraftMode ? this.draft : this.state
// The "server" part is primarily for local dev, but could be useful for alt-domain or multiuser usage.
- const url = path.includes('://')
- ? path
- : this.$store.state.instance.server + path
+ const url = path.includes('://') ? path : useInstanceStore().server + path
return {
mimetype: fileTypeExt(url),
url,
}
},
},
methods: {
...Setting.methods,
setMediaFile(fileInfo) {
if (this.realDraftMode) {
this.draft = fileInfo.url
} else {
this.configSink(this.path, fileInfo.url)
}
},
},
}
diff --git a/src/components/settings_modal/helpers/pwa_manifest_icons_setting.js b/src/components/settings_modal/helpers/pwa_manifest_icons_setting.js
index 6d6cdd121a..dd80ab54dc 100644
--- a/src/components/settings_modal/helpers/pwa_manifest_icons_setting.js
+++ b/src/components/settings_modal/helpers/pwa_manifest_icons_setting.js
@@ -1,101 +1,100 @@
import { clone } from 'lodash'
import Attachment from 'src/components/attachment/attachment.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Select from 'src/components/select/select.vue'
import { fileTypeExt } from 'src/services/file_type/file_type.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import Setting from './setting.js'
export default {
...Setting,
components: {
...Setting.components,
Select,
Attachment,
MediaUpload,
},
computed: {
...Setting.computed,
purposeOptions() {
return ['any', 'monochrome', 'maskable'].map((value) => ({
value,
key: value,
label: this.$t('admin_dash.instance.pwa.icon.' + value),
}))
},
},
methods: {
...Setting.methods,
attachment(e) {
const path = e[':src']
if (!path) {
return {
mimetype: '',
url: '',
}
}
- const url = path.includes('://')
- ? path
- : this.$store.state.instance.server + path
+ const url = path.includes('://') ? path : useInstanceStore().server + path
return {
mimetype: fileTypeExt(url),
url,
}
},
setMediaFile({ event, index }) {
this.update({
event: {
target: {
value: event.url,
},
},
index,
eventType: 'edit',
field: ':src',
})
},
setPurpose({ event, index }) {
this.update({
event: {
target: {
value: event,
},
},
index,
eventType: 'edit',
field: ':purpose',
})
},
getValue({ event, field, index, eventType }) {
switch (eventType) {
case 'add': {
const res = [...this.visibleState, {}]
return res
}
case 'remove': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
return [...pre, ...post]
}
case 'edit': {
const pre = this.visibleState.slice(0, index)
const post = this.visibleState.slice(index + 1)
const item = clone(this.visibleState[index])
const string = event.target.value
if (!string) {
delete item[field]
} else {
item[field] = string
}
return [...pre, item, ...post]
}
}
},
},
}
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index 1110505cc6..73151b70a9 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,494 +1,488 @@
import { mapActions } from 'pinia'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import { newImporter } from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { normalizeThemeData, useInterfaceStore } from 'src/stores/interface'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import Preview from './old_theme_tab/theme_preview.vue'
const AppearanceTab = {
data() {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure,
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange',
],
userPalette: {},
intersectionObserver: null,
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map(
(mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(
`settings.style.themes3.hacks.forced_roundness_mode_${mode}`,
),
}),
),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.style.themes3.hacks.underlay_override_mode_${mode}`,
),
})),
backgroundUploading: false,
background: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
ProfileSettingIndicator,
Preview,
PaletteEditor,
},
mounted() {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
- const currentIndex = this.$store.state.instance[`${resource}sIndex`]
+ const currentIndex = useInstanceStore()[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then((index) => {
return Object.entries(index).map(([k, func]) => [k, func()])
})
}
updateIndex('style').then((styles) => {
styles.forEach(([key, stylePromise]) =>
stylePromise.then((data) => {
const meta = data.find((x) => x.component === '@meta')
this.availableThemesV3.push({
key,
data,
name: meta.directives.name,
version: 'v3',
})
}),
)
})
updateIndex('theme').then((themes) => {
themes.forEach(([key, themePromise]) =>
themePromise.then((data) => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(
`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`,
)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({
key,
data,
name: data.name,
version: 'v2',
})
}
}),
)
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then((bundledPalettes) => {
bundledPalettes.forEach(([key, palettePromise]) =>
palettePromise.then((v) => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = v
palette = {
key,
name,
bg,
fg,
text,
link,
cRed,
cBlue,
cGreen,
cOrange,
}
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}),
)
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(
(x) => x.key === target.dataset.themeKey,
)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
},
{
root: this.$refs.themeList,
},
)
} else {
this.availableStyles.forEach((theme) =>
this.previewTheme(theme.key, theme.version, theme.data),
)
}
},
updated() {
this.$nextTick(() => {
this.$refs.themeList
.querySelectorAll('.theme-preview')
.forEach((node) => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed() {
this.userPalette = this.paletteDataUsed || {}
},
},
computed: {
isDefaultBackground() {
return !this.$store.state.users.currentUser.background_image
},
switchInProgress() {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed() {
return useInterfaceStore().paletteDataUsed
},
availableStyles() {
return [...this.availableThemesV3, ...this.availableThemesV2]
},
availablePalettes() {
return [...this.bundledPalettes, ...this.stylePalettes]
},
stylePalettes() {
const ruleset = useInterfaceStore().styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find((x) => x.component === '@meta')
const result = ruleset
.filter((x) => x.component.startsWith('@palette'))
.map((x) => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver() {
return !window.IntersectionObserver
},
instanceWallpaper() {
- this.$store.state.instance.background
+ useInstanceStore().background
},
instanceWallpaperUsed() {
return (
- this.$store.state.instance.background &&
+ useInstanceStore().background &&
!this.$store.state.users.currentUser.background_image
)
},
customThemeVersion() {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed() {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed() {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject(),
},
methods: {
importFile() {
this.fileImporter.importData()
},
importParser(file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport(parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure(result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find((x) => x.component === '@meta') == null) return false
return true
}
},
isThemeActive(key) {
- return (
- key === (this.mergedConfig.theme || this.$store.state.instance.theme)
- )
+ return key === (this.mergedConfig.theme || useInstanceStore().theme)
},
isStyleActive(key) {
- return (
- key === (this.mergedConfig.style || this.$store.state.instance.style)
- )
+ return key === (this.mergedConfig.style || useInstanceStore().style)
},
isPaletteActive(key) {
- return (
- key ===
- (this.mergedConfig.palette || this.$store.state.instance.palette)
- )
+ return key === (this.mergedConfig.palette || useInstanceStore().palette)
},
...mapActions(useInterfaceStore, ['setStyle', 'setTheme']),
setPalette(name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom(data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming() {
useInterfaceStore().setStyle('stock')
},
previewTheme(key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true,
})
} else if (version === 'v3') {
const palette = input.find((x) => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v]),
),
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter((x) => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule(
[
'#theme-preview-',
key,
' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}',
].join(''),
)
sheet.ready = true
adoptStyleSheets()
},
uploadFile(slot, e) {
const file = e.target.files[0]
if (!file) {
return
}
- if (file.size > this.$store.state.instance[slot + 'limit']) {
+ if (file.size > useInstanceStore()[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
- this.$store.state.instance[slot + 'limit'],
+ useInstanceStore()[slot + 'limit'],
)
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
}),
],
level: 'error',
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground() {
const confirmed = window.confirm(
this.$t('settings.reset_background_confirm'),
)
if (confirmed) {
this.submitBackground('')
}
},
resetUploadedBackground() {
this.backgroundPreview = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
this.$store.state.api.backendInteractor
.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => {
this.backgroundUploading = false
})
},
},
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/clutter_tab.js b/src/components/settings_modal/tabs/clutter_tab.js
index c0b7b33b2b..ad5d01b9db 100644
--- a/src/components/settings_modal/tabs/clutter_tab.js
+++ b/src/components/settings_modal/tabs/clutter_tab.js
@@ -1,205 +1,206 @@
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { mapState as mapVuexState } from 'vuex'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
instanceSpecificPanelPresent() {
- return this.$store.state.instance.showInstanceSpecificPanel
+ return useInstanceStore().showInstanceSpecificPanel
},
...SharedComputedObject(),
...mapState(useServerSideStorageStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
...mapVuexState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (state) => useInstanceStore().blockExpiration,
}),
onMuteDefaultActionLv1: {
get() {
const value = this.$store.state.config.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
const value = this.$store.state.config.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onBlockDefaultAction',
value: realValue,
})
},
},
muteFiltersDraft() {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired() {
const now = Date.now()
return Object.entries(this.muteFiltersDraftObject).filter(
([, { expires }]) => expires != null && expires <= now,
)
},
},
methods: {
...mapActions(useServerSideStorageStore, [
'setPreference',
'unsetPreference',
'pushServerSideStorage',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes()),
].join('')
return datetime
},
checkRegexValid(id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter(
filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
},
) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId, value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter(id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId, value: filter })
this.pushServerSideStorage()
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({
path: 'simple.muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default ClutterTab
diff --git a/src/components/settings_modal/tabs/composing_tab.js b/src/components/settings_modal/tabs/composing_tab.js
index 0b06922e08..73534bba2a 100644
--- a/src/components/settings_modal/tabs/composing_tab.js
+++ b/src/components/settings_modal/tabs/composing_tab.js
@@ -1,193 +1,194 @@
import { mapState } from 'vuex'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import localeService from 'src/services/locale/locale.service.js'
import { cacheKey, clearCache, emojiCacheKey } from 'src/services/sw/sw.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faDatabase,
faGlobe,
faMessage,
faPenAlt,
faSliders,
} from '@fortawesome/free-solid-svg-icons'
library.add(faGlobe, faMessage, faPenAlt, faDatabase, faSliders)
const ComposingTab = {
props: {
parentCollapsed: {
required: true,
type: Boolean,
},
},
data() {
return {
subjectLineOptions: ['email', 'noop', 'masto'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`,
),
})),
conversationDisplayOptions: ['tree', 'linear'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`),
})),
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`),
}),
),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`),
}),
),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`),
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`),
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(
HTMLVideoElement.prototype,
'mozHasAudio',
) ||
// Chrome-likes
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'webkitAudioDecodedByteCount',
) ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'audioTracks',
),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
InterfaceLanguageSwitcher,
ProfileSettingIndicator,
ScopeSelector,
Select,
FontControl,
},
computed: {
postFormats() {
- return this.$store.state.instance.postFormats || []
+ return useInstanceStore().postFormats || []
},
postContentOptions() {
return this.postFormats.map((format) => ({
key: format,
value: format,
label: this.$t(`post_status.content_type["${format}"]`),
}))
},
language: {
get: function () {
return this.$store.getters.mergedConfig.interfaceLanguage
},
set: function (val) {
this.$store.dispatch('setOption', {
name: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (state) => useInstanceStore().blockExpiration,
}),
},
methods: {
changeDefaultScope(value) {
this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
},
clearCache(key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch((error) => {
this.$store.dispatch('settingsSaved', { error })
})
},
tooSmall() {
this.$emit('tooSmall')
},
tooBig() {
this.$emit('tooBig')
},
getNavMode() {
return this.$refs.tabSwitcher.getNavMode()
},
clearAssetCache() {
this.clearCache(cacheKey)
},
clearEmojiCache() {
this.clearCache(emojiCacheKey)
},
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateFont(key, value) {
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value,
},
},
})
},
},
}
export default ComposingTab
diff --git a/src/components/settings_modal/tabs/developer_tab.js b/src/components/settings_modal/tabs/developer_tab.js
index 60f2ecb363..e90ffbcb69 100644
--- a/src/components/settings_modal/tabs/developer_tab.js
+++ b/src/components/settings_modal/tabs/developer_tab.js
@@ -1,45 +1,46 @@
import { cacheKey, clearCache, emojiCacheKey } from 'src/services/sw/sw.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const pleromaFeCommitUrl =
'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const VersionTab = {
data() {
- const instance = this.$store.state.instance
+ const instance = useInstanceStore()
return {
backendVersion: instance.backendVersion,
backendRepository: instance.backendRepository,
frontendVersion: instance.frontendVersion,
}
},
components: {
BooleanSetting,
},
computed: {
frontendVersionLink() {
return pleromaFeCommitUrl + this.frontendVersion
},
...SharedComputedObject(),
},
methods: {
clearAssetCache() {
this.clearCache(cacheKey)
},
clearEmojiCache() {
this.clearCache(emojiCacheKey)
},
clearCache(key) {
clearCache(key)
.then(() => {
this.$store.dispatch('settingsSaved', { success: true })
})
.catch((error) => {
this.$store.dispatch('settingsSaved', { error })
})
},
},
}
export default VersionTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 8bd2b913d3..324dbe3222 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,271 +1,272 @@
import { cloneDeep } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { mapState as mapVuexState } from 'vuex'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data() {
return {
replyVisibilityOptions: ['all', 'following', 'self'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`),
})),
muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`user_card.mute_block_${mode}`),
})),
muteFiltersDraftObject: cloneDeep(
useServerSideStorageStore().prefsStorage.simple.muteFilters,
),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useServerSideStorageStore().prefsStorage.simple.muteFilters,
).map(([k]) => [k, false]),
),
exportedFilter: null,
filterImporter: newImporter({
validator(parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = '',
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value,
})
},
onImportFailure(result) {
console.error('Failure importing filter:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error',
})
},
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter,
}),
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
instanceSpecificPanelPresent() {
- return this.$store.state.instance.showInstanceSpecificPanel
+ return useInstanceStore().showInstanceSpecificPanel
},
...SharedComputedObject(),
...mapState(useServerSideStorageStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
...mapVuexState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (state) => useInstanceStore().blockExpiration,
}),
onMuteDefaultActionLv1: {
get() {
const value = this.$store.state.config.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
const value = this.$store.state.config.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.$store.dispatch('setOption', {
name: 'onBlockDefaultAction',
value: realValue,
})
},
},
muteFiltersDraft() {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired() {
const now = Date.now()
return Object.entries(this.muteFiltersDraftObject).filter(
([, { expires }]) => expires != null && expires <= now,
)
},
},
methods: {
...mapActions(useServerSideStorageStore, [
'setPreference',
'unsetPreference',
'pushServerSideStorage',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes()),
].join('')
return datetime
},
checkRegexValid(id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
createFilter(
filter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
},
) {
const newId = uuidv4()
filter.order = this.muteFilters.length + 2
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId, value: filter })
this.pushServerSideStorage()
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter(id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setPreference({ path: 'simple.muteFilters.' + newId, value: filter })
this.pushServerSideStorage()
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
this.pushServerSideStorage()
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
this.pushServerSideStorage()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setPreference({
path: 'simple.muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
this.pushServerSideStorage()
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default FilteringTab
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index cf98fd1824..d24d8b77b7 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,86 +1,87 @@
import { mapState } from 'vuex'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import localeService from 'src/services/locale/locale.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
const GeneralTab = {
props: {
parentCollapsed: {
required: true,
type: Boolean,
},
},
data() {
return {
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
FloatSetting,
FontControl,
InterfaceLanguageSwitcher,
ProfileSettingIndicator,
},
computed: {
language: {
get: function () {
return this.$store.getters.mergedConfig.interfaceLanguage
},
set: function (val) {
this.$store.dispatch('setOption', {
name: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState({
- blockExpirationSupported: (state) => state.instance.blockExpiration,
+ blockExpirationSupported: (state) => useInstanceStore().blockExpiration,
}),
},
methods: {
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateFont(key, value) {
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value,
},
},
})
},
},
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/layout_tab.js b/src/components/settings_modal/tabs/layout_tab.js
index 3fc18491c5..3191af4db0 100644
--- a/src/components/settings_modal/tabs/layout_tab.js
+++ b/src/components/settings_modal/tabs/layout_tab.js
@@ -1,56 +1,57 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
const GeneralTab = {
props: {
parentCollapsed: {
required: true,
type: Boolean,
},
},
data() {
return {
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`),
}),
),
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
ProfileSettingIndicator,
},
computed: {
postFormats() {
- return this.$store.state.instance.postFormats || []
+ return useInstanceStore().postFormats || []
},
instanceShoutboxPresent() {
- return this.$store.state.instance.shoutAvailable
+ return useInstanceStore().shoutAvailable
},
columns() {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (
this.$store.getters.mergedConfig.sidebarRight ||
mode === 'postform'
) {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
...SharedComputedObject(),
},
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
index 55087d1e2b..0e5e401f3e 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -1,153 +1,154 @@
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import withLoadMore from 'src/components/../hocs/with_load_more/with_load_more'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
const BlockList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) =>
get($store.state.users.currentUser, 'blockIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const MuteList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) =>
get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items',
})(SelectableList)
const MutesAndBlocks = {
data() {
return {
activeTab: 'profile',
}
},
created() {
useOAuthTokensStore().fetchTokens()
this.$store.dispatch('getKnownDomains')
},
components: {
TabSwitcher,
BlockList,
MuteList,
DomainMuteList,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Autosuggest,
Checkbox,
},
computed: {
knownDomains() {
- return this.$store.state.instance.knownDomains
+ return useInstanceStore().knownDomains
},
user() {
return this.$store.state.users.currentUser
},
},
methods: {
importFollows(file) {
return this.$store.state.api.backendInteractor
.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks(file) {
return this.$store.state.api.backendInteractor
.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent(users) {
// Get addresses
return users
.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
}
return user.screen_name
})
.join('\n')
},
activateTab(tabName) {
this.activeTab = tabName
},
filterUnblockedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.user.id
})
},
filterUnMutedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.user.id
})
},
queryUserIds(query) {
return this.$store
.dispatch('searchUsers', { query })
.then((users) => map(users, 'id'))
},
blockUsers(ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers(ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers(ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers(ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
filterUnMutedDomains(urls) {
return urls.filter((url) => !this.user.domainMutes.includes(url))
},
queryKnownDomains(query) {
return new Promise((resolve) => {
resolve(
this.knownDomains.filter((url) => url.toLowerCase().includes(query)),
)
})
},
unmuteDomains(domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
},
}
export default MutesAndBlocks
diff --git a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
index c79c72e701..669abc9a37 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
@@ -1,838 +1,839 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import Select from 'src/components/select/select.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import {
getContrastRatioLayers,
hex2rgb,
relativeLuminance,
rgb2hex,
} from 'src/services/color_convert/color_convert.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import {
getCssRules,
getScopedVersion,
} from 'src/services/theme_data/css_utils.js'
import { SLOT_INHERITANCE } from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
colors2to3,
DEFAULT_SHADOWS,
generateColors,
generateFonts,
generateRadii,
generateShadows,
getLayers,
getOpacitySlot,
OPACITIES,
shadows2to3,
} from 'src/services/theme_data/theme_data.service.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import Preview from './theme_preview.vue'
// List of color values used in v1
const v1OnlyNames = [
'bg',
'fg',
'text',
'link',
'cRed',
'cGreen',
'cBlue',
'cOrange',
].map((_) => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data() {
return {
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
themeExporter: newExporter({
filename: 'pleroma_theme',
getExportedObject: () => this.exportedTheme,
}),
availableStyles: [],
selected: '',
selectedTheme: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewTheme: {},
shadowsInvalid: true,
colorsInvalid: true,
radiiInvalid: true,
keepColor: false,
keepShadows: false,
keepOpacity: false,
keepRoundness: false,
keepFonts: false,
...Object.keys(SLOT_INHERITANCE)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }),
{},
),
...Object.keys(OPACITIES)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }),
{},
),
shadowSelected: undefined,
shadowsLocal: {},
fontsLocal: {},
btnRadiusLocal: '',
inputRadiusLocal: '',
checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
tooltipRadiusLocal: '',
chatMessageRadiusLocal: '',
}
},
created() {
- const currentIndex = this.$store.state.instance.themesIndex
+ const currentIndex = useInstanceStore().themesIndex
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore().fetchThemesIndex()
}
promise.then((themesIndex) => {
Object.values(themesIndex).forEach((themeFunc) => {
themeFunc().then(
(themeData) => themeData && this.availableStyles.push(themeData),
)
})
})
},
mounted() {
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp() {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const { origin, themeEngineVersion, type, noActionsPossible } =
this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'fe_downgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'fe_upgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
}
},
selectedVersion() {
return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors() {
return Object.keys(SLOT_INHERITANCE)
.map((key) => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity() {
return Object.keys(OPACITIES)
.map((key) => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii() {
return {
btn: this.btnRadiusLocal,
input: this.inputRadiusLocal,
checkbox: this.checkboxRadiusLocal,
panel: this.panelRadiusLocal,
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal,
}
},
// This needs optimization maybe
previewContrast() {
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5,
})
const colorsConverted = Object.entries(colors).reduce(
(acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }),
{},
)
const ratios = Object.entries(SLOT_INHERITANCE).reduce(
(acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText =
slotIsBaseText ||
(typeof value === 'object' && value !== null && value.textColor)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg'
? ['cRed', 'cGreen', 'cBlue', 'cOrange']
: []),
]
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity,
)
// Temporary patch for null-y value errors
if (layers.flat().some((v) => v == null)) return acc
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey],
),
}
}, {}),
}
},
{},
)
return Object.entries(ratios).reduce((acc, [k, v]) => {
acc[k] = hints(v)
return acc
}, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
return {}
}
},
themeDataUsed() {
return useInterfaceStore().themeDataUsed
},
shadowsAvailable() {
return Object.keys(DEFAULT_SHADOWS).sort()
},
currentShadowOverriden: {
get() {
return !!this.currentShadow
},
set(val) {
if (val) {
this.shadowsLocal[this.shadowSelected] = (
this.currentShadowFallback || []
).map((s) => ({
name: null,
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1,
...s,
}))
} else {
delete this.shadowsLocal[this.shadowSelected]
}
},
},
currentShadowFallback() {
return (this.previewTheme.shadows || {})[this.shadowSelected]
},
currentShadow: {
get() {
return this.shadowsLocal[this.shadowSelected]
},
set(v) {
this.shadowsLocal[this.shadowSelected] = v
},
},
themeValid() {
return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
},
exportedTheme() {
const saveEverything =
!this.keepFonts &&
!this.keepShadows &&
!this.keepOpacity &&
!this.keepRoundness &&
!this.keepColor
const source = {
themeEngineVersion: CURRENT_VERSION,
}
if (this.keepFonts || saveEverything) {
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
}
return {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2,
theme,
source,
}
},
isActive() {
const tabSwitcher = this.$parent
return tabSwitcher ? tabSwitcher.isActive('theme') : false
},
},
components: {
ColorInput,
OpacityInput,
RangeInput,
ContrastRatio,
ShadowControl,
FontControl,
TabSwitcher,
Preview,
Checkbox,
Select,
},
methods: {
loadTheme(
{ theme, source, _pleroma_theme_version: fileVersion },
origin,
forceUseSource = false,
) {
this.dismissWarning()
const version =
origin === 'localStorage' && !theme.colors ? 'l1' : fileVersion
const snapshotEngineVersion = (theme || {}).themeEngineVersion
const themeEngineVersion = (source || {}).themeEngineVersion || 2
const versionsMatch = themeEngineVersion === CURRENT_VERSION
const sourceSnapshotMismatch =
theme !== undefined &&
source !== undefined &&
themeEngineVersion !== snapshotEngineVersion
// Force loading of source if user requested it or if snapshot
// is unavailable
const forcedSourceLoad = (source && forceUseSource) || !theme
if (
!(versionsMatch && !sourceSnapshotMismatch) &&
!forcedSourceLoad &&
version !== 'l1' &&
origin !== 'defaults'
) {
if (sourceSnapshotMismatch && origin === 'localStorage') {
this.themeWarning = {
origin,
themeEngineVersion,
type: 'snapshot_source_mismatch',
}
} else if (!theme) {
this.themeWarning = {
origin,
noActionsPossible: true,
themeEngineVersion,
type: 'no_snapshot_old_version',
}
} else if (!versionsMatch) {
this.themeWarning = {
origin,
noActionsPossible: !source,
themeEngineVersion,
type: 'wrong_version',
}
}
}
this.normalizeLocalState(theme, version, source, forcedSourceLoad)
},
forceLoadLocalStorage() {
this.loadThemeFromLocalStorage(true)
},
dismissWarning() {
this.themeWarning = undefined
this.tempImportFile = undefined
},
forceLoad() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(true)
break
case 'file':
this.onImport(this.tempImportFile, true)
break
}
this.dismissWarning()
},
forceSnapshot() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage(confirmLoadSource = false) {
const theme = this.themeDataUsed?.source
if (theme) {
this.loadTheme(
{
theme,
},
'localStorage',
confirmLoadSource,
)
}
},
setCustomTheme() {
useInterfaceStore().setThemeV2({
customTheme: {
ignore: true,
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
},
customThemeSource: {
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
colors: this.currentColors,
radii: this.currentRadii,
},
})
},
updatePreviewColors() {
const result = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors,
})
this.previewTheme.colors = result.theme.colors
this.previewTheme.opacity = result.theme.opacity
},
updatePreviewShadows() {
this.previewTheme.shadows = generateShadows(
{
shadows: this.shadowsLocal,
opacity: this.previewTheme.opacity,
themeEngineVersion: this.engineVersion,
},
this.previewTheme.colors,
relativeLuminance(this.previewTheme.colors.bg) < 0.5 ? 1 : -1,
).theme.shadows
},
importTheme() {
this.themeImporter.importData()
},
exportTheme() {
this.themeExporter.exportData()
},
onImport(parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
onImportFailure() {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll() {
this.loadThemeFromLocalStorage()
},
// Clears all the extra stuff when loading V1 theme
clearV1() {
Object.keys(this.$data)
.filter((_) => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter((_) => !v1OnlyNames.includes(_))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearRoundness() {
Object.keys(this.$data)
.filter((_) => _.endsWith('RadiusLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearOpacity() {
Object.keys(this.$data)
.filter((_) => _.endsWith('OpacityLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearShadows() {
this.shadowsLocal = {}
},
clearFonts() {
this.fontsLocal = {}
},
/**
* This applies stored theme data onto form. Supports three versions of data:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} theme - theme data (snapshot)
* @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
* @param {Object} source - theme source - this will be used if compatible
* @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
* this allows importing source anyway
*/
normalizeLocalState(theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source?.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
// Old v1 naming: fg is text, btn is foreground
if (
typeof colors.text === 'undefined' &&
typeof colors.fg !== 'undefined'
) {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (
typeof colors.text !== 'undefined' &&
typeof colors.fg !== 'undefined'
) {
version = 2
}
}
this.engineVersion = version
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
this.textColorLocal = rgb2hex(colors.fg)
}
if (!this.keepColor) {
this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
if (version === 1 || version === 'l1') {
keys
.add('bg')
.add('link')
.add('cRed')
.add('cBlue')
.add('cGreen')
.add('cOrange')
}
keys.forEach((key) => {
const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
if (!this.keepRoundness) {
this.clearRoundness()
Object.entries(radii).forEach(([k, v]) => {
// 'Radius' is kept mostly for v1->v2 localstorage transition
const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
this[key + 'RadiusLocal'] = v
})
}
if (!this.keepShadows) {
this.clearShadows()
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.updatePreviewColors()
this.updatePreviewShadows()
this.shadowSelected = this.shadowsAvailable[0]
}
if (!this.keepFonts) {
this.clearFonts()
this.fontsLocal = fonts
}
},
updateTheme3Preview() {
const theme2 = convertTheme2To3(this.previewTheme)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
})
const sheet = createStyleSheet('theme-tab-overall-preview', 90)
const rule = getScopedVersion(getCssRules(theme3.eager), '&').join('\n')
sheet.clear()
sheet.addRule('#theme-preview {\n' + rule + '\n}')
sheet.ready = true
adoptStyleSheets()
},
},
watch: {
themeDataUsed() {
this.loadThemeFromLocalStorage()
},
currentRadii() {
try {
this.previewTheme.radii = generateRadii({
radii: this.currentRadii,
}).theme.radii
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
console.warn(e)
}
},
shadowsLocal: {
handler() {
try {
this.updatePreviewShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
console.warn(e)
}
},
deep: true,
},
fontsLocal: {
handler() {
try {
this.previewTheme.fonts = generateFonts({
fonts: this.fontsLocal,
}).theme.fonts
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
console.warn(e)
}
},
deep: true,
},
currentColors() {
try {
this.updatePreviewColors()
this.colorsInvalid = false
} catch (e) {
this.colorsInvalid = true
console.warn(e)
}
},
currentOpacity() {
try {
this.updatePreviewColors()
} catch (e) {
console.warn(e)
}
},
selected() {
this.selectedTheme = Object.entries(this.availableStyles).find(
([, s]) => {
if (Array.isArray(s)) {
return s[0] === this.selected
} else {
return s.name === this.selected
}
},
)[1]
},
selectedTheme() {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
}
if (!this.keepShadows) {
this.clearShadows()
}
if (!this.keepOpacity) {
this.clearOpacity()
}
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selectedTheme[1]
this.fgColorLocal = this.selectedTheme[2]
this.textColorLocal = this.selectedTheme[3]
this.linkColorLocal = this.selectedTheme[4]
this.cRedColorLocal = this.selectedTheme[5]
this.cGreenColorLocal = this.selectedTheme[6]
this.cBlueColorLocal = this.selectedTheme[7]
this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(
this.selectedTheme.theme,
2,
this.selectedTheme.source,
)
}
},
},
}
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index 3bc02829ad..87a70af2aa 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -1,170 +1,171 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import localeService from 'src/services/locale/locale.service.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
import Mfa from './mfa.vue'
const SecurityTab = {
data() {
return {
newEmail: '',
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: ['', '', ''],
changedPassword: false,
changePasswordError: false,
moveAccountTarget: '',
moveAccountPassword: '',
movedAccount: false,
moveAccountError: false,
aliases: [],
listAliasesError: false,
addAliasTarget: '',
addedAlias: false,
addAliasError: false,
}
},
created() {
useOAuthTokensStore().fetchTokens()
this.fetchAliases()
},
components: {
ProgressButton,
Mfa,
Checkbox,
},
computed: {
user() {
return this.$store.state.users.currentUser
},
pleromaExtensionsAvailable() {
- return this.$store.state.instance.pleromaExtensionsAvailable
+ return useInstanceStore().pleromaExtensionsAvailable
},
oauthTokens() {
return useOAuthTokensStore().tokens.map((oauthToken) => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
),
}
})
},
},
methods: {
confirmDelete() {
this.deletingAccount = true
},
deleteAccount() {
this.$store.state.api.backendInteractor
.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword() {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2],
}
this.$store.state.api.backendInteractor
.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail() {
const params = {
email: this.newEmail,
password: this.changeEmailPassword,
}
this.$store.state.api.backendInteractor
.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
moveAccount() {
const params = {
targetAccount: this.moveAccountTarget,
password: this.moveAccountPassword,
}
this.$store.state.api.backendInteractor
.moveAccount(params)
.then((res) => {
if (res.status === 'success') {
this.movedAccount = true
this.moveAccountError = false
} else {
this.movedAccount = false
this.moveAccountError = res.error
}
})
},
removeAlias(alias) {
this.$store.state.api.backendInteractor
.deleteAlias({ alias })
.then(() => this.fetchAliases())
},
addAlias() {
this.$store.state.api.backendInteractor
.addAlias({ alias: this.addAliasTarget })
.then(() => {
this.addedAlias = true
this.addAliasError = false
this.addAliasTarget = ''
})
.catch((error) => {
this.addedAlias = false
this.addAliasError = error
})
.then(() => this.fetchAliases())
},
fetchAliases() {
this.$store.state.api.backendInteractor
.listAliases()
.then((res) => {
this.aliases = res.aliases
this.listAliasesError = false
})
.catch((error) => {
this.listAliasesError = error.error
})
},
logout() {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken(id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
useOAuthTokensStore().revokeToken(id)
}
},
},
}
export default SecurityTab
diff --git a/src/components/shout_panel/shout_panel.js b/src/components/shout_panel/shout_panel.js
index 6cb6a53b05..8725697d6b 100644
--- a/src/components/shout_panel/shout_panel.js
+++ b/src/components/shout_panel/shout_panel.js
@@ -1,56 +1,57 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useShoutStore } from 'src/stores/shout'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faBullhorn, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faBullhorn, faTimes)
const shoutPanel = {
props: ['floating'],
data() {
return {
currentMessage: '',
channel: null,
collapsed: true,
}
},
computed: {
messages() {
return useShoutStore().messages
},
},
methods: {
submit(message) {
useShoutStore().channel.push('new_msg', { text: message }, 10000)
this.currentMessage = ''
},
togglePanel() {
this.collapsed = !this.collapsed
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.username,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
},
watch: {
messages() {
const scrollEl = this.$el.querySelector('.chat-window')
if (!scrollEl) return
if (
scrollEl.scrollTop + scrollEl.offsetHeight + 20 >
scrollEl.scrollHeight
) {
this.$nextTick(() => {
if (!scrollEl) return
scrollEl.scrollTop = scrollEl.scrollHeight - scrollEl.offsetHeight
})
}
},
},
}
export default shoutPanel
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index b12cef86e1..0dc2163605 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,143 +1,144 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { useAnnouncementsStore } from 'src/stores/announcements'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useShoutStore } from 'src/stores/shout'
import GestureService from '../../services/gesture_service/gesture_service'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import UserCard from '../user_card/user_card.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faCompass,
faFilePen,
faHome,
faInfoCircle,
faList,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList,
faFilePen,
)
const SideDrawer = {
props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined,
}),
created() {
this.closeGesture = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.toggleDrawer,
)
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: { UserCard },
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
shout() {
return useShoutStore().joined
},
unseenNotifications() {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount() {
return this.unseenNotifications.length
},
suggestionsEnabled() {
- return this.$store.state.instance.suggestionsEnabled
+ return useInstanceStore().suggestionsEnabled
},
logo() {
- return this.$store.state.instance.logo
+ return useInstanceStore().logo
},
hideSitename() {
- return this.$store.state.instance.hideSitename
+ return useInstanceStore().hideSitename
},
sitename() {
- return this.$store.state.instance.name
+ return useInstanceStore().name
},
followRequestCount() {
return this.$store.state.api.followRequests.length
},
privateMode() {
- return this.$store.state.instance.private
+ return useInstanceStore().private
},
federating() {
- return this.$store.state.instance.federating
+ return useInstanceStore().federating
},
timelinesRoute() {
let name
if (useInterfaceStore().lastTimeline) {
name = useInterfaceStore().lastTimeline
}
name = this.currentUser ? 'friends' : 'public-timeline'
if (USERNAME_ROUTES.has(name)) {
return { name, params: { username: this.currentUser.screen_name } }
} else {
return { name }
}
},
...mapPiniaState(useAnnouncementsStore, {
supportsAnnouncements: (store) => store.supportsAnnouncements,
unreadAnnouncementCount: 'unreadAnnouncementCount',
}),
...mapState({
pleromaChatMessagesAvailable: (state) =>
- state.instance.pleromaChatMessagesAvailable,
+ useInstanceStore().pleromaChatMessagesAvailable,
}),
...mapGetters(['unreadChatCount', 'draftCount']),
},
methods: {
toggleDrawer() {
this.closed = !this.closed
},
doLogout() {
this.logout()
this.toggleDrawer()
},
touchStart(e) {
GestureService.beginSwipe(e, this.closeGesture)
},
touchMove(e) {
GestureService.updateSwipe(e, this.closeGesture)
},
openSettingsModal() {
useInterfaceStore().openSettingsModal('user')
},
openAdminModal() {
useInterfaceStore().openSettingsModal('admin')
},
},
}
export default SideDrawer
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index 0b7dc8b574..1c02e86570 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -1,36 +1,37 @@
import groupBy from 'lodash/groupBy'
import map from 'lodash/map'
import { mapGetters, mapState } from 'vuex'
+import { useInstanceStore } from 'src/stores/instance.js'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = {
created() {
- const nicknames = this.$store.state.instance.staffAccounts
+ const nicknames = useInstanceStore().staffAccounts
nicknames.forEach((nickname) =>
this.$store.dispatch('fetchUserIfMissing', nickname),
)
},
components: {
BasicUserCard,
},
computed: {
groupedStaffAccounts() {
const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(
(_) => _,
)
const groupedStaffAccounts = groupBy(staffAccounts, 'role')
return [
{ role: 'admin', users: groupedStaffAccounts.admin },
{ role: 'moderator', users: groupedStaffAccounts.moderator },
].filter((group) => group.users)
},
...mapGetters(['findUserByName']),
...mapState({
- staffAccounts: (state) => state.instance.staffAccounts,
+ staffAccounts: (state) => useInstanceStore().staffAccounts,
}),
},
}
export default StaffPanel
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 1673a6933a..59656299af 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,673 +1,674 @@
import { unescape as ldUnescape, uniqBy } from 'lodash'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import AvatarList from '../avatar_list/avatar_list.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
import Timeago from '../timeago/timeago.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import UserPopover from '../user_popover/user_popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const camelCase = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const controlledOrUncontrolledGetters = (list) =>
list.reduce((res, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const controlledName = `controlled${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
res[name] = function () {
return (this.$data[toggle] !== undefined ||
this.$props[toggle] !== undefined) &&
this[toggle]
? this[controlledName]
: this[uncontrolledName]
}
return res
}, {})
const controlledOrUncontrolledToggle = (obj, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[toggle]) {
obj[toggle]()
} else {
obj[uncontrolledName] = !obj[uncontrolledName]
}
}
const controlledOrUncontrolledSet = (obj, name, val) => {
const camelized = camelCase(name)
const set = `controlledSet${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[set]) {
obj[set](val)
} else {
obj[uncontrolledName] = val
}
}
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
RichContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
StatusActionButtons,
},
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
'profileUserId',
'inQuote',
'simpleTree',
'controlledThreadDisplayStatus',
'controlledToggleThreadDisplay',
'showOtherRepliesAsButton',
'controlledShowingTall',
'controlledToggleShowingTall',
'controlledExpandingSubject',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
'controlledReplying',
'controlledToggleReplying',
'controlledMediaPlaying',
'controlledSetMediaPlaying',
'dive',
],
emits: ['interacted'],
data() {
return {
uncontrolledReplying: false,
unmuted: false,
userExpanded: false,
uncontrolledMediaPlaying: [],
suspendable: true,
error: null,
headTailLinks: null,
displayQuote: !this.inQuote,
}
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
showReasonMutedThread() {
return (
(this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)) &&
!this.inConversation
)
},
repeaterClass() {
const user = this.statusoid.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightClass(user)
},
deleted() {
return this.statusoid.deleted
},
repeaterStyle() {
const user = this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.statusoid.retweeted_status
},
retweeterUser() {
return this.statusoid.user
},
retweeter() {
return this.statusoid.user.name || this.statusoid.user.screen_name_ui
},
retweeterHtml() {
return this.statusoid.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
this.statusoid.user.id,
this.statusoid.user.screen_name,
)
},
status() {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useServerSideStorageStore().prefsStorage.simple.muteFilters,
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
showActorTypeIndicator() {
return !this.hideBotIndication
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.status.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter((_) => _)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.statusoid.user.id === this.currentUser.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog =
reblog && this.$store.getters.relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting)
)
},
shouldNotMute() {
if (this.isFocused) return true
const { status } = this
const { reblog } = status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId))) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isFocused() {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
isReply() {
return !!(
this.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user && user.screen_name_ui
}
},
replySubject() {
if (!this.status.summary) return ''
const decodedSummary = ldUnescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.isFocused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.statusFromGlobalRepository.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return this.$store.state.users.currentUser
},
mergedConfig() {
return this.$store.getters.mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest() {
return !!this.controlledThreadDisplayStatus
},
threadShowing() {
return this.controlledThreadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.status.edited_at !== null
},
editingAvailable() {
- return this.$store.state.instance.editingAvailable
+ return useInstanceStore().editingAvailable
},
hasVisibleQuote() {
return this.status.quote_url && this.status.quote_visible
},
hasInvisibleQuote() {
return this.status.quote_url && !this.status.quote_visible
},
quotedStatus() {
return this.status.quote_id
? this.$store.state.statuses.allStatusesObject[this.status.quote_id]
: undefined
},
shouldDisplayQuote() {
return this.quotedStatus && this.displayQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.$emit('interacted')
this.error = undefined
},
toggleReplying() {
this.$emit('interacted')
if (this.replying) {
this.$refs.postStatusForm.requestClose()
} else {
this.doToggleReplying()
}
},
doToggleReplying() {
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
controlledOrUncontrolledSet(
this,
'mediaPlaying',
this.mediaPlaying.concat(id),
)
},
removeMediaPlaying(id) {
controlledOrUncontrolledSet(
this,
'mediaPlaying',
this.mediaPlaying.filter((mediaId) => mediaId !== id),
)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted(highlightId) {
if (this.$el.getBoundingClientRect == null) return
const id = highlightId
if (this.status.id === id) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
toggleDisplayQuote() {
if (this.shouldDisplayQuote) {
this.displayQuote = false
} else if (!this.quotedStatus) {
this.$store.dispatch('fetchStatus', this.status.quote_id).then(() => {
this.displayQuote = true
})
} else {
this.displayQuote = true
}
},
},
watch: {
highlight: function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.isFocused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.isFocused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (val) {
this.suspendable = val
},
},
}
export default Status
diff --git a/src/components/status_action_buttons/action_button.js b/src/components/status_action_buttons/action_button.js
index 5badd87e70..d5c68422a9 100644
--- a/src/components/status_action_buttons/action_button.js
+++ b/src/components/status_action_buttons/action_button.js
@@ -1,150 +1,151 @@
import EmojiPicker from 'src/components/emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusBookmarkFolderMenu from 'src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBookmark as faBookmarkRegular,
faStar as faStarRegular,
} from '@fortawesome/free-regular-svg-icons'
import {
faBookmark,
faCheck,
faChevronRight,
faChevronUp,
faExternalLinkAlt,
faEyeSlash,
faHistory,
faMinus,
faPlus,
faReply,
faRetweet,
faShareAlt,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPlus,
faMinus,
faCheck,
faTimes,
faWrench,
faChevronRight,
faChevronUp,
faReply,
faRetweet,
faStar,
faStarRegular,
faSmileBeam,
faBookmark,
faBookmarkRegular,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faHistory,
)
export default {
props: [
'button',
'status',
'extra',
'status',
'funcArg',
'getClass',
'getComponent',
'doAction',
'outerClose',
],
emits: ['interacted'],
components: {
StatusBookmarkFolderMenu,
EmojiPicker,
Popover,
},
data: () => ({
animationState: false,
}),
computed: {
buttonClass() {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra,
},
]
},
userIsMuted() {
return this.$store.getters.relationship(this.status.user.id).muting
},
threadIsMuted() {
return this.status.thread_muted
},
hideCustomEmoji() {
- return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable
+ return !useInstanceStore().pleromaCustomEmojiReactionsAvailable
},
buttonInnerClass() {
return [
this.button.name + '-button',
{
'main-button': this.extra,
'button-unstyled': !this.extra,
'-active': this.button.active?.(this.funcArg),
disabled: this.button.interactive
? !this.button.interactive(this.funcArg)
: false,
},
]
},
remoteInteractionLink() {
return this.$store.getters.remoteInteractionLink({
statusId: this.status.id,
})
},
},
methods: {
addReaction(event) {
const emoji = event.insertion
const existingReaction = this.status.emoji_reactions.find(
(r) => r.name === emoji,
)
if (existingReaction && existingReaction.me) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
} else {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
}
},
doActionWrap(
button,
close = () => {
/* no-op */
},
) {
if (
this.button.interactive ? !this.button.interactive(this.funcArg) : false
)
return
this.$emit('interacted')
if (button.name === 'emoji') {
this.$refs.picker.showPicker()
} else {
this.animationState = true
this.getComponent(button) === 'button' && this.doAction(button)
setTimeout(() => {
this.animationState = false
}, 500)
close()
}
},
},
}
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index d1a6845d1b..16f3b811bd 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,285 +1,286 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: 'none',
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
icon({ status, currentUser }) {
if (
currentUser.id !== status.user.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status, getters }) =>
!status.repeated && getters.mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status, dispatch }) {
if (!status.repeated) {
return dispatch('retweet', { id: status.id })
} else {
return dispatch('unretweet', { id: status.id })
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action({ status, dispatch }) {
if (!status.favorited) {
return dispatch('favorite', { id: status.id })
} else {
return dispatch('unfavorite', { id: status.id })
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'smile-beam'],
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: true,
dropdown: true,
// action ({ status, dispatch, emit }) {
// }
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status, dispatch }) {
if (status.pinned) {
return dispatch('unpinStatus', status.id)
} else {
return dispatch('pinStatus', status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status, dispatch }) {
if (status.bookmarked) {
return dispatch('unbookmark', { id: status.id })
} else {
return dispatch('bookmark', { id: status.id })
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status, state }) {
- return state.instance.editingAvailable && status.edited_at !== null
+ return useInstanceStore().editingAvailable && status.edited_at !== null
},
action({ status }) {
const originalStatus = { ...status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html',
]
stripFieldsList.forEach((p) => delete originalStatus[p])
useStatusHistoryStore().openStatusHistoryModal(originalStatus)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser, state }) {
return (
loggedIn &&
- state.instance.editingAvailable &&
+ useInstanceStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ dispatch, status }) {
return dispatch('fetchStatusSource', { id: status.id }).then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
visibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.includes('messages_delete'))
)
},
confirm: ({ getters }) => getters.mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ dispatch, status }) {
return dispatch('deleteStatus', { id: status.id })
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ state, status, router }) {
navigator.clipboard.writeText(
[
- state.instance.server,
+ useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } })
.href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
return useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js
index 6a6aed8628..6b2d1ed390 100644
--- a/src/components/sticker_picker/sticker_picker.js
+++ b/src/components/sticker_picker/sticker_picker.js
@@ -1,53 +1,54 @@
/* eslint-env browser */
+import { useInstanceStore } from 'src/stores/instance.js'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
const StickerPicker = {
components: {
TabSwitcher,
},
data() {
return {
meta: {
stickers: [],
},
path: '',
}
},
computed: {
pack() {
- return this.$store.state.instance.stickers || []
+ return useInstanceStore().stickers || []
},
},
methods: {
clear() {
this.meta = {
stickers: [],
}
},
pick(sticker, name) {
const store = this.$store
// TODO remove this workaround by finding a way to bypass reuploads
fetch(sticker).then((res) => {
res.blob().then((blob) => {
const file = new File([blob], name, { mimetype: 'image/png' })
const formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData }).then(
(fileData) => {
this.$emit('uploaded', fileData)
this.clear()
},
(error) => {
console.warn("Can't attach sticker")
console.warn(error)
this.$emit('upload-failed', 'default')
},
)
})
})
},
},
}
export default StickerPicker
diff --git a/src/components/still-image/still-image-emoji-popover.vue b/src/components/still-image/still-image-emoji-popover.vue
index f8ff89a6f1..b53234b2a5 100644
--- a/src/components/still-image/still-image-emoji-popover.vue
+++ b/src/components/still-image/still-image-emoji-popover.vue
@@ -1,211 +1,212 @@
<template>
<Popover
ref="emojiPopover"
trigger="click"
placement="top"
:bound-to="{ x: 'container' }"
:offset="{ y: 10 }"
@show="fetchEmojiPacksIfAdmin"
>
<template #trigger>
<StillImage v-bind="$attrs" />
</template>
<template #content>
<div class="emoji-popover">
<h3>{{ $attrs.title }}</h3>
<div class="emoji-popover-centered">
<StillImage
class="emoji"
v-bind="$attrs"
/>
</div>
<div v-if="isUserAdmin && !isLocal">
<button
class="button button-default btn emoji-popover-button"
type="button"
:disabled="packName == ''"
@click="copyToLocalPack"
>
{{ $t('admin_dash.emoji.copy_to_pack') }}
</button>
<SelectComponent
v-model="packName"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</div>
</div>
</template>
</Popover>
</template>
<script>
import Popover from 'components/popover/popover.vue'
import SelectComponent from 'components/select/select.vue'
import { assign } from 'lodash'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import StillImage from './still-image.vue'
export default {
components: { StillImage, Popover, SelectComponent },
props: {
shortcode: {
type: String,
required: true,
},
isLocal: {
type: Boolean,
required: true,
},
},
data() {
return {
knownLocalPacks: {},
packName: '',
}
},
computed: {
isUserAdmin() {
return this.$store.state.users.currentUser.rights.admin
},
},
methods: {
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error',
})
},
copyToLocalPack() {
this.$store.state.api.backendInteractor
.addNewEmojiFile({
packName: this.packName,
file: this.$attrs.src,
shortcode: this.shortcode,
filename: '',
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.copied_successfully',
messageArgs: [this.shortcode, this.packName],
level: 'success',
})
this.$refs.emojiPopover.hidePopover()
this.packName = ''
})
},
// Copied from emoji_tab.js
loadPacksPaginated(listFunction) {
const pageSize = 25
const allPacks = {}
return listFunction({
- instance: this.$store.state.instance.server,
+ instance: useInstanceStore().server,
page: 1,
pageSize: 0,
})
.then((data) => data.json())
.then((data) => {
if (data.error !== undefined) {
return Promise.reject(data.error)
}
let resultingPromise = Promise.resolve({})
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
resultingPromise = resultingPromise
.then(() =>
listFunction({
- instance: this.$store.state.instance.server,
+ instance: useInstanceStore().server,
page: i,
pageSize,
}),
)
.then((data) => data.json())
.then((pageData) => {
if (pageData.error !== undefined) {
return Promise.reject(pageData.error)
}
assign(allPacks, pageData.packs)
})
}
return resultingPromise
})
.then(() => allPacks)
.catch((data) => {
this.displayError(data)
})
},
fetchEmojiPacksIfAdmin() {
if (!this.isUserAdmin) return
this.loadPacksPaginated(
this.$store.state.api.backendInteractor.listEmojiPacks,
).then((allPacks) => {
// Sort by key
const sorted = Object.keys(allPacks)
.sort()
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = allPacks[key]
return acc
}, {})
this.knownLocalPacks = sorted
})
},
},
}
</script>
<style>
.emoji-popover {
margin: 0 0.5em 0.5em;
text-align: center;
.emoji {
width: 4.6em;
height: 4.6em;
}
.emoji-popover-centered {
display: flex;
align-items: center;
justify-content: center;
}
.emoji-popover-button {
width: 100%;
margin-top: 0.5em;
}
.Select {
width: 100%;
}
}
</style>
diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.js b/src/components/terms_of_service_panel/terms_of_service_panel.js
index e250ba5af4..0e6a075ca6 100644
--- a/src/components/terms_of_service_panel/terms_of_service_panel.js
+++ b/src/components/terms_of_service_panel/terms_of_service_panel.js
@@ -1,12 +1,14 @@
+import { useInstanceStore } from 'src/stores/instance.js'
+
const TermsOfServicePanel = {
computed: {
content() {
- return this.$store.state.instance.tos
+ return useInstanceStore().tos
},
embedded() {
- return this.$store.state.instance.embeddedToS
+ return useInstanceStore().embeddedToS
},
},
}
export default TermsOfServicePanel
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
index 97c4dbe35e..fe7539b878 100644
--- a/src/components/timeline_menu/timeline_menu.js
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -1,121 +1,123 @@
import { mapState } from 'vuex'
import { filterNavigation } from 'src/components/navigation/filter.js'
import { TIMELINES } from 'src/components/navigation/navigation.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useListsStore } from 'src/stores/lists'
import BookmarkFoldersMenuContent from '../bookmark_folders_menu/bookmark_folders_menu_content.vue'
import ListsMenuContent from '../lists_menu/lists_menu_content.vue'
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown)
// Route -> i18n key mapping, exported and not in the computed
// because nav panel benefits from the same information.
export const timelineNames = (supportsBookmarkFolders) => {
return {
friends: 'nav.home_timeline',
bookmarks: supportsBookmarkFolders ? 'nav.all_bookmarks' : 'nav.bookmarks',
dms: 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
quotes: 'nav.quotes',
bubble: 'nav.bubble',
}
}
const TimelineMenu = {
components: {
Popover,
NavigationEntry,
ListsMenuContent,
BookmarkFoldersMenuContent,
},
data() {
return {
isOpen: false,
}
},
created() {
if (timelineNames(this.bookmarkFolders)[this.$route.name]) {
useInterfaceStore().setLastTimeline(this.$route.name)
}
},
computed: {
useListsMenu() {
const route = this.$route.name
return route === 'lists-timeline'
},
useBookmarkFoldersMenu() {
const route = this.$route.name
return (
this.bookmarkFolders &&
(route === 'bookmark-folder' || route === 'bookmarks')
)
},
...mapState({
currentUser: (state) => state.users.currentUser,
- privateMode: (state) => state.instance.private,
- federating: (state) => state.instance.federating,
+ privateMode: (state) => useInstanceStore().private,
+ federating: (state) => useInstanceStore().federating,
bookmarkFolders: (state) =>
- state.instance.pleromaBookmarkFoldersAvailable,
- bubbleTimeline: (state) => state.instance.localBubbleInstances.length > 0,
+ useInstanceStore().pleromaBookmarkFoldersAvailable,
+ bubbleTimeline: (state) =>
+ useInstanceStore().localBubbleInstances.length > 0,
}),
timelinesList() {
return filterNavigation(
Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser,
supportsBookmarkFolders: this.bookmarkFolders,
supportsBubbleTimeline: this.bubbleTimeline,
},
)
},
},
methods: {
openMenu() {
// $nextTick is too fast, animation won't play back but
// instead starts in fully open position. Low values
// like 1-5 work on fast machines but not on mobile, 25
// seems like a good compromise that plays without significant
// added lag.
setTimeout(() => {
this.isOpen = true
}, 25)
},
blockOpen(event) {
// For the blank area inside the button element.
// Just setting @click.stop="" makes unintuitive behavior when
// menu is open and clicking on the blank area doesn't close it.
if (!this.isOpen) {
event.stopPropagation()
}
},
timelineName() {
const route = this.$route.name
if (route === 'tag-timeline') {
return '#' + this.$route.params.tag
}
if (route === 'lists-timeline') {
return useListsStore().findListTitle(this.$route.params.id)
}
if (route === 'bookmark-folder') {
return useBookmarkFoldersStore().findBookmarkFolderName(
this.$route.params.id,
)
}
const i18nkey = timelineNames(this.bookmarkFolders)[this.$route.name]
return i18nkey ? this.$t(i18nkey) : route
},
},
}
export default TimelineMenu
diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js
index a2cb9e5c18..e912d33eae 100644
--- a/src/components/update_notification/update_notification.js
+++ b/src/components/update_notification/update_notification.js
@@ -1,86 +1,87 @@
import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png'
import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png'
import Modal from 'src/components/modal/modal.vue'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
export const CURRENT_UPDATE_COUNTER = 1
const pleromaTan = '/static/pleromatan_apology.png'
const pleromaTanFox = '/static/pleromatan_apology_fox.png'
const UpdateNotification = {
data() {
return {
showingImage: false,
pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox,
showingMore: false,
}
},
components: {
Modal,
},
computed: {
pleromaTanStyles() {
const mask =
this.pleromaTanVariant === pleromaTan
? pleromaTanMask
: pleromaTanFoxMask
return {
'shape-outside': 'url(' + mask + ')',
}
},
shouldShow() {
return (
- !this.$store.state.instance.disableUpdateNotification &&
+ !useInstanceStore().disableUpdateNotification &&
this.$store.state.users.currentUser &&
useServerSideStorageStore().flagStorage.updateCounter <
CURRENT_UPDATE_COUNTER &&
!useServerSideStorageStore().prefsStorage.simple.dontShowUpdateNotifs
)
},
},
methods: {
toggleShow() {
this.showingMore = !this.showingMore
},
neverShowAgain() {
this.toggleShow()
useServerSideStorageStore().setFlag({
flag: 'updateCounter',
value: CURRENT_UPDATE_COUNTER,
})
useServerSideStorageStore().setPreference({
path: 'simple.dontShowUpdateNotifs',
value: true,
})
useServerSideStorageStore().pushServerSideStorage()
},
dismiss() {
useServerSideStorageStore().setFlag({
flag: 'updateCounter',
value: CURRENT_UPDATE_COUNTER,
})
useServerSideStorageStore().pushServerSideStorage()
},
},
mounted() {
this.contentHeightNoImage = this.$refs.animatedText.scrollHeight
// Workaround to get the text height only after mask loaded. A bit hacky.
const newImg = new Image()
newImg.onload = () => {
setTimeout(() => {
this.showingImage = true
}, 100)
}
newImg.src =
this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask
},
}
export default UpdateNotification
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index a8bd03d49a..f193f8eb2c 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -1,55 +1,56 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import StillImage from '../still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPeopleGroup, faRobot } from '@fortawesome/free-solid-svg-icons'
library.add(faRobot, faPeopleGroup)
const UserAvatar = {
props: {
// User object to show avatar of
user: {
required: true,
type: Object,
},
// Use less space and use alternative roundness
compact: {
required: false,
type: Boolean,
default: false,
},
// Show small icon indicating if account is a bot or group
showActorTypeIndicator: {
required: false,
type: Boolean,
default: false,
},
// Override avatar image URL, useful for profile editing
url: {
required: false,
type: String,
default: null,
},
},
data() {
return {
showPlaceholder: false,
- defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`,
+ defaultAvatar: `${useInstanceStore().server + useInstanceStore().defaultAvatar}`,
betterShadow: useInterfaceStore().browserSupport.cssFilter,
}
},
components: {
StillImage,
},
methods: {
imgSrc(src) {
return !src || this.showPlaceholder ? this.defaultAvatar : src
},
imageLoadError() {
this.showPlaceholder = true
},
},
}
export default UserAvatar
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 0786e5b359..4a973fdfa1 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,564 +1,559 @@
import isEqual from 'lodash/isEqual'
import merge from 'lodash/merge'
import ldUnescape from 'lodash/unescape'
import { mapGetters } from 'vuex'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import localeService from 'src/services/locale/locale.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import { usePostStatusStore } from 'src/stores/post_status'
import { useInterfaceStore } from '../../stores/interface'
import { useMediaViewerStore } from '../../stores/media_viewer'
import AccountActions from '../account_actions/account_actions.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import Select from '../select/select.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import UserNote from '../user_note/user_note.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBirthdayCake,
faClockRotateLeft,
faEdit,
faExpandAlt,
faExternalLinkAlt,
faRss,
faSave,
faSearchPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake,
faClockRotateLeft,
)
export default {
props: {
// Enables all the options for profile editing, used in settings -> profile tab
editable: {
required: false,
default: false,
type: Boolean,
},
// ID of user to show data of
userId: {
required: true,
type: String,
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default',
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false,
},
},
components: {
DialogModal,
UserAvatar,
Checkbox,
RemoteFollow,
ModerationTools,
AccountActions,
ProgressButton,
FollowButton,
Select,
RichContent,
UserLink,
UserNote,
UserTimedFilterModal,
ColorInput,
EmojiInput,
ImageCropper,
},
data() {
const user = this.$store.getters.findUser(this.userId)
return {
followRequestInProgress: false,
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: ldUnescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
editingFields: false,
}
},
created() {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
somethingToSave() {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== ldUnescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (
!isEqual(
this.newFields,
this.user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
)
)
return true
return false
},
groupActorAvailable() {
- return this.$store.state.instance.groupActorAvailable
+ return useInstanceStore().groupActorAvailable
},
availableActorTypes() {
return this.groupActorAvailable
? ['Person', 'Service', 'Group']
: ['Person', 'Service']
},
user() {
return this.$store.getters.findUser(this.userId)
},
role() {
return this.user.role
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
isOtherUser() {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl() {
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn() {
return this.$store.state.users.currentUser
},
dailyAvg() {
const days = Math.ceil(
(new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000),
)
return Math.round(this.user.statuses_count / days)
},
emoji() {
- return this.$store.state.instance.customEmoji.map((e) => ({
+ return useInstanceStore().customEmoji.map((e) => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl,
}))
},
userHighlightType: {
get() {
const data =
this.$store.getters.mergedConfig.highlight[this.user.screen_name]
return (data && data.type) || 'disabled'
},
set(type) {
const data =
this.$store.getters.mergedConfig.highlight[this.user.screen_name]
if (type !== 'disabled') {
this.$store.dispatch('setHighlight', {
user: this.user.screen_name,
color: (data && data.color) || '#FFFFFF',
type,
})
} else {
this.$store.dispatch('setHighlight', {
user: this.user.screen_name,
color: undefined,
})
}
},
...mapGetters(['mergedConfig']),
},
userHighlightColor: {
get() {
const data =
this.$store.getters.mergedConfig.highlight[this.user.screen_name]
return data && data.color
},
set(color) {
this.$store.dispatch('setHighlight', {
user: this.user.screen_name,
color,
})
},
},
visibleRole() {
if (!this.newShowRole) {
return
}
const rights = this.user.rights
if (!rights) {
return
}
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount() {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount() {
return this.isOtherUser && this.user.hide_followers_count
},
showModerationMenu() {
const privileges = this.loggedIn.privileges
return (
this.loggedIn.role === 'admin' ||
privileges.includes('users_manage_activation_state') ||
privileges.includes('users_delete') ||
privileges.includes('users_manage_tags')
)
},
hasNote() {
return this.relationship.note
},
supportsNote() {
return 'note' in this.relationship
},
muteExpiryAvailable() {
return Object.hasOwn(this.user, 'mute_expires_at')
},
muteExpiry() {
return this.user.mute_expires_at === false
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
formattedBirthday() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.user.birthday)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
// Editable stuff
avatarImgSrc() {
const currentUrl =
this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl =
this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return this.newAvatar === null ? currentUrl : newUrl
},
bannerImgSrc() {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl =
this.newBanner === null ? this.defaultBanner : this.newBanner
return this.newBanner === null ? currentUrl : newUrl
},
defaultAvatar() {
- return (
- this.$store.state.instance.server +
- this.$store.state.instance.defaultAvatar
- )
+ return useInstanceStore().server + useInstanceStore().defaultAvatar
},
defaultBanner() {
- return (
- this.$store.state.instance.server +
- this.$store.state.instance.defaultBanner
- )
+ return useInstanceStore().server + useInstanceStore().defaultBanner
},
isDefaultAvatar() {
- const baseAvatar = this.$store.state.instance.defaultAvatar
+ const baseAvatar = useInstanceStore().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
+ const baseBanner = useInstanceStore().defaultBanner
return (
!this.$store.state.users.currentUser.cover_photo ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
)
},
fieldsLimits() {
- return this.$store.state.instance.fieldsLimits
+ return useInstanceStore().fieldsLimits
},
maxFields() {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
- ...this.$store.state.instance.customEmoji,
+ ...useInstanceStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
- ...this.$store.state.instance.customEmoji,
+ ...useInstanceStore().customEmoji,
],
})
},
...mapGetters(['mergedConfig']),
},
methods: {
muteUser() {
this.$refs.timedMuteDialog.optionallyPrompt()
},
unmuteUser() {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser() {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser() {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
zoomAvatar() {
const attachment = {
url: this.user.profile_image_url_original,
mimetype: 'image',
}
useMediaViewerStore().setMedia([attachment])
useMediaViewerStore().setCurrentMedia(attachment)
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: true,
repliedUser: this.user,
})
},
onAvatarClickHandler(e) {
if (this.onAvatarClick) {
e.preventDefault()
this.onAvatarClick()
}
},
// Editable stuff
changeAvatar() {
this.editImage = 'avatar'
},
changeBanner() {
this.editImage = 'banner'
},
submitImage({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) =>
this.submitImage({ canvas: null, file: data }),
)
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage() {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField() {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField(index) {
this.newFields.splice(index, 1)
},
propsToNative(props) {
return propsToNative(props)
},
cancelImageText() {
return
},
resetState() {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newBio = ldUnescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map((field) => ({
name: field.name,
value: field.value,
}))
},
updateProfile() {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter((el) => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.newActorType) {
params.actor_type = this.newActorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError(error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error',
})
},
},
}
diff --git a/src/components/user_link/user_link.vue b/src/components/user_link/user_link.vue
index 059508f495..4bb1822c96 100644
--- a/src/components/user_link/user_link.vue
+++ b/src/components/user_link/user_link.vue
@@ -1,43 +1,44 @@
<template>
<div class="user-profile-link">
<router-link
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
<slot>
{{ at ? '@' : '' }}{{ user.screen_name_ui }}<UnicodeDomainIndicator
:user="user"
/>
</slot>
</router-link>
</div>
</template>
<script>
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
const UserLink = {
props: {
user: Object,
at: {
type: Boolean,
default: true,
},
},
components: {
UnicodeDomainIndicator,
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
},
}
export default UserLink
</script>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 73fc81a9e1..767ae1d5b6 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,202 +1,203 @@
import get from 'lodash/get'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { useInstanceStore } from 'src/stores/instance.js'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import Conversation from '../conversation/conversation.vue'
import FollowCard from '../follow_card/follow_card.vue'
import List from '../list/list.vue'
import Timeline from '../timeline/timeline.vue'
import UserCard from '../user_card/user_card.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const FollowerList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'followerIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const FriendList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFriends', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'friendIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const defaultTabKey = 'statuses'
const UserProfile = {
data() {
return {
error: false,
userId: null,
tab: defaultTabKey,
footerRef: null,
}
},
created() {
const routeParams = this.$route.params
this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey)
},
unmounted() {
this.stopFetching()
},
computed: {
timeline() {
return this.$store.state.statuses.timelines.user
},
favorites() {
return this.$store.state.statuses.timelines.favorites
},
media() {
return this.$store.state.statuses.timelines.media
},
isUs() {
return (
this.userId &&
this.$store.state.users.currentUser.id &&
this.userId === this.$store.state.users.currentUser.id
)
},
user() {
return this.$store.getters.findUser(this.userId)
},
isExternal() {
return this.$route.name === 'external-user-profile'
},
followsTabVisible() {
return this.isUs || !this.user.hide_follows
},
followersTabVisible() {
return this.isUs || !this.user.hide_followers
},
favoritesTabVisible() {
return (
this.isUs ||
- (this.$store.state.instance.pleromaPublicFavouritesAvailable &&
+ (useInstanceStore().pleromaPublicFavouritesAvailable &&
!this.user.hide_favorites)
)
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
load(userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {
// Clear timeline only if load another user's profile
if (userId !== this.$store.state.statuses.timelines[timeline].userId) {
this.$store.commit('clearTimeline', { timeline })
}
this.$store.dispatch('startFetchingTimeline', { timeline, userId })
}
const loadById = (userId) => {
this.userId = userId
startFetchingTimeline('user', userId)
startFetchingTimeline('media', userId)
if (this.isUs) {
startFetchingTimeline('favorites')
} else if (!this.user.hide_favorites) {
startFetchingTimeline('favorites', userId)
}
// Fetch all pinned statuses immediately
this.$store.dispatch('fetchPinnedStatuses', userId)
}
// Reset view
this.userId = null
this.error = false
const maybeId = userNameOrId.id
const maybeName = userNameOrId.name
// Check if user data is already loaded in store
const user = maybeId
? this.$store.getters.findUser(maybeId)
: this.$store.getters.findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
;(maybeId
? this.$store.dispatch('fetchUser', maybeId)
: this.$store.dispatch('fetchUserByName', maybeName)
)
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') {
// Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
}
},
stopFetching() {
this.$store.dispatch('stopFetchingTimeline', 'user')
this.$store.dispatch('stopFetchingTimeline', 'favorites')
this.$store.dispatch('stopFetchingTimeline', 'media')
},
switchUser(userNameOrId) {
this.stopFetching()
this.load(userNameOrId)
},
onTabSwitch(tab) {
this.tab = tab
this.$router.replace({ query: { tab } })
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
},
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
this.tab = newVal.tab || defaultTabKey
},
},
components: {
UserCard,
Timeline,
FollowerList,
FriendList,
FollowCard,
TabSwitcher,
Conversation,
RichContent,
},
}
export default UserProfile
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
index 5dbb1c3d1f..ce01ad8158 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.js
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -1,80 +1,81 @@
import { shuffle } from 'lodash'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { useInstanceStore } from 'src/stores/instance.js'
import apiService from '../../services/api/api.service.js'
function showWhoToFollow(panel, reply) {
const shuffled = shuffle(reply)
panel.usersToFollow.forEach((toFollow, index) => {
const user = shuffled[index]
- const img = user.avatar || this.$store.state.instance.defaultAvatar
+ const img = user.avatar || useInstanceStore().defaultAvatar
const name = user.acct
toFollow.img = img
toFollow.name = name
panel.$store.state.api.backendInteractor
.fetchUser({ id: name })
.then((externalUser) => {
if (!externalUser.error) {
panel.$store.commit('addNewUsers', [externalUser])
toFollow.id = externalUser.id
}
})
})
}
function getWhoToFollow(panel) {
const credentials = panel.$store.state.users.currentUser.credentials
if (credentials) {
panel.usersToFollow.forEach((toFollow) => {
toFollow.name = 'Loading...'
})
apiService.suggestions({ credentials }).then((reply) => {
showWhoToFollow(panel, reply)
})
}
}
const WhoToFollowPanel = {
data: () => ({
usersToFollow: [],
}),
computed: {
user: function () {
return this.$store.state.users.currentUser.screen_name
},
suggestionsEnabled() {
- return this.$store.state.instance.suggestionsEnabled
+ return useInstanceStore().suggestionsEnabled
},
},
methods: {
userProfileLink(id, name) {
return generateProfileLink(
id,
name,
- this.$store.state.instance.restrictedNicknames,
+ useInstanceStore().restrictedNicknames,
)
},
},
watch: {
user: function () {
if (this.suggestionsEnabled) {
getWhoToFollow(this)
}
},
},
mounted: function () {
this.usersToFollow = new Array(3).fill().map(() => ({
- img: this.$store.state.instance.defaultAvatar,
+ img: useInstanceStore().defaultAvatar,
name: '',
id: 0,
}))
if (this.suggestionsEnabled) {
getWhoToFollow(this)
}
},
}
export default WhoToFollowPanel
diff --git a/src/lib/push_notifications_plugin.js b/src/lib/push_notifications_plugin.js
index 2cc55c3bbf..0bf69baa0e 100644
--- a/src/lib/push_notifications_plugin.js
+++ b/src/lib/push_notifications_plugin.js
@@ -1,38 +1,39 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
export default (store) => {
store.subscribe((mutation, state) => {
- const vapidPublicKey = state.instance.vapidPublicKey
+ const vapidPublicKey = useInstanceStore().vapidPublicKey
const webPushNotification = state.config.webPushNotifications
const permission = useInterfaceStore().notificationPermission === 'granted'
const user = state.users.currentUser
const isUserMutation = mutation.type === 'setCurrentUser'
const isVapidMutation =
mutation.type === 'setInstanceOption' &&
mutation.payload.name === 'vapidPublicKey'
const isPermMutation =
mutation.type === 'setNotificationPermission' &&
mutation.payload === 'granted'
const isUserConfigMutation =
mutation.type === 'setOption' &&
mutation.payload.name === 'webPushNotifications'
const isVisibilityMutation =
mutation.type === 'setOption' &&
mutation.payload.name === 'notificationVisibility'
if (
isUserMutation ||
isVapidMutation ||
isPermMutation ||
isUserConfigMutation ||
isVisibilityMutation
) {
if (user && vapidPublicKey && permission && webPushNotification) {
return store.dispatch('registerPushNotifications')
} else if (isUserConfigMutation && !webPushNotification) {
return store.dispatch('unregisterPushNotifications')
}
}
})
}
diff --git a/src/modules/api.js b/src/modules/api.js
index cb8b72e100..3ef78da8b3 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,362 +1,363 @@
import { Socket } from 'phoenix'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useShoutStore } from 'src/stores/shout.js'
import { WSConnectionStatus } from '../services/api/api.service.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
backendInteractor: backendInteractorService(),
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [],
},
getters: {
followRequestCount: (state) => state.followRequests.length,
},
mutations: {
setBackendInteractor(state, backendInteractor) {
state.backendInteractor = backendInteractor
},
addFetcher(state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher(state, { fetcherName }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken(state, token) {
state.wsToken = token
},
setSocket(state, socket) {
state.socket = socket
},
setFollowRequests(state, value) {
state.followRequests = value
},
setMastoUserSocketStatus(state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier(state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier(state) {
state.retryMultiplier = 1
},
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets(store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![WebSocket.CLOSED, WebSocket.CLOSING].includes(
state.mastoUserSocket.getState(),
)
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets(store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
// MastoAPI 'User' sockets
startMastoUserSocket(store) {
return new Promise((resolve, reject) => {
try {
const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({
store,
})
state.mastoUserSocket.addEventListener(
'pleroma:authenticated',
() => {
state.mastoUserSocket.subscribe('user')
},
)
state.mastoUserSocket.addEventListener(
'message',
({ detail: message }) => {
if (!message) return // pings
if (message.event === 'notification') {
dispatch('addNewNotifications', {
notifications: [message.notification],
older: false,
})
} else if (message.event === 'update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends',
})
} else if (message.event === 'status.update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately:
message.status.id in timelineData.visibleStatusesObject,
timeline: 'friends',
})
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage],
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
},
)
state.mastoUserSocket.addEventListener('open', () => {
// Do not show notification when we just opened up the page
if (
state.mastoUserSocketStatus !==
WSConnectionStatus.STARTING_INITIAL
) {
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
// Stop polling if we were errored or disabled
if (
new Set([
WSConnectionStatus.ERROR,
WSConnectionStatus.DISABLED,
]).has(state.mastoUserSocketStatus)
) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener(
'error',
({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
// TODO is this needed?
dispatch('clearOpenedChats')
},
)
state.mastoUserSocket.addEventListener(
'close',
({ detail: closeEvent }) => {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
dispatch('startMastoUserSocket')
}, retryTimeout(state.retryMultiplier))
commit('incrementRetryMultiplier')
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
dispatch('clearOpenedChats')
},
)
resolve()
} catch (e) {
reject(e)
}
})
},
stopMastoUserSocket({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
// Timelines
startFetchingTimeline(
store,
{
timeline = 'friends',
tag = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
},
) {
if (
timeline === 'favourites' &&
- !store.rootState.instance.pleromaPublicFavouritesAvailable
+ !useInstanceStore().pleromaPublicFavouritesAvailable
)
return
if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetchingTimeline({
timeline,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
stopFetchingTimeline(store, timeline) {
const fetcher = store.state.fetchers[timeline]
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
fetchTimeline(store, { timeline, ...rest }) {
store.state.backendInteractor.fetchTimeline({
store,
timeline,
...rest,
})
},
// Notifications
startFetchingNotifications(store) {
if (store.state.fetchers.notifications) return
const fetcher = store.state.backendInteractor.startFetchingNotifications({
store,
})
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications(store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
fetchNotifications(store, { ...rest }) {
store.state.backendInteractor.fetchNotifications({
store,
...rest,
})
},
// Follow requests
startFetchingFollowRequests(store) {
if (store.state.fetchers.followRequests) return
const fetcher = store.state.backendInteractor.startFetchingFollowRequests(
{ store },
)
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests(store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
removeFollowRequest(store, request) {
const requests = store.state.followRequests.filter((it) => it !== request)
store.commit('setFollowRequests', requests)
},
// Lists
startFetchingLists(store) {
if (store.state.fetchers.lists) return
const fetcher = store.state.backendInteractor.startFetchingLists({
store,
})
store.commit('addFetcher', { fetcherName: 'lists', fetcher })
},
stopFetchingLists(store) {
const fetcher = store.state.fetchers.lists
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'lists', fetcher })
},
// Bookmark folders
startFetchingBookmarkFolders(store) {
if (store.state.fetchers.bookmarkFolders) return
- if (!store.rootState.instance.pleromaBookmarkFoldersAvailable) return
+ if (!useInstanceStore().pleromaBookmarkFoldersAvailable) return
const fetcher =
store.state.backendInteractor.startFetchingBookmarkFolders({ store })
store.commit('addFetcher', { fetcherName: 'bookmarkFolders', fetcher })
},
stopFetchingBookmarkFolders(store) {
const fetcher = store.state.fetchers.bookmarkFolders
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'bookmarkFolders', fetcher })
},
// Pleroma websocket
setWsToken(store, token) {
store.commit('setWsToken', token)
},
initializeSocket({ commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (
- rootState.instance.shoutAvailable &&
+ useInstanceStore().shoutAvailable &&
typeof token !== 'undefined' &&
state.socket === null
) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
useShoutStore().initializeShout(socket)
}
},
disconnectFromSocket({ commit, state }) {
state.socket && state.socket.disconnect()
commit('setSocket', null)
},
},
}
export default api
diff --git a/src/modules/instance.js b/src/modules/instance.js
deleted file mode 100644
index 0f7009bc7d..0000000000
--- a/src/modules/instance.js
+++ /dev/null
@@ -1,376 +0,0 @@
-// See build/emojis_plugin for more details
-
-import { useInterfaceStore } from 'src/stores/interface.js'
-import { ensureFinalFallback } from '../i18n/languages.js'
-import apiService from '../services/api/api.service.js'
-import { instanceDefaultProperties } from './config.js'
-import {
- instanceDefaultConfig,
- staticOrApiConfigDefault,
-} from './default_config_state.js'
-
-import { annotationsLoader } from 'virtual:pleroma-fe/emoji-annotations'
-
-const SORTED_EMOJI_GROUP_IDS = [
- 'smileys-and-emotion',
- 'people-and-body',
- 'animals-and-nature',
- 'food-and-drink',
- 'travel-and-places',
- 'activities',
- 'objects',
- 'symbols',
- 'flags',
-]
-
-const REGIONAL_INDICATORS = (() => {
- const start = 0x1f1e6
- const end = 0x1f1ff
- const A = 'A'.codePointAt(0)
- const res = new Array(end - start + 1)
- for (let i = start; i <= end; ++i) {
- const letter = String.fromCodePoint(A + i - start)
- res[i - start] = {
- replacement: String.fromCodePoint(i),
- imageUrl: false,
- displayText: 'regional_indicator_' + letter,
- displayTextI18n: {
- key: 'emoji.regional_indicator',
- args: { letter },
- },
- }
- }
- return res
-})()
-
-const REMOTE_INTERACTION_URL = '/main/ostatus'
-
-const defaultState = {
- // Stuff from apiConfig
- name: 'Pleroma FE',
- registrationOpen: true,
- server: 'http://localhost:4040/',
- textlimit: 5000,
- themesIndex: undefined,
- stylesIndex: undefined,
- palettesIndex: undefined,
- themeData: undefined, // used for theme editor v2
- vapidPublicKey: undefined,
-
- // Stuff from static/config.json
- loginMethod: 'password',
- disableUpdateNotification: false,
-
- fontsOverride: {},
-
- // Instance-wide configurations that should not be changed by individual users
- ...staticOrApiConfigDefault,
- // Instance admins can override default settings for the whole instance
- ...instanceDefaultConfig,
-
- // Nasty stuff
- customEmoji: [],
- customEmojiFetched: false,
- emoji: {},
- emojiFetched: false,
- unicodeEmojiAnnotations: {},
- pleromaExtensionsAvailable: true,
- postFormats: [],
- restrictedNicknames: [],
- safeDM: true,
- knownDomains: [],
- birthdayRequired: false,
- birthdayMinAge: 0,
-
- // Feature-set, apparently, not everything here is reported...
- shoutAvailable: false,
- pleromaChatMessagesAvailable: false,
- pleromaCustomEmojiReactionsAvailable: false,
- pleromaBookmarkFoldersAvailable: false,
- pleromaPublicFavouritesAvailable: true,
- statusNotificationTypeAvailable: true,
- gopherAvailable: false,
- mediaProxyAvailable: false,
- suggestionsEnabled: false,
- suggestionsWeb: '',
- quotingAvailable: false,
- groupActorAvailable: false,
- blockExpiration: false,
- localBubbleInstances: [], // Akkoma
-
- // Html stuff
- instanceSpecificPanelContent: '',
- tos: '',
-
- // Version Information
- backendVersion: '',
- backendRepository: '',
- frontendVersion: '',
-
- pollsAvailable: false,
- pollLimits: {
- max_options: 4,
- max_option_chars: 255,
- min_expiration: 60,
- max_expiration: 60 * 60 * 24,
- },
-}
-
-const loadAnnotations = (lang) => {
- return annotationsLoader[lang]().then((k) => k.default)
-}
-
-const injectAnnotations = (emoji, annotations) => {
- const availableLangs = Object.keys(annotations)
-
- return {
- ...emoji,
- annotations: availableLangs.reduce((acc, cur) => {
- acc[cur] = annotations[cur][emoji.replacement]
- return acc
- }, {}),
- }
-}
-
-const injectRegionalIndicators = (groups) => {
- groups.symbols.push(...REGIONAL_INDICATORS)
- return groups
-}
-
-const instance = {
- state: defaultState,
- mutations: {
- setInstanceOption(state, { name, value }) {
- if (typeof value !== 'undefined') {
- state[name] = value
- }
- },
- setKnownDomains(state, domains) {
- state.knownDomains = domains
- },
- setUnicodeEmojiAnnotations(state, { lang, annotations }) {
- state.unicodeEmojiAnnotations[lang] = annotations
- },
- },
- getters: {
- instanceDefaultConfig(state) {
- return instanceDefaultProperties
- .map((key) => [key, state[key]])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
- },
- groupedCustomEmojis(state) {
- const packsOf = (emoji) => {
- const packs = emoji.tags
- .filter((k) => k.startsWith('pack:'))
- .map((k) => {
- const packName = k.slice(5) // remove 'pack:' prefix
- return {
- id: `custom-${packName}`,
- text: packName,
- }
- })
-
- if (!packs.length) {
- return [
- {
- id: 'unpacked',
- },
- ]
- } else {
- return packs
- }
- }
-
- return state.customEmoji.reduce((res, emoji) => {
- packsOf(emoji).forEach(({ id: packId, text: packName }) => {
- if (!res[packId]) {
- res[packId] = {
- id: packId,
- text: packName,
- image: emoji.imageUrl,
- emojis: [],
- }
- }
- res[packId].emojis.push(emoji)
- })
- return res
- }, {})
- },
- standardEmojiList(state) {
- return SORTED_EMOJI_GROUP_IDS.map((groupId) =>
- (state.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, state.unicodeEmojiAnnotations),
- ),
- ).reduce((a, b) => a.concat(b), [])
- },
- standardEmojiGroupList(state) {
- return SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
- id: groupId,
- emojis: (state.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, state.unicodeEmojiAnnotations),
- ),
- }))
- },
- instanceDomain(state) {
- return new URL(state.server).hostname
- },
- remoteInteractionLink(state) {
- const server = state.server.endsWith('/')
- ? state.server.slice(0, -1)
- : state.server
- const link = server + REMOTE_INTERACTION_URL
-
- return ({ statusId, nickname }) => {
- if (statusId) {
- return `${link}?status_id=${statusId}`
- } else {
- return `${link}?nickname=${nickname}`
- }
- }
- },
- },
- actions: {
- setInstanceOption({ commit, dispatch }, { name, value }) {
- commit('setInstanceOption', { name, value })
- switch (name) {
- case 'name':
- useInterfaceStore().setPageTitle()
- break
- case 'shoutAvailable':
- if (value) {
- dispatch('initializeSocket')
- }
- break
- }
- },
- async getStaticEmoji({ commit }) {
- try {
- const values = (await import('/src/assets/emoji.json')).default
-
- const emoji = Object.keys(values).reduce((res, groupId) => {
- res[groupId] = values[groupId].map((e) => ({
- displayText: e.slug,
- imageUrl: false,
- replacement: e.emoji,
- }))
- return res
- }, {})
- commit('setInstanceOption', {
- name: 'emoji',
- value: injectRegionalIndicators(emoji),
- })
- } catch (e) {
- console.warn("Can't load static emoji\n", e)
- }
- },
-
- loadUnicodeEmojiData({ commit, state }, language) {
- const langList = ensureFinalFallback(language)
-
- return Promise.all(
- langList.map(async (lang) => {
- if (!state.unicodeEmojiAnnotations[lang]) {
- try {
- const annotations = await loadAnnotations(lang)
- commit('setUnicodeEmojiAnnotations', { lang, annotations })
- } catch (e) {
- console.warn(
- `Error loading unicode emoji annotations for ${lang}: `,
- e,
- )
- // ignore
- }
- }
- }),
- )
- },
-
- async getCustomEmoji({ commit, state }) {
- try {
- let res = await window.fetch('/api/v1/pleroma/emoji')
- if (!res.ok) {
- res = await window.fetch('/api/pleroma/emoji.json')
- }
- if (res.ok) {
- const result = await res.json()
- const values = Array.isArray(result)
- ? Object.assign({}, ...result)
- : result
- const caseInsensitiveStrCmp = (a, b) => {
- const la = a.toLowerCase()
- const lb = b.toLowerCase()
- return la > lb ? 1 : la < lb ? -1 : 0
- }
- const noPackLast = (a, b) => {
- const aNull = a === ''
- const bNull = b === ''
- if (aNull === bNull) {
- return 0
- } else if (aNull && !bNull) {
- return 1
- } else {
- return -1
- }
- }
- const byPackThenByName = (a, b) => {
- const packOf = (emoji) =>
- (emoji.tags.filter((k) => k.startsWith('pack:'))[0] || '').slice(
- 5,
- )
- const packOfA = packOf(a)
- const packOfB = packOf(b)
- return (
- noPackLast(packOfA, packOfB) ||
- caseInsensitiveStrCmp(packOfA, packOfB) ||
- caseInsensitiveStrCmp(a.displayText, b.displayText)
- )
- }
-
- const emoji = Object.entries(values)
- .map(([key, value]) => {
- const imageUrl = value.image_url
- return {
- displayText: key,
- imageUrl: imageUrl ? state.server + imageUrl : value,
- tags: imageUrl
- ? value.tags.sort((a, b) => (a > b ? 1 : 0))
- : ['utf'],
- replacement: `:${key}: `,
- }
- // Technically could use tags but those are kinda useless right now,
- // should have been "pack" field, that would be more useful
- })
- .sort(byPackThenByName)
- commit('setInstanceOption', { name: 'customEmoji', value: emoji })
- } else {
- throw res
- }
- } catch (e) {
- console.warn("Can't load custom emojis\n", e)
- }
- },
- fetchEmoji({ dispatch, state }) {
- if (!state.customEmojiFetched) {
- state.customEmojiFetched = true
- dispatch('getCustomEmoji')
- }
- if (!state.emojiFetched) {
- state.emojiFetched = true
- dispatch('getStaticEmoji')
- }
- },
-
- async getKnownDomains({ commit, rootState }) {
- try {
- const result = await apiService.fetchKnownDomains({
- credentials: rootState.users.currentUser.credentials,
- })
- commit('setKnownDomains', result)
- } catch (e) {
- console.warn("Can't load known domains\n", e)
- }
- },
- },
-}
-
-export default instance
diff --git a/src/modules/users.js b/src/modules/users.js
index 4e45d420f8..6b2a26ae50 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,839 +1,839 @@
import {
compact,
concat,
each,
isArray,
last,
map,
mergeWith,
uniq,
} from 'lodash'
import { declarations } from 'src/modules/config_declaration'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useServerSideStorageStore } from 'src/stores/serverSideStorage'
import apiService from '../services/api/api.service.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) {
return false
}
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const mergeArrayLength = (oldValue, newValue) => {
if (isArray(oldValue) && isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default')
return Notification.requestPermission()
return Promise.resolve(Notification.permission)
}
const blockUser = (store, args) => {
const id = args.id
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addBlockId', id)
return store.rootState.api.backendInteractor
.blockUser({ id, expiresIn })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addBlockId', id)
store.commit('removeStatus', { timeline: 'friends', userId: id })
store.commit('removeStatus', { timeline: 'public', userId: id })
store.commit('removeStatus', {
timeline: 'publicAndExternal',
userId: id,
})
})
}
const unblockUser = (store, id) => {
return store.rootState.api.backendInteractor
.unblockUser({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const removeUserFromFollowers = (store, id) => {
return store.rootState.api.backendInteractor
.removeUserFromFollowers({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const editUserNote = (store, { id, comment }) => {
return store.rootState.api.backendInteractor
.editUserNote({ id, comment })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteUser = (store, args) => {
const id = typeof args === 'object' ? args.id : args
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addMuteId', id)
return store.rootState.api.backendInteractor
.muteUser({ id, expiresIn })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addMuteId', id)
})
}
const unmuteUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.muting = false
store.commit('updateUserRelationship', [predictedRelationship])
return store.rootState.api.backendInteractor
.unmuteUser({ id })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const hideReblogs = (store, userId) => {
return store.rootState.api.backendInteractor
.followUser({ id: userId, reblogs: false })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
})
}
const showReblogs = (store, userId) => {
return store.rootState.api.backendInteractor
.followUser({ id: userId, reblogs: true })
.then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteDomain = (store, domain) => {
return store.rootState.api.backendInteractor
.muteDomain({ domain })
.then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return store.rootState.api.backendInteractor
.unmuteDomain({ domain })
.then(() => store.commit('removeDomainMute', domain))
}
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.concat([tag])
user.tags = newTags
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.filter((t) => t !== tag)
user.tags = newTags
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
updateActivationStatus(state, { user: { id }, deactivated }) {
const user = state.usersObject[id]
user.deactivated = deactivated
},
setCurrentUser(state, user) {
state.lastLoginName = user.screen_name
state.currentUser = mergeWith(
state.currentUser || {},
user,
mergeArrayLength,
)
},
clearCurrentUser(state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin(state) {
state.loggingIn = true
},
endLogin(state) {
state.loggingIn = false
},
saveFriendIds(state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = uniq(concat(user.friendIds || [], friendIds))
},
saveFollowerIds(state, { id, followerIds }) {
const user = state.usersObject[id]
user.followerIds = uniq(concat(user.followerIds || [], followerIds))
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.friendIds = []
}
},
clearFollowers(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.followerIds = []
}
},
addNewUsers(state, users) {
each(users, (user) => {
if (user.relationship) {
state.relationships[user.relationship.id] = user.relationship
}
const res = mergeOrAdd(state.users, state.usersObject, user)
const item = res.item
if (res.new && item.screen_name && !item.screen_name.includes('@')) {
state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
})
},
updateUserRelationship(state, relationships) {
relationships.forEach((relationship) => {
state.relationships[relationship.id] = relationship
})
},
updateUserInLists(state, { id, inLists }) {
state.usersObject[id].inLists = inLists
},
saveBlockIds(state, blockIds) {
state.currentUser.blockIds = blockIds
},
addBlockId(state, blockId) {
if (state.currentUser.blockIds.indexOf(blockId) === -1) {
state.currentUser.blockIds.push(blockId)
}
},
setBlockIdsMaxId(state, blockIdsMaxId) {
state.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(state, muteIds) {
state.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(state, muteIdsMaxId) {
state.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(state, muteId) {
if (state.currentUser.muteIds.indexOf(muteId) === -1) {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes(state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute(state, domain) {
if (state.currentUser.domainMutes.indexOf(domain) === -1) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute(state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser(state, status) {
const user = state.usersObject[status.user.id]
user.pinnedStatusIds = user.pinnedStatusIds || []
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus(state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification(state, notification) {
if (notification.type !== 'follow') {
notification.action.user = state.usersObject[notification.action.user.id]
}
notification.from_profile = state.usersObject[notification.from_profile.id]
},
setColor(state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
user.highlight = highlighted
},
signUpPending(state) {
state.signUpPending = true
state.signUpErrors = []
state.signUpNotice = {}
},
signUpSuccess(state) {
state.signUpPending = false
},
signUpFailure(state, errors) {
state.signUpPending = false
state.signUpErrors = errors
state.signUpNotice = {}
},
signUpNotice(state, notice) {
state.signUpPending = false
state.signUpErrors = []
state.signUpNotice = notice
},
}
export const getters = {
findUser: (state) => (query) => {
return state.usersObject[query]
},
findUserByName: (state) => (query) => {
return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: (state) => (query) => {
return state.users.find(
(u) =>
u.statusnet_profile_url &&
u.statusnet_profile_url.toLowerCase() === query.toLowerCase(),
)
},
relationship: (state) => (id) => {
const rel = id && state.relationships[id]
return rel || { id, loading: true }
},
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
signUpNotice: {},
relationships: {},
}
const users = {
state: defaultState,
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) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserByName(store, name) {
return store.rootState.api.backendInteractor
.fetchUserByName({ name })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship(store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor
.fetchUserRelationship({ id })
.then((relationships) =>
store.commit('updateUserRelationship', relationships),
)
}
},
fetchUserInLists(store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor
.fetchUserInLists({ id })
.then((inLists) => store.commit('updateUserInLists', { id, inLists }))
}
},
fetchBlocks(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.blockIdsMaxId
return store.rootState.api.backendInteractor
.fetchBlocks({ maxId })
.then((blocks) => {
if (reset) {
store.commit('saveBlockIds', map(blocks, 'id'))
} else {
map(blocks, 'id').map((id) => store.commit('addBlockId', id))
}
if (blocks.length) {
store.commit('setBlockIdsMaxId', last(blocks).id)
}
store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser(store, data) {
return blockUser(store, data)
},
unblockUser(store, data) {
return unblockUser(store, data)
},
removeUserFromFollowers(store, id) {
return removeUserFromFollowers(store, id)
},
blockUsers(store, data = []) {
return Promise.all(data.map((d) => blockUser(store, d)))
},
unblockUsers(store, data = []) {
return Promise.all(data.map((d) => unblockUser(store, d)))
},
editUserNote(store, args) {
return editUserNote(store, args)
},
fetchMutes(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.muteIdsMaxId
return store.rootState.api.backendInteractor
.fetchMutes({ maxId })
.then((mutes) => {
if (reset) {
store.commit('saveMuteIds', map(mutes, 'id'))
} else {
map(mutes, 'id').map((id) => store.commit('addMuteId', id))
}
if (mutes.length) {
store.commit('setMuteIdsMaxId', last(mutes).id)
}
store.commit('addNewUsers', mutes)
return mutes
})
},
muteUser(store, data) {
return muteUser(store, data)
},
unmuteUser(store, id) {
return unmuteUser(store, id)
},
hideReblogs(store, id) {
return hideReblogs(store, id)
},
showReblogs(store, id) {
return showReblogs(store, id)
},
muteUsers(store, data = []) {
return Promise.all(data.map((d) => muteUser(store, d)))
},
unmuteUsers(store, ids = []) {
return Promise.all(ids.map((d) => unmuteUser(store, d)))
},
fetchDomainMutes(store) {
return store.rootState.api.backendInteractor
.fetchDomainMutes()
.then((domainMutes) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain(store, domain) {
return muteDomain(store, domain)
},
unmuteDomain(store, domain) {
return unmuteDomain(store, domain)
},
muteDomains(store, domains = []) {
return Promise.all(domains.map((domain) => muteDomain(store, domain)))
},
unmuteDomains(store, domain = []) {
return Promise.all(domain.map((domain) => unmuteDomain(store, domain)))
},
fetchFriends({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds)
return rootState.api.backendInteractor
.fetchFriends({ id, maxId })
.then((friends) => {
commit('addNewUsers', friends)
commit('saveFriendIds', { id, friendIds: map(friends, 'id') })
return friends
})
},
fetchFollowers({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.followerIds)
return rootState.api.backendInteractor
.fetchFollowers({ id, maxId })
.then((followers) => {
commit('addNewUsers', followers)
commit('saveFollowerIds', { id, followerIds: map(followers, 'id') })
return followers
})
},
clearFriends({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser({ rootState, commit }, id) {
return rootState.api.backendInteractor
.followUser({ id, notify: true })
.then((relationship) =>
commit('updateUserRelationship', [relationship]),
)
},
unsubscribeUser({ rootState, commit }, id) {
return rootState.api.backendInteractor
.followUser({ id, notify: false })
.then((relationship) =>
commit('updateUserRelationship', [relationship]),
)
},
toggleActivationStatus({ rootState, commit }, { user }) {
const api = user.deactivated
? rootState.api.backendInteractor.activateUser
: rootState.api.backendInteractor.deactivateUser
api({ user }).then((user) => {
const deactivated = !user.is_active
commit('updateActivationStatus', { user, deactivated })
})
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
- const vapidPublicKey = store.rootState.instance.vapidPublicKey
+ const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
const notificationVisibility =
store.rootState.config.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications(store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewUsers({ commit }, users) {
commit('addNewUsers', users)
},
addNewStatuses(store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
each(statuses, (status) => {
// Reconnect users to statuses
store.commit('setUserForStatus', status)
// Set pinned statuses to user
store.commit('setPinnedToUser', status)
})
each(compact(map(statuses, 'retweeted_status')), (status) => {
// Reconnect users to retweets
store.commit('setUserForStatus', status)
// Set pinned retweets to user
store.commit('setPinnedToUser', status)
})
},
addNewNotifications(store, { notifications }) {
const users = map(notifications, 'from_profile')
const targetUsers = map(notifications, 'target').filter((_) => _)
const notificationIds = notifications.map((_) => _.id)
store.commit('addNewUsers', users)
store.commit('addNewUsers', targetUsers)
const notificationsObject = store.rootState.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k]) => notificationIds.includes(k))
.map(([, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
searchUsers({ rootState, commit }, { query }) {
return rootState.api.backendInteractor
.searchUsers({ query })
.then((users) => {
commit('addNewUsers', users)
return users
})
},
async signUp(store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await oauthStore.ensureAppToken()
const data = await apiService.register({
credentials: token,
params: { ...userInfo },
})
if (data.access_token) {
store.commit('signUpSuccess')
oauthStore.setToken(data.access_token)
await store.dispatch('loginUser', data.access_token)
return 'ok'
} else {
// Request succeeded, but user cannot login yet.
store.commit('signUpNotice', data)
return 'request_sent'
}
} catch (e) {
const errors = e.message
store.commit('signUpFailure', errors)
throw e
}
},
async getCaptcha(store) {
return store.rootState.api.backendInteractor.getCaptcha()
},
logout(store) {
const oauth = useOAuthStore()
const { instance } = store.rootState
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: instance.server,
token: oauth.userToken,
}
return oauthApi.revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
oauth.clearToken()
store.dispatch('stopFetchingTimeline', 'friends')
store.commit(
'setBackendInteractor',
backendInteractorService(oauth.getToken),
)
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingBookmarkFolders')
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
store.dispatch('resetChats')
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
store.commit('clearServerSideStorage')
})
},
loginUser(store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
const dispatch = store.dispatch
- const rootState = store.rootState
commit('beginLogin')
store.rootState.api.backendInteractor
.verifyCredentials(accessToken)
.then((data) => {
if (!data.error) {
const user = data
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
useServerSideStorageStore().setServerSideStorage(user)
commit('addNewUsers', [user])
dispatch('fetchEmoji')
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
// Set our new backend interactor
commit(
'setBackendInteractor',
backendInteractorService(accessToken),
)
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useServerSideStorageStore().prefsStorage.simple.muteFilters
).forEach(key => {
useServerSideStorageStore().unsetPreference({ path: 'simple.muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useServerSideStorageStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
const { configMigration } =
useServerSideStorageStore().flagStorage
declarations
.filter((x) => {
return (
x.store === 'server-side' &&
x.migrationNum > 0 &&
x.migrationNum > configMigration
)
})
.toSorted((a, b) => a.configMigration - b.configMigration)
.forEach((value) => {
value.migration(useServerSideStorageStore(), store.rootState)
useServerSideStorageStore().setFlag({
flag: 'configMigration',
value: value.migrationNum,
})
useServerSideStorageStore().pushServerSideStorage()
})
if (user.token) {
dispatch('setWsToken', user.token)
// Initialize the shout socket.
dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
dispatch('startFetchingTimeline', { timeline: 'friends' })
// Start fetching notifications
dispatch('startFetchingNotifications')
- if (rootState.instance.pleromaChatMessagesAvailable) {
+ if (useInstanceStore().pleromaChatMessagesAvailable) {
// Start fetching chats
dispatch('startFetchingChats')
}
}
dispatch('startFetchingLists')
dispatch('startFetchingBookmarkFolders')
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (store.getters.mergedConfig.useStreamingApi) {
dispatch('fetchTimeline', { timeline: 'friends', since: null })
dispatch('fetchNotifications', { since: null })
dispatch('enableMastoSockets', true)
.catch((error) => {
console.error(
'Failed initializing MastoAPI Streaming socket',
error,
)
})
.then(() => {
dispatch('fetchChats', { latest: true })
setTimeout(
() => dispatch('setNotificationsSilence', false),
10000,
)
})
} else {
startPolling()
}
// Get user mutes
dispatch('fetchMutes')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
store.rootState.api.backendInteractor
.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
} else {
const response = data.error
// Authentication failed
commit('endLogin')
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(response.status)) {
useOAuthStore().clearToken()
}
if (response.status === 401) {
reject(new Error('Wrong username or password'))
} else {
reject(new Error('An error occurred, please try again'))
}
}
commit('endLogin')
resolve()
})
.catch((error) => {
console.error(error)
commit('endLogin')
reject(new Error('Failed to connect to server, try again'))
})
})
},
},
}
export default users
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index ccbc76d2bf..b7911a7dc4 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -1,73 +1,74 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import bookmarkFoldersFetcher from '../../services/bookmark_folders_fetcher/bookmark_folders_fetcher.service.js'
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
import apiService, {
getMastodonSocketURI,
ProcessedWS,
} from '../api/api.service.js'
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
const backendInteractorService = (credentials) => ({
startFetchingTimeline({
timeline,
store,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
tag,
}) {
return timelineFetcher.startFetching({
timeline,
store,
credentials,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
},
fetchTimeline(args) {
return timelineFetcher.fetchAndUpdate({ ...args, credentials })
},
startFetchingNotifications({ store }) {
return notificationsFetcher.startFetching({ store, credentials })
},
fetchNotifications(args) {
return notificationsFetcher.fetchAndUpdate({ ...args, credentials })
},
startFetchingFollowRequests({ store }) {
return followRequestFetcher.startFetching({ store, credentials })
},
startFetchingLists({ store }) {
return listsFetcher.startFetching({ store, credentials })
},
startFetchingBookmarkFolders({ store }) {
return bookmarkFoldersFetcher.startFetching({ store, credentials })
},
startUserSocket({ store }) {
- const serv = store.rootState.instance.server.replace('http', 'ws')
+ const serv = useInstanceStore().server.replace('http', 'ws')
const url = getMastodonSocketURI({}, serv)
return ProcessedWS({ url, id: 'Unified', credentials })
},
...Object.entries(apiService).reduce((acc, [key, func]) => {
return {
...acc,
[key]: (args) => func({ credentials, ...args }),
}
}, {}),
verifyCredentials: apiService.verifyCredentials,
})
export default backendInteractorService
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index c8dca3159b..03fdd7c5b3 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -1,128 +1,129 @@
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
//
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = new Set([
'mention',
'status',
'favourite',
'reblog',
'follow',
'follow_request',
'move',
'poll',
'pleroma:emoji_reaction',
'pleroma:report',
])
const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const args = { credentials }
const { getters } = store
const rootState = store.rootState || store.state
const timelineData = rootState.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
- if (rootState.instance.pleromaChatMessagesAvailable) {
+ if (useInstanceStore().pleromaChatMessagesAvailable) {
mastoApiNotificationTypes.add('pleroma:chat_mention')
}
args.includeTypes = mastoApiNotificationTypes
args.withMuted = !hideMutedPosts
args.timeline = 'notifications'
if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) {
args.until = timelineData.minId
}
return fetchNotifications({ store, args, older })
} else {
// fetch new notifications
if (
since === undefined &&
timelineData.maxId !== Number.POSITIVE_INFINITY
) {
args.since = timelineData.maxId
} else if (since !== null) {
args.since = since
}
const result = fetchNotifications({ store, args, older })
// 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)
const unreadNotifsIds = notifications
.filter((n) => !n.seen)
.map((n) => n.id)
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
if (minId !== Infinity) {
args.since = false // Don't use since_id since it sorta conflicts with min_id
args.minId = minId - 1 // go beyond
fetchNotifications({ store, args, older })
}
}
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService
.fetchTimeline(args)
.then((response) => {
if (response.errors) {
if (
response.status === 400 &&
response.statusText.includes('Invalid value for enum')
) {
response.statusText
.matchAll(/(\w+) - Invalid value for enum./g)
.toArray()
.map((x) => x[1])
.forEach((x) => mastoApiNotificationTypes.delete(x))
return fetchNotifications({ store, args, older })
} else {
throw new Error(`${response.status} ${response.statusText}`)
}
}
const notifications = response.data
update({ store, notifications, older })
return notifications
})
.catch((error) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'notifications.error',
messageArgs: [error.message],
timeout: 5000,
})
console.error(error)
})
}
const startFetching = ({ credentials, store }) => {
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 10000)
}
const notificationsFetcher = {
fetchAndUpdate,
startFetching,
}
export default notificationsFetcher
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index aef724eda4..577ad3e1b2 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -1,160 +1,161 @@
import { camelCase } from 'lodash'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
const update = ({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
}) => {
const ccTimeline = camelCase(timeline)
store.dispatch('addNewStatuses', {
timeline: ccTimeline,
userId,
listId,
statuses,
showImmediately,
pagination,
})
}
const fetchAndUpdate = ({
store,
credentials,
timeline = 'friends',
older = false,
showImmediately = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
tag = false,
until,
since,
}) => {
const args = { timeline, credentials }
const rootState = store.rootState || store.state
const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const { hideMutedPosts, replyVisibility } = getters.mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) {
args.until = until || timelineData.minId
} else {
if (since === undefined) {
args.since = timelineData.maxId
} else if (since !== null) {
args.since = since
}
}
args.userId = userId
args.listId = listId
args.statusId = statusId
args.bookmarkFolderId = bookmarkFolderId
args.tag = tag
args.withMuted = !hideMutedPosts
if (
loggedIn &&
['friends', 'public', 'publicAndExternal', 'bubble'].includes(timeline)
) {
args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timelineData.statuses.length
return apiService
.fetchTimeline(args)
.then((response) => {
if (response.errors) {
if (timeline === 'favorites') {
- rootState.instance.pleromaPublicFavouritesAvailable = false
+ useInstanceStore().pleromaPublicFavouritesAvailable = false
return
}
throw new Error(`${response.status} ${response.statusText}`)
}
const { data: statuses, pagination } = response
if (
!older &&
statuses.length >= 20 &&
!timelineData.loading &&
numStatusesBeforeFetch > 0
) {
store.dispatch('queueFlush', { timeline, id: timelineData.maxId })
}
update({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
})
return { statuses, pagination }
})
.catch((error) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.error',
messageArgs: [error.message],
timeout: 5000,
})
})
}
const startFetching = ({
timeline = 'friends',
credentials,
store,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
tag = false,
}) => {
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const showImmediately = timelineData.visibleStatuses.length === 0
timelineData.userId = userId
timelineData.listId = listId
timelineData.bookmarkFolderId = bookmarkFolderId
fetchAndUpdate({
timeline,
credentials,
store,
showImmediately,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
const boundFetchAndUpdate = () =>
fetchAndUpdate({
timeline,
credentials,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
return promiseInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {
fetchAndUpdate,
startFetching,
}
export default timelineFetcher
diff --git a/src/stores/instance.js b/src/stores/instance.js
new file mode 100644
index 0000000000..3dc591f8f4
--- /dev/null
+++ b/src/stores/instance.js
@@ -0,0 +1,157 @@
+import { get, set } from 'lodash'
+import { defineStore } from 'pinia'
+
+import { ensureFinalFallback } from 'src/i18n/languages.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { instanceDefaultProperties } from '../modules/config.js'
+import {
+ instanceDefaultConfig,
+ staticOrApiConfigDefault,
+} from '../modules/default_config_state.js'
+import apiService from '../services/api/api.service.js'
+
+const REMOTE_INTERACTION_URL = '/main/ostatus'
+
+const defaultState = {
+ // Stuff from apiConfig
+ name: 'Pleroma FE',
+ registrationOpen: true,
+ server: 'http://localhost:4040/',
+ textlimit: 5000,
+ private: false,
+ federating: true,
+ federationPolicy: null,
+ themesIndex: null,
+ stylesIndex: null,
+ palettesIndex: null,
+ themeData: null, // used for theme editor v2
+ vapidPublicKey: null,
+
+ // Stuff from static/config.json
+ loginMethod: 'password',
+ disableUpdateNotification: false,
+
+ // Instance-wide configurations that should not be changed by individual users
+ instanceIdentity: {
+ ...staticOrApiConfigDefault,
+ },
+
+ limits: {
+ bannerlimit: null,
+ avatarlimit: null,
+ backgroundlimit: null,
+ uploadlimit: null,
+ fieldsLimits: null,
+ pollLimits: {
+ max_options: 4,
+ max_option_chars: 255,
+ min_expiration: 60,
+ max_expiration: 60 * 60 * 24,
+ },
+ },
+
+ // Instance admins can override default settings for the whole instance
+ prefsStorage: {
+ ...instanceDefaultConfig,
+ },
+
+ // Known domains list for user's domain-muting
+ knownDomains: [],
+
+ // Moderation stuff
+ staffAccounts: [],
+ accountActivationRequired: null,
+ accountApprovalRequired: null,
+ birthdayRequired: false,
+ birthdayMinAge: 0,
+ restrictedNicknames: [],
+ localBubbleInstances: [], // Akkoma
+
+ // Feature-set, apparently, not everything here is reported...
+ featureSet: {
+ postFormats: [],
+ mailerEnabled: false,
+ safeDM: true,
+ shoutAvailable: false,
+ pleromaExtensionsAvailable: true,
+ pleromaChatMessagesAvailable: false,
+ pleromaCustomEmojiReactionsAvailable: false,
+ pleromaBookmarkFoldersAvailable: false,
+ pleromaPublicFavouritesAvailable: true,
+ statusNotificationTypeAvailable: true,
+ gopherAvailable: false,
+ editingAvailable: false,
+ mediaProxyAvailable: false,
+ suggestionsEnabled: false,
+ suggestionsWeb: '',
+ quotingAvailable: false,
+ groupActorAvailable: false,
+ blockExpiration: false,
+ tagPolicyAvailable: false,
+ pollsAvailable: false,
+ localBubble: false, // Akkoma
+ },
+
+ // Html stuff
+ instanceSpecificPanelContent: '',
+ tos: '',
+
+ // Version Information
+ backendVersion: '',
+ backendRepository: '',
+ frontendVersion: '',
+}
+
+export const useInstanceStore = defineStore('instance', {
+ state: () => ({ ...defaultState }),
+ getters: {
+ instanceDefaultConfig(state) {
+ return instanceDefaultProperties
+ .map((key) => [key, state[key]])
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
+ },
+ instanceDomain(state) {
+ return new URL(this.server).hostname
+ },
+ remoteInteractionLink(state) {
+ const server = this.server.endsWith('/')
+ ? this.server.slice(0, -1)
+ : this.server
+ const link = server + REMOTE_INTERACTION_URL
+
+ return ({ statusId, nickname }) => {
+ if (statusId) {
+ return `${link}?status_id=${statusId}`
+ } else {
+ return `${link}?nickname=${nickname}`
+ }
+ }
+ },
+ },
+ actions: {
+ set({ path, value }) {
+ if (get(defaultState, path) === undefined)
+ console.error(`Unknown instance option ${path}, value: ${value}`)
+ set(this, path, value)
+ switch (name) {
+ case 'name':
+ useInterfaceStore().setPageTitle()
+ break
+ case 'shoutAvailable':
+ if (value) {
+ window.vuex.dispatch('initializeSocket')
+ }
+ break
+ }
+ },
+ async getKnownDomains() {
+ try {
+ this.knownDomains = await apiService.fetchKnownDomains({
+ credentials: window.vuex.state.users.currentUser.credentials,
+ })
+ } catch (e) {
+ console.warn("Can't load known domains\n", e)
+ }
+ },
+ },
+})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index d39a6d7426..289fd845b7 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,739 +1,740 @@
import { defineStore } from 'pinia'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
+import { useInstanceStore } from 'src/stores/instance.js'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
window.CSS &&
window.CSS.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
layoutHeight: 0,
lastTimeline: null,
}),
actions: {
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
if (this.temporaryChangesCountdown === 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
- document.title = `${option} ${window.vuex.state.instance.name}`
+ document.title = `${option} ${window.vuex.useInstanceStore().name}`
} catch (error) {
console.error(`${error}`)
}
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
togglePeekSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
default:
throw new Error('Illegal minimization state of settings modal')
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 0,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
const { thirdColumnMode } = window.vuex.getters.mergedConfig
if (thirdColumnMode === 'none' || !window.vuex.state.users.currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map((font) => font.family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
window.vuex.commit('setInstanceOption', {
name: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
window.vuex.commit('setInstanceOption', {
name: 'palettesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
window.vuex.commit('setOption', { name: 'palette', value })
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
window.vuex.commit('setOption', { name: 'paletteCustomData', value })
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
window.vuex.commit('setInstanceOption', { name: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
window.vuex.commit('setInstanceOption', {
name: 'stylesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
window.vuex.commit('setOption', { name: 'style', value })
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
window.vuex.commit('setOption', { name: 'styleCustomData', value })
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
window.vuex.commit('setInstanceOption', { name: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
window.vuex.commit('setInstanceOption', {
name: 'themesIndex',
value: { _error: e },
})
return Promise.resolve({})
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
window.vuex.commit('setOption', { name: 'theme', value })
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
window.vuex.commit('setOption', { name: 'customTheme', value })
window.vuex.commit('setOption', { name: 'customThemeSource', value })
this.applyTheme({ recompile: true })
},
resetThemeV3() {
window.vuex.commit('setOption', { name: 'style', value: null })
window.vuex.commit('setOption', { name: 'styleCustomData', value: null })
},
resetThemeV3Palette() {
window.vuex.commit('setOption', { name: 'palette', value: null })
window.vuex.commit('setOption', {
name: 'paletteCustomData',
value: null,
})
},
resetThemeV2() {
window.vuex.commit('setOption', { name: 'theme', value: null })
window.vuex.commit('setOption', { name: 'customTheme', value: null })
window.vuex.commit('setOption', {
name: 'customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
const { style: instanceStyleName, palette: instancePaletteName } =
- window.vuex.state.instance
+ window.vuex.useInstanceStore()
let {
theme: instanceThemeV2Name,
themesIndex,
stylesIndex,
palettesIndex,
- } = window.vuex.state.instance
+ } = window.vuex.useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = window.vuex.state.config
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = window.vuex.state.config
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
console.debug('Instance V2 theme: ' + instanceThemeV2Name)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeV2Name == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeV2Name = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeV2Name
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
const key = 'style.' + palette.name.toLowerCase().replace(/ /g, '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
window.vuex.commit('setOption', {
name: 'palette',
value: firstStylePaletteName,
})
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeV2Name,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
const { forceThemeRecompilation, themeDebug, theme3hacks } =
window.vuex.state.config
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
Object.entries(theme3hacks).forEach(([key, value]) => {
switch (key) {
case 'fonts': {
Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
if (!font?.family) return
switch (fontKey) {
case 'interface':
hacks.push({
component: 'Root',
directives: {
'--font': 'generic | ' + font.family,
},
})
break
case 'input':
hacks.push({
component: 'Input',
directives: {
'--font': 'generic | ' + font.family,
},
})
break
case 'post':
hacks.push({
component: 'RichContent',
directives: {
'--font': 'generic | ' + font.family,
},
})
break
case 'monospace':
hacks.push({
component: 'Root',
directives: {
'--monoFont': 'generic | ' + font.family,
},
})
break
}
})
break
}
case 'underlay': {
if (value !== 'none') {
const newRule = {
component: 'Underlay',
directives: {},
}
if (value === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (value === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
break
}
}
})
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter((x) => x)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
window.splashError(e)
}
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/oauth.js b/src/stores/oauth.js
index ea5a4bfacd..9b47e38314 100644
--- a/src/stores/oauth.js
+++ b/src/stores/oauth.js
@@ -1,160 +1,158 @@
import { defineStore } from 'pinia'
import {
createApp,
getClientToken,
verifyAppToken,
} from 'src/services/new_api/oauth.js'
+import { useInstanceStore } from 'src/stores/instance.js'
// status codes about verifyAppToken (GET /api/v1/apps/verify_credentials)
const isAppTokenRejected = (error) =>
// Pleroma API docs say it returns 422 when unauthorized
error.statusCode === 422 ||
// but it actually returns 400 (as of 2.9.0)
// NOTE: don't try to match against the error message, it is translatable
error.statusCode === 400 ||
// and Mastodon docs say it returns 401
error.statusCode === 401
// status codes about getAppToken (GET /oauth/token)
const isClientDataRejected = (error) =>
// Mastodon docs say it returns 401
error.statusCode === 401 ||
// but Pleroma actually returns 400 (as of 2.9.0)
// NOTE: don't try to match against the error message, it is translatable
error.statusCode === 400
export const useOAuthStore = defineStore('oauth', {
state: () => ({
clientId: false,
clientSecret: false,
/* App token is authentication for app without any user, used mostly for
* MastoAPI's registration of new users, stored so that we can fall back to
* it on logout
*/
appToken: false,
/* User token is authentication for app with user, this is for every calls
* that need authorized user to be successful (i.e. posting, liking etc)
*/
userToken: false,
}),
getters: {
getToken() {
return this.userToken || this.appToken
},
getUserToken() {
return this.userToken
},
},
actions: {
setClientData({ clientId, clientSecret }) {
this.clientId = clientId
this.clientSecret = clientSecret
},
setAppToken(token) {
this.appToken = token
},
setToken(token) {
this.userToken = token
},
clearToken() {
this.userToken = false
},
async createApp() {
- const { state } = window.vuex
- const instance = state.instance.server
+ const instance = useInstanceStore().server
const app = await createApp(instance)
this.setClientData(app)
return app
},
/// Use this if you want to get the client id and secret but are not interested
/// in whether they are valid.
/// @return {{ clientId: string, clientSecret: string }} An object representing the app
async ensureApp() {
if (this.clientId && this.clientSecret) {
return {
clientId: this.clientId,
clientSecret: this.clientSecret,
}
} else {
return this.createApp()
}
},
async getAppToken() {
- const { state } = window.vuex
- const instance = state.instance.server
+ const instance = useInstanceStore().server
const res = await getClientToken({
clientId: this.clientId,
clientSecret: this.clientSecret,
instance,
})
this.setAppToken(res.access_token)
return res.access_token
},
/// Use this if you want to ensure the app is still valid to use.
/// @return {string} The access token to the app (not attached to any user)
async ensureAppToken() {
- const { state } = window.vuex
if (this.appToken) {
try {
await verifyAppToken({
- instance: state.instance.server,
+ instance: useInstanceStore().server,
appToken: this.appToken,
})
return this.appToken
} catch (e) {
if (!isAppTokenRejected(e)) {
// The server did not reject our token, but we encountered other problems. Maybe the server is down.
throw e
} else {
// The app token is rejected, so it is no longer useful.
this.setAppToken(false)
}
}
}
// appToken is not available, or is rejected: try to get a new one.
// First, make sure the client id and client secret are filled.
try {
await this.ensureApp()
} catch (e) {
console.error('Cannot create app', e)
throw e
}
// Note that at this step, the client id and secret may be invalid
// (because the backend may have already deleted the app due to no user login)
try {
return await this.getAppToken()
} catch (e) {
if (!isClientDataRejected(e)) {
// Non-credentials problem, fail fast
console.error('Cannot get app token', e)
throw e
} else {
// the client id and secret are invalid, so we should clear them
// and re-create our app
this.setClientData({
clientId: false,
clientSecret: false,
})
await this.createApp()
// try once again to get the token
return await this.getAppToken()
}
}
},
},
persist: {
afterLoad(state) {
// state.token is userToken with older name, coming from persistent state
if (state.token && !state.userToken) {
state.userToken = state.token
}
if ('token' in state) {
delete state.token
}
return state
},
},
})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 2, 8:35 AM (8 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803888
Default Alt Text
(408 KB)

Event Timeline