Merge remote-tracking branch 'origin/develop' into admin-users

This commit is contained in:
Henry Jameson 2026-06-08 00:57:42 +03:00
commit 43936a8725
628 changed files with 72639 additions and 24537 deletions

View file

@ -1,8 +1,16 @@
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue'
import { mapState } from 'pinia'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MRFTransparencyPanel from 'src/components/mrf_transparency_panel/mrf_transparency_panel.vue'
import StaffPanel from 'src/components/staff_panel/staff_panel.vue'
import TermsOfServicePanel from 'src/components/terms_of_service_panel/terms_of_service_panel.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const pleromaFeCommitUrl =
'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const About = {
components: {
@ -10,16 +18,28 @@ const About = {
FeaturesPanel,
TermsOfServicePanel,
StaffPanel,
MRFTransparencyPanel
MRFTransparencyPanel,
},
computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
}
}
showFeaturesPanel() {
return useInstanceStore().instanceIdentity.showFeaturesPanel
},
frontendVersionLink() {
return pleromaFeCommitUrl + this.frontendVersion
},
...mapState(useInstanceStore, [
'backendVersion',
'backendRepository',
'frontendVersion',
]),
showInstanceSpecificPanel() {
return (
useInstanceStore().instanceIdentity.showInstanceSpecificPanel &&
!useMergedConfigStore().mergedConfig.hideISP &&
useInstanceStore().instanceIdentity.instanceSpecificPanelContent
)
},
},
}
export default About

View file

@ -1,11 +1,47 @@
<template>
<div class="column-inner">
<div class="About column-inner">
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
<MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" />
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.version.title') }}
</div>
</div>
<div class="panel-body">
<dl>
<dt>{{ $t('settings.version.backend_version') }}</dt>
<dd>
<a
:href="backendRepository"
target="_blank"
>
{{ backendVersion }}
</a>
</dd>
<dt>{{ $t('settings.version.frontend_version') }}</dt>
<dd>
<a
:href="frontendVersionLink"
target="_blank"
>
{{ frontendVersion }}
</a>
</dd>
</dl>
</div>
</div>
</div>
</template>
<script src="./about.js"></script>
<style>
.About {
dl {
padding-left: 1em;
}
}
</style>

View file

@ -1,53 +1,58 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports'
library.add(
faEllipsisV
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisV)
const AccountActions = {
props: [
'user', 'relationship'
],
data () {
props: ['user', 'relationship'],
data() {
return {
showingConfirmBlock: false,
showingConfirmRemoveFollower: false
showingConfirmRemoveFollower: false,
}
},
components: {
ProgressButton,
Popover,
UserListMenu,
ConfirmModal,
UserTimedFilterModal
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
},
methods: {
showConfirmRemoveUserFromFollowers () {
showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true
},
hideConfirmRemoveUserFromFollowers () {
hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false
},
hideConfirmBlock () {
hideConfirmBlock() {
this.showingConfirmBlock = false
},
showRepeats () {
showRepeats() {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats () {
hideRepeats() {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
blockUser() {
if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
@ -58,46 +63,49 @@ const AccountActions = {
}
}
},
doBlockUser () {
doBlockUser() {
this.$store.dispatch('blockUser', { id: this.user.id })
this.hideConfirmBlock()
},
unblockUser () {
unblockUser() {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers () {
removeUserFromFollowers() {
if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers()
} else {
this.showConfirmRemoveUserFromFollowers()
}
},
doRemoveUserFromFollowers () {
doRemoveUserFromFollowers() {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
this.hideConfirmRemoveUserFromFollowers()
},
reportUser () {
reportUser() {
useReportsStore().openUserReportingModal({ userId: this.user.id })
},
openChat () {
openChat() {
this.$router.push({
name: 'chat',
params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id }
params: {
username: this.$store.state.users.currentUser.screen_name,
recipient_id: this.user.id,
},
})
}
},
},
computed: {
shouldConfirmBlock () {
return this.$store.getters.mergedConfig.modalOnBlock
shouldConfirmBlock() {
return useMergedConfigStore().mergedConfig.modalOnBlock
},
shouldConfirmRemoveUserFromFollowers () {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
shouldConfirmRemoveUserFromFollowers() {
return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers
},
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
}
...mapState(useInstanceCapabilitiesStore, [
'blockExpiration',
'pleromaChatMessagesAvailable',
]),
},
}
export default AccountActions

View file

@ -94,8 +94,8 @@
</template>
</Popover>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmBlock && !blockExpirationSupported"
<ConfirmModal
v-if="showingConfirmBlock && !blockExpiration"
ref="blockDialog"
:title="$t('user_card.block_confirm_title')"
:confirm-text="$t('user_card.block_confirm_accept_button')"
@ -114,10 +114,10 @@
/>
</template>
</i18n-t>
</confirm-modal>
</ConfirmModal>
</teleport>
<teleport to="#modal">
<confirm-modal
<ConfirmModal
v-if="showingConfirmRemoveFollower"
:title="$t('user_card.remove_follower_confirm_title')"
:confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
@ -136,9 +136,9 @@
/>
</template>
</i18n-t>
</confirm-modal>
</ConfirmModal>
<UserTimedFilterModal
v-if="blockExpirationSupported"
v-if="blockExpiration"
ref="timedBlockDialog"
:is-mute="false"
:user="user"

View file

@ -1,57 +1,51 @@
export default {
name: 'Alert',
selector: '.alert',
validInnerComponents: [
'Text',
'Icon',
'Link',
'Border',
'ButtonUnstyled'
],
validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'],
variants: {
normal: '.neutral',
error: '.error',
warning: '.warning',
success: '.success'
success: '.success',
},
editor: {
border: 1,
aspect: '3 / 1'
aspect: '3 / 1',
},
defaultRules: [
{
directives: {
background: '--text',
opacity: 0.5,
blur: '9px'
}
blur: '9px',
},
},
{
parent: {
component: 'Alert'
component: 'Alert',
},
component: 'Border',
directives: {
textColor: '--parent'
}
textColor: '--parent',
},
},
{
variant: 'error',
directives: {
background: '--cRed'
}
background: '--cRed',
},
},
{
variant: 'warning',
directives: {
background: '--cOrange'
}
background: '--cOrange',
},
},
{
variant: 'success',
directives: {
background: '--cGreen'
}
}
]
background: '--cGreen',
},
},
],
}

View file

@ -1,109 +1,128 @@
import { mapState } from 'vuex'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import localeService from '../../services/locale/locale.service.js'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const Announcement = {
components: {
AnnouncementEditor,
RichContent
},
data () {
data() {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined
allDay: undefined,
},
editError: ''
editError: '',
}
},
props: {
announcement: Object
announcement: Object,
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
currentUser: (state) => state.users.currentUser,
}),
canEditAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
canEditAnnouncement() {
return (
this.currentUser &&
this.currentUser.privileges.includes(
'announcements_manage_announcements',
)
)
},
content () {
content() {
return this.announcement.content
},
isRead () {
isRead() {
return this.announcement.read
},
publishedAt () {
publishedAt() {
const time = this.announcement.published_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
startsAt () {
startsAt() {
const time = this.announcement.starts_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
endsAt () {
endsAt() {
const time = this.announcement.ends_at
if (!time) {
return
}
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
inactive () {
inactive() {
return this.announcement.inactive
}
},
},
methods: {
markAsRead () {
markAsRead() {
if (!this.isRead) {
return useAnnouncementsStore().markAnnouncementAsRead(this.announcement.id)
return useAnnouncementsStore().markAnnouncementAsRead(
this.announcement.id,
)
}
},
deleteAnnouncement () {
deleteAnnouncement() {
return useAnnouncementsStore().deleteAnnouncement(this.announcement.id)
},
formatTimeOrDate (time, locale) {
formatTimeOrDate(time, locale) {
const d = new Date(time)
return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
return this.announcement.all_day
? d.toLocaleDateString(locale)
: d.toLocaleString(locale)
},
enterEditMode () {
enterEditMode() {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true
},
submitEdit () {
useAnnouncementsStore().editAnnouncement({
id: this.announcement.id,
...this.editedAnnouncement
})
submitEdit() {
useAnnouncementsStore()
.editAnnouncement({
id: this.announcement.id,
...this.editedAnnouncement,
})
.then(() => {
this.editing = false
})
.catch(error => {
.catch((error) => {
this.editError = error.error
})
},
cancelEdit () {
cancelEdit() {
this.editing = false
},
clearError () {
clearError() {
this.editError = undefined
}
}
},
},
}
export default Announcement

View file

@ -1,13 +1,13 @@
import Checkbox from '../checkbox/checkbox.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const AnnouncementEditor = {
components: {
Checkbox
Checkbox,
},
props: {
announcement: Object,
disabled: Boolean
}
disabled: Boolean,
},
}
export default AnnouncementEditor

View file

@ -1,59 +1,67 @@
import { mapState } from 'vuex'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import Announcement from 'src/components/announcement/announcement.vue'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor
AnnouncementEditor,
},
data () {
data() {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false
allDay: false,
},
posting: false,
error: undefined
error: undefined,
}
},
mounted () {
mounted() {
useAnnouncementsStore().fetchAnnouncements()
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
currentUser: (state) => state.users.currentUser,
}),
announcements () {
announcements() {
return useAnnouncementsStore().announcements
},
canPostAnnouncement () {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements')
}
canPostAnnouncement() {
return (
this.currentUser &&
this.currentUser.privileges.includes(
'announcements_manage_announcements',
)
)
},
},
methods: {
postAnnouncement () {
postAnnouncement() {
this.posting = true
useAnnouncementsStore().postAnnouncement(this.newAnnouncement)
useAnnouncementsStore()
.postAnnouncement(this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch(error => {
.catch((error) => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError () {
clearError() {
this.error = undefined
}
}
},
},
}
export default AnnouncementsPage

View file

@ -21,10 +21,10 @@
export default {
emits: ['resetAsyncComponent'],
methods: {
retry () {
retry() {
this.$emit('resetAsyncComponent')
}
}
},
},
}
</script>

View file

@ -1,24 +1,29 @@
import StillImage from '../still-image/still-image.vue'
import Flash from '../flash/flash.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import VideoAttachment from 'src/components/video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faMusic,
faPencilAlt,
faAlignRight
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from 'src/stores/media_viewer'
library.add(
faFile,
@ -31,7 +36,7 @@ library.add(
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight
faAlignRight,
)
const Attachment = {
@ -46,72 +51,74 @@ const Attachment = {
'remove',
'shiftUp',
'shiftDn',
'edit'
'edit',
],
data () {
data() {
return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
nsfwImage:
useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
img: this.attachment.type === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
showDescription: false
}
},
components: {
Flash,
StillImage,
VideoAttachment
Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
Popover,
},
computed: {
classNames () {
classNames() {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact
'-compact': this.compact,
},
'-type-' + this.type,
'-type-' + this.attachment.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
]
},
usePlaceholder () {
usePlaceholder() {
return this.size === 'hide'
},
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
useContainFit() {
return this.mergedConfig.useContainFit
},
placeholderName () {
placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
return this.attachment.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'
placeholderIconClass() {
if (this.attachment.type === 'image') return 'image'
if (this.attachment.type === 'video') return 'video'
if (this.attachment.type === 'audio') return 'music'
return 'file'
},
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
referrerpolicy() {
return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
},
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
hidden() {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed)
isEmpty() {
return this.attachment.type === 'html' && !this.attachment.oembed
},
useModal () {
useModal() {
let modalTypes = []
switch (this.size) {
case 'hide':
@ -124,64 +131,63 @@ const Attachment = {
: ['image']
break
}
return modalTypes.includes(this.type)
return modalTypes.includes(this.attachment.type)
},
videoTag () {
videoTag() {
return this.useModal ? 'button' : 'span'
},
...mapGetters(['mergedConfig'])
...mapState(useMergedConfigStore, ['mergedConfig']),
},
watch: {
'attachment.description' (newVal) {
'attachment.description'(newVal) {
this.localDescription = newVal
},
localDescription (newVal) {
localDescription(newVal) {
this.onEdit(newVal)
}
},
},
methods: {
linkClicked ({ target }) {
linkClicked({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal () {
openModal() {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.type === 'unknown') {
} else if (this.attachment.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce () {
openModalForce() {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit (event) {
onEdit(event) {
this.edit && this.edit(this.attachment, event)
},
onRemove () {
onRemove() {
this.remove && this.remove(this.attachment)
},
onShiftUp () {
onShiftUp() {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn () {
onShiftDn() {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash () {
stopFlash() {
this.$refs.flash.closePlayer()
},
setFlashLoaded (event) {
setFlashLoaded(event) {
this.flashLoaded = event
},
toggleDescription () {
this.showDescription = !this.showDescription
},
toggleHidden (event) {
toggleHidden(event) {
if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.attachment.type !== 'video' ||
this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
@ -201,12 +207,12 @@ const Attachment = {
this.showHidden = !this.showHidden
}
},
onImageLoad (image) {
onImageLoad(image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
}
}
},
},
}
export default Attachment

View file

@ -134,7 +134,7 @@
width: 2em;
height: 2em;
margin-left: 0.5em;
font-size: 1.25em;
font-size: 1em;
}
}
@ -265,3 +265,27 @@
}
}
}
.description-popover {
padding: 1em;
width: 50ch;
max-width: 90vw;
overflow: hidden;
box-sizing: border-box;
summary {
display: inline-block;
margin-bottom: 0.5em;
font-weight: bold;
pointer-events: none;
}
span {
display: block;
overflow-y: auto;
max-height: 10.5em;
text-wrap: pretty;
line-height: 1.5;
white-space: pre-wrap;
}
}

View file

@ -6,7 +6,7 @@
@click="openModal"
>
<a
v-if="type !== 'html'"
v-if="attachment.type !== 'html'"
class="placeholder"
target="_blank"
:href="attachment.url"
@ -30,21 +30,16 @@
</button>
</div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
v-if="size !== 'hide' && !hideDescription && edit"
class="description-container"
:class="{ '-static': !edit }"
>
<input
v-if="edit"
<textarea
v-model="localDescription"
type="text"
class="input description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
/>
</div>
</button>
<div
@ -70,7 +65,7 @@
:src="nsfwImage"
>
<FAIcon
v-if="type === 'video'"
v-if="attachment.type === 'video'"
class="play-icon"
icon="play-circle"
/>
@ -80,23 +75,31 @@
class="attachment-buttons"
>
<button
v-if="type === 'flash' && flashLoaded"
v-if="attachment.type === 'flash' && flashLoaded"
class="button-default attachment-button -transparent"
:title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash"
>
<FAIcon icon="stop" />
</button>
<button
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription"
<Popover
v-if="attachment.description && size !== 'small' && !edit && attachment.type !== 'unknown'"
trigger="click"
popover-class="popover popover-default description-popover"
:trigger-attrs="{ 'class': 'button-default attachment-button -transparent', 'title': $t('status.attachment_description') }"
>
<FAIcon icon="align-right" />
</button>
<template #trigger>
<FAIcon icon="align-right" />
</template>
<template #content>
<details open>
<summary>{{ $t('status.attachment_description') }}</summary>
<span>{{ localDescription }}</span>
</details>
</template>
</Popover>
<button
v-if="!useModal && type !== 'unknown'"
v-if="!useModal && attachment.type !== 'unknown'"
class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce"
@ -138,7 +141,7 @@
</div>
<a
v-if="type === 'image' && (!hidden || preloadImage)"
v-if="attachment.type === 'image' && (!hidden || preloadImage)"
class="image-container"
:class="{'-hidden': hidden && preloadImage }"
:href="attachment.url"
@ -156,7 +159,7 @@
</a>
<a
v-if="type === 'unknown' && !hidden"
v-if="attachment.type === 'unknown' && !hidden"
class="placeholder-container"
:href="attachment.url"
target="_blank"
@ -173,7 +176,7 @@
<component
:is="videoTag"
v-if="type === 'video' && !hidden"
v-if="attachment.type === 'video' && !hidden"
class="video-container"
:href="attachment.url"
@click.stop.prevent="openModal"
@ -193,13 +196,13 @@
</component>
<span
v-if="type === 'audio' && !hidden"
v-if="attachment.type === 'audio' && !hidden"
class="audio-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<audio
v-if="type === 'audio'"
v-if="attachment.type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@ -210,7 +213,7 @@
</span>
<div
v-if="type === 'html' && attachment.oembed"
v-if="attachment.type === 'html' && attachment.oembed"
class="oembed-container"
@click.prevent="linkClicked"
>
@ -229,7 +232,7 @@
</div>
<span
v-if="type === 'flash' && !hidden"
v-if="attachment.type === 'flash' && !hidden"
class="flash-container"
:href="attachment.url"
@click.stop.prevent="openModal"
@ -244,21 +247,16 @@
</span>
</div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
v-if="size !== 'hide' && !hideDescription && edit"
class="description-container"
:class="{ '-static': !edit }"
>
<input
v-if="edit"
<textarea
v-model="localDescription"
type="text"
class="input description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
/>
</div>
</div>
</template>

View file

@ -1,28 +1,34 @@
import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
import { mapState } from 'pinia'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { h, resolveComponent } from 'vue'
import LoginForm from 'src/components/login_form/login_form.vue'
import MFARecoveryForm from 'src/components/mfa_form/recovery_form.vue'
import MFATOTPForm from 'src/components/mfa_form/totp_form.vue'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
const AuthForm = {
name: 'AuthForm',
render () {
render() {
return h(resolveComponent(this.authForm))
},
computed: {
authForm () {
if (this.requiredTOTP) { return 'MFATOTPForm' }
if (this.requiredRecovery) { return 'MFARecoveryForm' }
authForm() {
if (this.requiredTOTP) {
return 'MFATOTPForm'
}
if (this.requiredRecovery) {
return 'MFARecoveryForm'
}
return 'LoginForm'
},
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery'])
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery']),
},
components: {
MFARecoveryForm,
MFATOTPForm,
LoginForm
}
LoginForm,
},
}
export default AuthForm

View file

@ -2,51 +2,55 @@ const debounceMilliseconds = 500
export default {
props: {
query: { // function to query results and return a promise
query: {
// function to query results and return a promise
type: Function,
required: true
required: true,
},
filter: { // function to filter results in real time
type: Function
filter: {
// function to filter results in real time
type: Function,
},
placeholder: {
type: String,
default: 'Search...'
}
default: 'Search...',
},
},
data () {
data() {
return {
term: '',
timeout: null,
results: [],
resultsVisible: false
resultsVisible: false,
}
},
computed: {
filtered () {
filtered() {
return this.filter ? this.filter(this.results) : this.results
}
},
},
watch: {
term (val) {
term(val) {
this.fetchResults(val)
}
},
},
methods: {
fetchResults (term) {
fetchResults(term) {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.results = []
if (term) {
this.query(term).then((results) => { this.results = results })
this.query(term).then((results) => {
this.results = results
})
}
}, debounceMilliseconds)
},
onInputClick () {
onInputClick() {
this.resultsVisible = true
},
onClickOutside () {
onClickOutside() {
this.resultsVisible = false
}
}
},
},
}

View file

@ -1,21 +1,28 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const AvatarList = {
props: ['users'],
computed: {
slicedUsers () {
slicedUsers() {
return this.users ? this.users.slice(0, 15) : []
}
},
},
components: {
UserAvatar
UserAvatar,
},
methods: {
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
}
export default AvatarList

View file

@ -1,30 +1,27 @@
export default {
name: 'Badge',
selector: '.badge',
validInnerComponents: [
'Text',
'Icon'
],
validInnerComponents: ['Text', 'Icon'],
variants: {
notification: '.-notification'
notification: '.-notification',
},
defaultRules: [
{
component: 'Root',
directives: {
'--badgeNotification': 'color | --cRed'
}
'--badgeNotification': 'color | --cRed',
},
},
{
directives: {
background: '--cGreen'
}
background: '--cGreen',
},
},
{
variant: 'notification',
directives: {
background: '--cRed'
}
}
]
background: '--cRed',
},
},
],
}

View file

@ -1,24 +1,34 @@
import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: [
'user'
],
props: ['user'],
components: {
UserPopover,
UserAvatar,
RichContent,
UserLink
UserLink,
},
methods: {
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
computed: {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
}
export default BasicUserCard

View file

@ -27,6 +27,7 @@
class="basic-user-card-user-name-value"
:html="user.name"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
/>
</div>
<div>

View file

@ -1,46 +1,50 @@
import { mapState } from 'vuex'
import { mapState } from 'pinia'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
const BlockCard = {
props: ['userId'],
computed: {
user () {
user() {
return this.$store.getters.findUser(this.userId)
},
relationship () {
relationship() {
return this.$store.getters.relationship(this.userId)
},
blocked () {
blocked() {
return this.relationship.blocking
},
blockExpiryAvailable () {
return this.user.block_expires_at !== undefined
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry () {
return this.user.block_expires_at == null
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()])
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
},
components: {
BasicUserCard
BasicUserCard,
UserTimedFilterModal,
},
methods: {
unblockUser () {
unblockUser() {
this.$store.dispatch('unblockUser', this.user.id)
},
blockUser () {
if (this.blockExpirationSupported) {
blockUser() {
if (this.blockExpiration) {
this.$refs.timedBlockDialog.optionallyPrompt()
} else {
this.$store.dispatch('blockUser', { id: this.user.id })
}
}
}
},
},
}
export default BlockCard

View file

@ -1,22 +1,15 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
library.add(
faEllipsisH
)
library.add(faEllipsisH)
const BookmarkFolderCard = {
props: [
'folder',
'allBookmarks'
],
props: ['folder', 'allBookmarks'],
computed: {
firstLetter () {
firstLetter() {
return this.folder ? this.folder.name[0] : null
}
}
},
},
}
export default BookmarkFolderCard

View file

@ -1,10 +1,11 @@
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import EmojiPicker from 'src/components/emoji_picker/emoji_picker.vue'
import apiService from '../../services/api/api.service'
import { useInterfaceStore } from 'src/stores/interface'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useInterfaceStore } from 'src/stores/interface.js'
const BookmarkFolderEdit = {
data () {
data() {
return {
name: '',
nameDraft: '',
@ -13,54 +14,59 @@ const BookmarkFolderEdit = {
emojiDraft: '',
emojiUrlDraft: null,
emojiPickerExpanded: false,
reallyDelete: false
reallyDelete: false,
}
},
components: {
EmojiPicker
EmojiPicker,
},
created () {
created() {
if (!this.id) return
const credentials = this.$store.state.users.currentUser.credentials
apiService.fetchBookmarkFolders({ credentials })
.then((folders) => {
const folder = folders.find(folder => folder.id === this.id)
if (!folder) return
apiService.fetchBookmarkFolders({ credentials }).then((folders) => {
const folder = folders.find((folder) => folder.id === this.id)
if (!folder) return
this.nameDraft = this.name = folder.name
this.emojiDraft = this.emoji = folder.emoji
this.emojiUrlDraft = this.emojiUrl = folder.emoji_url
})
this.nameDraft = this.name = folder.name
this.emojiDraft = this.emoji = folder.emoji
this.emojiUrlDraft = this.emojiUrl = folder.emoji_url
})
},
computed: {
id () {
id() {
return this.$route.params.id
}
},
},
methods: {
selectEmoji (event) {
selectEmoji(event) {
this.emojiDraft = event.insertion
this.emojiUrlDraft = event.insertionUrl
},
showEmojiPicker () {
showEmojiPicker() {
if (!this.emojiPickerExpanded) {
this.$refs.picker.showPicker()
}
},
onShowPicker () {
onShowPicker() {
this.emojiPickerExpanded = true
},
onClosePicker () {
onClosePicker() {
this.emojiPickerExpanded = false
},
updateFolder () {
useBookmarkFoldersStore().updateBookmarkFolder({ folderId: this.id, name: this.nameDraft, emoji: this.emojiDraft })
updateFolder() {
useBookmarkFoldersStore()
.updateBookmarkFolder({
folderId: this.id,
name: this.nameDraft,
emoji: this.emojiDraft,
})
.then(() => {
this.$router.push({ name: 'bookmark-folders' })
})
},
createFolder () {
useBookmarkFoldersStore().createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft })
createFolder() {
useBookmarkFoldersStore()
.createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft })
.then(() => {
this.$router.push({ name: 'bookmark-folders' })
})
@ -68,15 +74,15 @@ const BookmarkFolderEdit = {
useInterfaceStore().pushGlobalNotice({
messageKey: 'bookmark_folders.error',
messageArgs: [e.message],
level: 'error'
level: 'error',
})
})
},
deleteFolder () {
deleteFolder() {
useBookmarkFoldersStore().deleteBookmarkFolder({ folderId: this.id })
this.$router.push({ name: 'bookmark-folders' })
}
}
},
},
}
export default BookmarkFolderEdit

View file

@ -1,28 +1,29 @@
import BookmarkFolderCard from '../bookmark_folder_card/bookmark_folder_card.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import BookmarkFolderCard from 'src/components/bookmark_folder_card/bookmark_folder_card.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
const BookmarkFolders = {
data () {
data() {
return {
isNew: false
isNew: false,
}
},
components: {
BookmarkFolderCard
BookmarkFolderCard,
},
computed: {
bookmarkFolders () {
bookmarkFolders() {
return useBookmarkFoldersStore().allFolders
}
},
},
methods: {
cancelNewFolder () {
cancelNewFolder() {
this.isNew = false
},
newFolder () {
newFolder() {
this.isNew = true
}
}
},
},
}
export default BookmarkFolders

View file

@ -1,20 +1,20 @@
import { mapState } from 'pinia'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { getBookmarkFolderEntries } from 'src/components/navigation/filter.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
export const BookmarkFoldersMenuContent = {
props: [
'showPin'
],
props: ['showPin'],
components: {
NavigationEntry
NavigationEntry,
},
computed: {
...mapState(useBookmarkFoldersStore, {
folders: getBookmarkFolderEntries
})
}
folders: getBookmarkFolderEntries,
}),
},
}
export default BookmarkFoldersMenuContent

View file

@ -1,32 +1,38 @@
import Timeline from '../timeline/timeline.vue'
import Timeline from 'src/components/timeline/timeline.vue'
const Bookmarks = {
created () {
created() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null })
this.$store.dispatch('startFetchingTimeline', {
timeline: 'bookmarks',
bookmarkFolderId: this.folderId || null,
})
},
components: {
Timeline
Timeline,
},
computed: {
folderId () {
folderId() {
return this.$route.params.id
},
timeline () {
timeline() {
return this.$store.state.statuses.timelines.bookmarks
}
},
},
watch: {
folderId () {
folderId() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null })
}
this.$store.dispatch('startFetchingTimeline', {
timeline: 'bookmarks',
bookmarkFolderId: this.folderId || null,
})
},
},
unmounted () {
unmounted() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
}
},
}
export default Bookmarks

View file

@ -6,8 +6,8 @@ export default {
{
directives: {
textColor: '$mod(--parent 10)',
textAuto: 'no-auto'
}
}
]
textAuto: 'no-auto',
},
},
],
}

View file

@ -1,18 +1,20 @@
import Timeline from '../timeline/timeline.vue'
import Timeline from 'src/components/timeline/timeline.vue'
const BubbleTimeline = {
components: {
Timeline
Timeline,
},
computed: {
timeline () { return this.$store.state.statuses.timelines.bubble }
timeline() {
return this.$store.state.statuses.timelines.bubble
},
},
created () {
created() {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
},
unmounted () {
unmounted() {
this.$store.dispatch('stopFetchingTimeline', 'bubble')
}
},
}
export default BubbleTimeline

View file

@ -12,25 +12,22 @@ export default {
focused: ':focus-within',
pressed: ':active',
hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled'
disabled: ':disabled',
},
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
variants: {
// Variants save on computation time since adding new variant just adds one more "set".
// normal: '', // you can override normal variant, it will be appenended to the main class
danger: '.-danger',
transparent: '.-transparent'
transparent: '.-transparent',
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist.
},
editor: {
aspect: '2 / 1'
aspect: '2 / 1',
},
// This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever).
validInnerComponents: [
'Text',
'Icon'
],
validInnerComponents: ['Text', 'Icon'],
// Default rules, used as "default theme", essentially.
defaultRules: [
{
@ -39,9 +36,11 @@ export default {
'--buttonDefaultHoverGlow': 'shadow | 0 0 1 2 --text / 0.4',
'--buttonDefaultFocusGlow': 'shadow | 0 0 1 2 --link / 0.5',
'--buttonDefaultShadow': 'shadow | 0 0 2 #000000',
'--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 1), $borderSide(#000000 bottom 0.2 1)',
'--buttonPressedBevel': 'shadow | inset 0 0 4 #000000, $borderSide(#FFFFFF bottom 0.2 1), $borderSide(#000000 top 0.2 1)'
}
'--buttonDefaultBevel':
'shadow | $borderSide(#FFFFFF top 0.2 1), $borderSide(#000000 bottom 0.2 1)',
'--buttonPressedBevel':
'shadow | inset 0 0 4 #000000, $borderSide(#FFFFFF bottom 0.2 1), $borderSide(#000000 top 0.2 1)',
},
},
{
// component: 'Button', // no need to specify components every time unless you're specifying how other component should look
@ -49,128 +48,128 @@ export default {
directives: {
background: '--fg',
shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3
}
roundness: 3,
},
},
{
variant: 'danger',
directives: {
background: '--cRed'
}
background: '$blend(--cRed 0.25 --inheritedBackground)',
},
},
{
variant: 'transparent',
directives: {
opacity: 0.5
}
opacity: 0.5,
},
},
{
component: 'Text',
parent: {
component: 'Button',
variant: 'transparent'
variant: 'transparent',
},
directives: {
textColor: '--text'
}
textColor: '--text',
},
},
{
component: 'Icon',
parent: {
component: 'Button',
variant: 'transparent'
variant: 'transparent',
},
directives: {
textColor: '--text'
}
textColor: '--text',
},
},
{
state: ['hover'],
directives: {
shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
}
shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'],
},
},
{
state: ['focused'],
directives: {
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel']
}
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'],
},
},
{
state: ['pressed'],
directives: {
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'],
},
},
{
state: ['pressed', 'hover'],
directives: {
shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow']
}
shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'],
},
},
{
state: ['toggled'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'],
},
},
{
state: ['toggled', 'hover'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
},
},
{
state: ['toggled', 'focused'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
},
},
{
state: ['toggled', 'hover', 'focused'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
},
},
{
state: ['toggled', 'disabled'],
directives: {
background: '$blend(--accent 0.25 --parent)',
shadow: ['--buttonPressedBevel']
}
shadow: ['--buttonPressedBevel'],
},
},
{
state: ['disabled'],
directives: {
background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--buttonDefaultBevel']
}
shadow: ['--buttonDefaultBevel'],
},
},
{
component: 'Text',
parent: {
component: 'Button',
state: ['disabled']
state: ['disabled'],
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
textOpacityMode: 'blend',
},
},
{
component: 'Icon',
parent: {
component: 'Button',
state: ['disabled']
state: ['disabled'],
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
textOpacityMode: 'blend',
},
},
],
}

View file

@ -7,91 +7,86 @@ export default {
toggled: '.toggled',
disabled: ':disabled',
hover: ':is(:hover, :focus-visible):not(:disabled)',
focused: ':focus-within:not(:is(:focus-visible))'
focused: ':focus-within:not(:is(:focus-visible))',
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Badge'
],
validInnerComponents: ['Text', 'Link', 'Icon', 'Badge'],
defaultRules: [
{
directives: {
shadow: []
}
shadow: [],
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['hover']
state: ['hover'],
},
directives: {
textColor: '--parent--text'
}
textColor: '--parent--text',
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled']
state: ['toggled'],
},
directives: {
textColor: '--parent--text'
}
textColor: '--parent--text',
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'hover']
state: ['toggled', 'hover'],
},
directives: {
textColor: '--parent--text'
}
textColor: '--parent--text',
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'focused']
state: ['toggled', 'focused'],
},
directives: {
textColor: '--parent--text'
}
textColor: '--parent--text',
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'focused', 'hover']
state: ['toggled', 'focused', 'hover'],
},
directives: {
textColor: '--parent--text'
}
textColor: '--parent--text',
},
},
{
component: 'Text',
parent: {
component: 'ButtonUnstyled',
state: ['disabled']
state: ['disabled'],
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
textOpacityMode: 'blend',
},
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['disabled']
state: ['disabled'],
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
textOpacityMode: 'blend',
},
},
],
}

View file

@ -1,25 +1,27 @@
import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import { throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import ChatMessage from '../chat_message/chat_message.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import chatService from '../../services/chat_service/chat_service.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import {
getNewTopPosition,
getScrollPosition,
isBottomedOut,
isScrollable,
} from './chat_layout_utils.js'
import { useInterfaceStore } from 'src/stores/interface.js'
library.add(
faChevronDown,
faChevronLeft
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
@ -31,78 +33,95 @@ const Chat = {
components: {
ChatMessage,
ChatTitle,
PostStatusForm
PostStatusForm,
},
data () {
data() {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false,
messageRetriers: {}
messageRetriers: {},
}
},
created () {
created() {
this.startFetching()
window.addEventListener('resize', this.handleResize)
},
mounted () {
mounted() {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
}
this.$nextTick(() => {
this.handleResize()
})
},
unmounted () {
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
if (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient () {
recipient() {
return this.currentChat && this.currentChat.account
},
recipientId () {
recipientId() {
return this.$route.params.recipient_id
},
formPlaceholder () {
formPlaceholder() {
if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else {
return ''
}
},
chatViewItems () {
chatViewItems() {
return chatService.getView(this.currentChatMessageService)
},
newMessageCount () {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
newMessageCount() {
return (
this.currentChatMessageService &&
this.currentChatMessageService.newMessageCount
)
},
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
'mergedConfig'
'mergedConfig',
]),
...mapPiniaState(useInterfaceStore, {
mobileLayout: store => store.layoutType === 'mobile'
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapState({
backendInteractor: state => state.api.backendInteractor,
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
currentUser: state => state.users.currentUser
})
backendInteractor: (state) => state.api.backendInteractor,
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
chatViewItems () {
chatViewItems() {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
@ -115,23 +134,23 @@ const Chat = {
$route: function () {
this.startFetching()
},
mastoUserSocketStatus (newValue) {
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
}
},
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover ({ isHovered, messageChainId }) {
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onFilesDropped () {
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleVisibilityChange () {
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
@ -139,7 +158,7 @@ const Chat = {
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize (opts = {}) {
handleResize(opts = {}) {
const { delayed = false } = opts
if (delayed) {
@ -160,40 +179,56 @@ const Chat = {
this.lastScrollPosition = getScrollPosition()
})
},
scrollDown (options = {}) {
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior })
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
readChat () {
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
if (document.hidden) { return }
readChat() {
if (
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', {
id: this.currentChat.id,
lastReadId
lastReadId,
})
},
bottomedOut (offset) {
bottomedOut(offset) {
return isBottomedOut(offset)
},
reachedTop () {
reachedTop() {
return window.scrollY <= 0
},
cullOlderCheck () {
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
}
}, 5000)
},
handleScroll: _.throttle(function () {
handleScroll: throttle(function () {
this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) { return }
if (!this.currentChat) {
return
}
if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
@ -213,22 +248,27 @@ const Chat = {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp (positionBeforeLoading) {
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService
if (!chatMessageService) { return }
if (fetchLatest && this.streamingEnabled) { return }
if (!chatMessageService) {
return
}
if (fetchLatest && this.streamingEnabled) {
return
}
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId
return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
return this.backendInteractor
.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
@ -236,28 +276,34 @@ const Chat = {
}
const positionBeforeUpdate = getScrollPosition()
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
this.$store
.dispatch('addChatMessages', { chatId, messages })
.then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.currentChatMessageService.minId,
})
}
})
})
})
})
},
async startFetching () {
async startFetching() {
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
chat = await this.backendInteractor.getOrCreateChat({
accountId: this.recipientId,
})
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
@ -271,13 +317,14 @@ const Chat = {
this.doStartFetching()
}
},
doStartFetching () {
doStartFetching() {
this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
fetcher: () =>
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
})
this.fetchChat({ isFirstFetch: true })
},
handleAttachmentPosting () {
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
@ -285,11 +332,11 @@ const Chat = {
this.scrollDown({ forceRead: true })
})
},
sendMessage ({ status, media, idempotencyKey }) {
sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
content: status,
idempotencyKey
idempotencyKey,
}
if (media[0]) {
@ -301,52 +348,72 @@ const Chat = {
chatId: this.currentChat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey
idempotencyKey,
})
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage]
}).then(() => {
this.handleAttachmentPosting()
})
this.$store
.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage],
})
.then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
return this.doSendMessage({
params,
fakeMessage,
retriesLeft: MAX_RETRIES,
})
},
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
this.backendInteractor.sendChatMessage(params)
.then(data => {
this.backendInteractor
.sendChatMessage(params)
.then((data) => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }]
messages: [{ ...data, fakeId: fakeMessage.id }],
})
return data
})
.catch(error => {
.catch((error) => {
console.error('Error sending message', error)
this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id,
fakeId: fakeMessage.id,
isRetry: retriesLeft !== MAX_RETRIES
isRetry: retriesLeft !== MAX_RETRIES,
})
if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
this.messageRetriers[fakeMessage.id] = setTimeout(() => {
this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
}, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[fakeMessage.id] = setTimeout(
() => {
this.doSendMessage({
params,
fakeMessage,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
return {}
})
return Promise.resolve(fakeMessage)
},
goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
}
}
goBack() {
this.$router.push({
name: 'chats',
params: { username: this.currentUser.screen_name },
})
},
},
}
export default Chat

View file

@ -1,19 +1,13 @@
export default {
name: 'Chat',
selector: '.chat-message-list',
validInnerComponents: [
'Text',
'Link',
'Icon',
'Avatar',
'ChatMessage'
],
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
defaultRules: [
{
directives: {
background: '--bg',
blur: '5px'
}
}
]
blur: '5px',
},
},
],
}

View file

@ -73,6 +73,7 @@
:disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true"
:disable-quotes="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"

View file

@ -3,14 +3,17 @@ export const getScrollPosition = () => {
return {
scrollTop: window.scrollY,
scrollHeight: document.documentElement.scrollHeight,
offsetHeight: window.innerHeight
offsetHeight: window.innerHeight,
}
}
// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
// Takes two scroll positions, before and after the update.
export const getNewTopPosition = (previousPosition, newPosition) => {
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
return (
previousPosition.scrollTop +
(newPosition.scrollHeight - previousPosition.scrollHeight)
)
}
export const isBottomedOut = (offset = 0) => {

View file

@ -1,37 +1,38 @@
import { mapState, mapGetters } from 'vuex'
import ChatListItem from '../chat_list_item/chat_list_item.vue'
import ChatNew from '../chat_new/chat_new.vue'
import List from '../list/list.vue'
import { mapGetters, mapState } from 'vuex'
import ChatListItem from 'src/components/chat_list_item/chat_list_item.vue'
import ChatNew from 'src/components/chat_new/chat_new.vue'
import List from 'src/components/list/list.vue'
const ChatList = {
components: {
ChatListItem,
List,
ChatNew
ChatNew,
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
currentUser: (state) => state.users.currentUser,
}),
...mapGetters(['sortedChatList'])
...mapGetters(['sortedChatList']),
},
data () {
data() {
return {
isNew: false
isNew: false,
}
},
created () {
created() {
this.$store.dispatch('fetchChats', { latest: true })
},
methods: {
cancelNewChat () {
cancelNewChat() {
this.isNew = false
this.$store.dispatch('fetchChats', { latest: true })
},
newChat () {
newChat() {
this.isNew = true
}
}
},
},
}
export default ChatList

View file

@ -1,31 +1,31 @@
import { mapState } from 'vuex'
import StatusBody from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import StatusBody from 'src/components/status_content/status_content.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
const ChatListItem = {
name: 'ChatListItem',
props: [
'chat'
],
props: ['chat'],
components: {
UserAvatar,
AvatarList,
Timeago,
ChatTitle,
StatusBody
StatusBody,
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
currentUser: (state) => state.users.currentUser,
}),
attachmentInfo () {
if (this.chat.lastMessage.attachments.length === 0) { return }
attachmentInfo() {
if (this.chat.lastMessage.attachments.length === 0) {
return
}
const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
const types = this.chat.lastMessage.attachments.map((file) => file.type)
if (types.includes('video')) {
return this.$t('file_type.video')
} else if (types.includes('audio')) {
@ -36,34 +36,36 @@ const ChatListItem = {
return this.$t('file_type.file')
}
},
messageForStatusContent () {
messageForStatusContent() {
const message = this.chat.lastMessage
const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
const content = message ? this.attachmentInfo || message.content : ''
const messagePreview = isYou
? `<i>${this.$t('chats.you')}</i> ${content}`
: content
return {
summary: '',
emojis: messageEmojis,
raw_html: messagePreview,
text: messagePreview,
attachments: []
attachments: [],
}
}
},
},
methods: {
openChat () {
openChat() {
if (this.chat.id) {
this.$router.push({
name: 'chat',
params: {
username: this.currentUser.screen_name,
recipient_id: this.chat.account.id
}
recipient_id: this.chat.account.id,
},
})
}
}
}
},
},
}
export default ChatListItem

View file

@ -1,24 +1,24 @@
import { mapState, mapGetters } from 'vuex'
import { mapState as mapPiniaState } from 'pinia'
import Popover from '../popover/popover.vue'
import Attachment from '../attachment/attachment.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
import { mapGetters, mapState } from 'vuex'
library.add(
faTimes,
faEllipsisH
)
import Attachment from 'src/components/attachment/attachment.vue'
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
const ChatMessage = {
name: 'ChatMessage',
@ -27,7 +27,7 @@ const ChatMessage = {
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain'
'hoveredMessageChain',
],
emits: ['hover'],
components: {
@ -38,73 +38,80 @@ const ChatMessage = {
Gallery,
LinkPreview,
ChatMessageDate,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
UserPopover,
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt () {
createdAt() {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
isCurrentUser () {
isCurrentUser() {
return this.message.account_id === this.currentUser.id
},
message () {
message() {
return this.chatViewItem.data
},
isMessage () {
isMessage() {
return this.chatViewItem.type === 'message'
},
messageForStatusContent () {
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments
attachments: this.message.attachments,
}
},
hasAttachment () {
hasAttachment() {
return this.message.attachments.length > 0
},
...mapPiniaState(useInterfaceStore, {
betterShadow: store => store.browserSupport.cssFilter
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: state => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
popoverMarginStyle () {
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapGetters(['mergedConfig', 'findUser'])
...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
},
data () {
data() {
return {
hovered: false,
menuOpened: false
menuOpened: false,
}
},
methods: {
onHover (bool) {
this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
})
},
async deleteMessage () {
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
chatId: this.chatViewItem.data.chat_id,
})
}
this.hovered = false
this.menuOpened = false
}
}
},
},
}
export default ChatMessage

View file

@ -2,26 +2,21 @@ export default {
name: 'ChatMessage',
selector: '.chat-message',
variants: {
outgoing: '.outgoing'
outgoing: '.outgoing',
},
validInnerComponents: [
'Text',
'Icon',
'Border',
'PollGraph'
],
validInnerComponents: ['Text', 'Icon', 'Border', 'PollGraph'],
defaultRules: [
{
directives: {
background: '--bg, 2',
backgroundNoCssColor: 'yes'
}
backgroundNoCssColor: 'yes',
},
},
{
variant: 'outgoing',
directives: {
background: '--bg, 5'
}
}
]
background: '--bg, 5',
},
},
],
}

View file

@ -11,16 +11,19 @@ export default {
name: 'Timeago',
props: ['date'],
computed: {
displayDate () {
displayDate() {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' })
return this.date.toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ day: 'numeric', month: 'long' },
)
}
}
}
},
},
}
</script>

View file

@ -1,39 +1,35 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { mapGetters, mapState } from 'vuex'
library.add(
faSearch,
faChevronLeft
)
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const chatNew = {
components: {
BasicUserCard,
UserAvatar
UserAvatar,
},
data () {
data() {
return {
suggestions: [],
userIds: [],
loading: false,
query: ''
query: '',
}
},
async created () {
async created() {
const { chats } = await this.backendInteractor.chats()
chats.forEach(chat => this.suggestions.push(chat.account))
chats.forEach((chat) => this.suggestions.push(chat.account))
},
computed: {
users () {
return this.userIds.map(userId => this.findUser(userId))
users() {
return this.userIds.map((userId) => this.findUser(userId))
},
availableUsers () {
availableUsers() {
if (this.query.length !== 0) {
return this.users
} else {
@ -41,29 +37,29 @@ const chatNew = {
}
},
...mapState({
currentUser: state => state.users.currentUser,
backendInteractor: state => state.api.backendInteractor
currentUser: (state) => state.users.currentUser,
backendInteractor: (state) => state.api.backendInteractor,
}),
...mapGetters(['findUser'])
...mapGetters(['findUser']),
},
methods: {
goBack () {
goBack() {
this.$emit('cancel')
},
goToChat (user) {
goToChat(user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
},
onInput () {
onInput() {
this.search(this.query)
},
addUser (user) {
addUser(user) {
this.selectedUserIds.push(user.id)
this.query = ''
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
removeUser(userId) {
this.selectedUserIds = this.selectedUserIds.filter((id) => id !== userId)
},
search (query) {
search(query) {
if (!query) {
this.loading = false
return
@ -71,13 +67,14 @@ const chatNew = {
this.loading = true
this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then(data => {
this.$store
.dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then((data) => {
this.loading = false
this.userIds = data.accounts.map(a => a.id)
this.userIds = data.accounts.map((a) => a.id)
})
}
}
},
},
}
export default chatNew

View file

@ -1,23 +1,27 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { defineAsyncComponent } from 'vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
name: 'ChatTitle',
components: {
UserAvatar,
RichContent,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
UserPopover,
},
props: [
'user', 'withAvatar'
],
props: ['user', 'withAvatar'],
computed: {
title () {
title() {
return this.user ? this.user.screen_name_ui : ''
},
htmlTitle () {
htmlTitle() {
return this.user ? this.user.name_html : ''
}
}
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
}

View file

@ -19,6 +19,7 @@
:title="'@'+(user && user.screen_name_ui)"
:html="htmlTitle"
:emoji="user.emoji || []"
:allow-non-square-emoji="allowNonSquareEmoji"
:is-local="user.is_local"
/>
</div>

View file

@ -1,7 +1,7 @@
<template>
<label
class="checkbox"
:class="[{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']"
:class="[{ ['-disabled']: disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']"
>
<span
v-if="!!$slots.before"
@ -36,42 +36,39 @@
<script>
export default {
props: [
'radio',
'modelValue',
'indeterminate',
'disabled'
],
props: ['radio', 'modelValue', 'indeterminate', 'disabled'],
emits: ['update:modelValue'],
data: (vm) => ({
indeterminateTransitionFix: vm.indeterminate
indeterminateTransitionFix: vm.indeterminate,
}),
watch: {
indeterminate (e) {
indeterminate(e) {
if (e) {
this.indeterminateTransitionFix = true
}
}
},
},
methods: {
onTransitionEnd () {
onTransitionEnd() {
if (!this.indeterminate) {
this.indeterminateTransitionFix = false
}
}
}
},
},
}
</script>
<style lang="scss">
.checkbox {
position: relative;
display: inline-block;
display: inline-flex;
min-height: 1.2em;
align-items: baseline;
gap: 0 0.5em;
&-indicator,
& .label {
vertical-align: middle;
align-self: center;
}
& > &-indicator {
@ -123,7 +120,7 @@ export default {
.disabled {
.checkbox-indicator::before {
background-color: var(--background);
background-color: transparent;
}
}
@ -143,15 +140,5 @@ export default {
content: "";
}
}
& > .label {
&.-after {
margin-left: 0.5em;
}
&.-before {
margin-right: 0.5em;
}
}
}
</style>

View file

@ -1,18 +1,27 @@
.color-input {
display: inline-flex;
flex-wrap: wrap;
max-width: 10em;
&.-compact {
max-width: none;
}
.label {
flex: 1 1 auto;
grid-area: label;
}
.opt {
grid-area: checkbox;
margin-right: 0.5em;
}
&-field.input {
display: inline-flex;
flex: 0 0 0;
max-width: 9em;
flex: 1 1 10em;
max-width: 10em;
grid-area: input;
display: flex;
align-items: stretch;
input {

View file

@ -1,7 +1,7 @@
<template>
<div
class="color-input style-control"
:class="{ disabled: !present || disabled }"
:class="{ disabled: !present || disabled, '-compact': compact }"
>
<label
:for="name"
@ -64,91 +64,96 @@
</div>
</template>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { throttle } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEyeDropper
} from '@fortawesome/free-solid-svg-icons'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
library.add(
faEyeDropper
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEyeDropper } from '@fortawesome/free-solid-svg-icons'
library.add(faEyeDropper)
export default {
components: {
Checkbox
Checkbox,
},
props: {
// Name of color, used for identifying
name: {
required: true,
type: String
type: String,
},
// Readable label
label: {
required: true,
type: String
required: false,
type: String,
default: '',
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean
type: Boolean,
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined
default: undefined,
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined
default: undefined,
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false
default: false,
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true
default: true,
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false
}
default: false,
},
compact: {
required: false,
type: Boolean,
},
},
emits: ['update:modelValue'],
computed: {
present () {
present() {
return typeof this.modelValue !== 'undefined'
},
validColor () {
validColor() {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor () {
transparentColor() {
return this.modelValue === 'transparent'
},
computedColor () {
return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
}
computedColor() {
return (
this.modelValue &&
(this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
)
},
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100)
}
}, 100),
},
}
</script>
<style lang="scss" src="./color_input.scss"></style>

View file

@ -2,12 +2,15 @@ import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
export default {
components: {
Checkbox,
ColorInput
ColorInput,
},
props: [
'shadow',
@ -17,41 +20,41 @@ export default {
'previewCss',
'disabled',
'invalid',
'noColorControl'
'noColorControl',
],
emits: ['update:shadow'],
data () {
data() {
return {
colorOverride: undefined,
lightGrid: false,
zoom: 100,
randomSeed: genRandomSeed()
randomSeed: genRandomSeed(),
}
},
mounted () {
mounted() {
this.update()
},
computed: {
hideControls () {
hideControls() {
return typeof this.shadow === 'string'
}
},
},
watch: {
previewCss () {
previewCss() {
this.update()
},
previewStyle () {
previewStyle() {
this.update()
},
zoom () {
zoom() {
this.update()
}
},
},
methods: {
updateProperty (axis, value) {
updateProperty(axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) })
},
update () {
update() {
const sheet = createStyleSheet('style-component-preview', 90)
sheet.clear()
@ -60,23 +63,25 @@ export default {
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
const styleRule = [
'#component-preview-', this.randomSeed, ' {\n',
'#component-preview-',
this.randomSeed,
' {\n',
'.preview-block {\n',
`zoom: ${this.zoom / 100};`,
this.previewStyle,
'\n}',
'\n}'
'\n}',
].join('')
sheet.addRule(styleRule)
sheet.addRule([
'#component-preview-', this.randomSeed, ' {\n',
...result,
'\n}'
].join(''))
sheet.addRule(
['#component-preview-', this.randomSeed, ' {\n', ...result, '\n}'].join(
'',
),
)
sheet.ready = true
adoptStyleSheets()
}
}
},
},
}

View file

@ -104,6 +104,7 @@
v-model="colorOverride"
class="input-color-input"
fallback="#606060"
:compact="true"
:label="$t('settings.style.shadows.color_override')"
/>
</div>

View file

@ -1,4 +1,4 @@
import DialogModal from '../dialog_modal/dialog_modal.vue'
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
/**
* This component emits the following events:
@ -9,30 +9,32 @@ import DialogModal from '../dialog_modal/dialog_modal.vue'
*/
const ConfirmModal = {
components: {
DialogModal
DialogModal,
},
props: {
title: {
type: String
type: String,
},
cancelText: {
type: String
type: String,
},
confirmText: {
type: String
}
type: String,
},
confirmDanger: {
type: Boolean,
},
},
emits: ['cancelled', 'accepted'],
computed: {
},
computed: {},
methods: {
onCancel () {
onCancel() {
this.$emit('cancelled')
},
onAccept () {
onAccept() {
this.$emit('accepted')
}
}
},
},
}
export default ConfirmModal

View file

@ -14,6 +14,7 @@
<slot name="footerLeft" />
<button
class="btn button-default"
:class="{ '-danger': confirmDanger }"
@click.prevent="onAccept"
v-text="confirmText"
/>

View file

@ -4,37 +4,37 @@ import ConfirmModal from './confirm_modal.vue'
export default {
props: {
title: {
type: String
type: String,
},
message: {
type: String
type: String,
},
cancelText: {
type: String
type: String,
},
confirmText: {
type: String
}
type: String,
},
},
emits: ['hide', 'show', 'action'],
data: () => ({
showing: false
showing: false,
}),
components: {
ConfirmModal
ConfirmModal,
},
methods: {
show () {
show() {
this.showing = true
this.$emit('show')
},
hide () {
hide() {
this.showing = false
this.$emit('hide')
},
doGeneric () {
doGeneric() {
this.$emit('action')
this.hide()
}
}
},
},
}

View file

@ -1,70 +1,78 @@
import { mapGetters } from 'vuex'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import ConfirmModal from './confirm_modal.vue'
import Select from 'src/components/select/select.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
props: ['type', 'user', 'status'],
emits: ['hide', 'show', 'muted'],
data: () => ({
showing: false
showing: false,
}),
components: {
ConfirmModal,
Select
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
Select,
},
computed: {
domain () {
domain() {
return this.user.fqn.split('@')[1]
},
keypath () {
keypath() {
if (this.type === 'domain') {
return 'status.mute_domain_confirm'
return 'user_card.mute_domain_confirm'
} else if (this.type === 'conversation') {
return 'status.mute_conversation_confirm'
return 'user_card.mute_conversation_confirm'
}
},
conversationIsMuted () {
conversationIsMuted() {
return this.status.conversation_muted
},
domainIsMuted () {
return new Set(this.$store.state.users.currentUser.domainMutes).has(this.domain)
domainIsMuted() {
return new Set(this.$store.state.users.currentUser.domainMutes).has(
this.domain,
)
},
shouldConfirm () {
shouldConfirm() {
switch (this.type) {
case 'domain': {
return this.mergedConfig.modalOnMuteDomain
}
default: { // conversation
default: {
// conversation
return this.mergedConfig.modalOnMuteConversation
}
}
},
...mapGetters(['mergedConfig'])
...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
optionallyPrompt () {
optionallyPrompt() {
if (this.shouldConfirm) {
this.show()
} else {
this.doMute()
}
},
show () {
show() {
this.showing = true
this.$emit('show')
},
hide () {
hide() {
this.showing = false
this.$emit('hide')
},
doMute () {
doMute() {
switch (this.type) {
case 'domain': {
if (!this.domainIsMuted) {
this.$store.dispatch('muteDomain', { id: this.domain })
this.$store.dispatch('muteDomain', this.domain)
} else {
this.$store.dispatch('unmuteDomain', { id: this.domain })
this.$store.dispatch('unmuteDomain', this.domain)
}
break
}
@ -79,6 +87,6 @@ export default {
}
this.$emit('muted')
this.hide()
}
}
},
},
}

View file

@ -1,5 +1,5 @@
<template>
<confirm-modal
<ConfirmModal
v-if="showing"
:title="$t('user_card.mute_confirm_title')"
:confirm-text="$t('user_card.mute_confirm_accept_button')"
@ -18,7 +18,7 @@
<span v-text="user.screen_name_ui" />
</template>
</i18n-t>
</confirm-modal>
</ConfirmModal>
</template>
<script src="./mute_confirm.js" />

View file

@ -4,38 +4,38 @@ import ConfirmModal from './confirm_modal.vue'
export default {
props: {
title: {
type: String
type: String,
},
message: {
type: String
type: String,
},
cancelText: {
type: String
type: String,
},
confirmText: {
type: String
}
type: String,
},
},
emits: ['hide', 'show', 'action'],
data: () => ({
showing: false,
text: ""
text: '',
}),
components: {
ConfirmModal
ConfirmModal,
},
methods: {
show () {
show() {
this.showing = true
this.$emit('show')
},
hide () {
hide() {
this.showing = false
this.$emit('hide')
},
doWithText () {
doWithText() {
this.$emit('action', this.text)
this.hide()
}
}
},
},
}

View file

@ -63,54 +63,68 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAdjust,
faExclamationTriangle,
faThumbsUp
faThumbsUp,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAdjust,
faExclamationTriangle,
faThumbsUp
)
library.add(faAdjust, faExclamationTriangle, faThumbsUp)
export default {
components: {
Tooltip
Tooltip,
},
props: {
large: {
required: false,
type: Boolean,
default: false
default: false,
},
// TODO: Make theme switcher compute theme initially so that contrast
// component won't be called without contrast data
contrast: {
required: false,
type: Object,
default: () => ({})
default: () => ({
/* no-op */
}),
},
showRatio: {
required: false,
type: Boolean,
default: false
}
default: false,
},
},
computed: {
hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
hint() {
const levelVal = this.contrast.aaa
? 'aaa'
: this.contrast.aa
? 'aa'
: 'bad'
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.text')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
return this.$t('settings.style.common.contrast.hint', {
level,
context,
ratio,
})
},
hint_18pt () {
const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad')
hint_18pt() {
const levelVal = this.contrast.laaa
? 'aaa'
: this.contrast.laa
? 'aa'
: 'bad'
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.18pt')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
}
}
return this.$t('settings.style.common.contrast.hint', {
level,
context,
ratio,
})
},
},
}
</script>

View file

@ -1,14 +1,14 @@
import Conversation from '../conversation/conversation.vue'
import Conversation from 'src/components/conversation/conversation.vue'
const conversationPage = {
components: {
Conversation
Conversation,
},
computed: {
statusId () {
statusId() {
return this.$route.params.id
}
}
},
},
}
export default conversationPage

View file

@ -1,25 +1,23 @@
import { reduce, filter, findIndex, clone, get } from 'lodash'
import Status from '../status/status.vue'
import ThreadTree from '../thread_tree/thread_tree.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
import { mapState } from 'vuex'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
faChevronLeft,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
)
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@ -43,23 +41,25 @@ const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById)
return conversation.filter((_) => _).sort(sortById)
}
const conversation = {
data () {
data() {
return {
highlight: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null,
loadStatusError: null
loadStatusError: null,
}
},
props: [
@ -69,76 +69,80 @@ const conversation = {
'pinnedStatusIdsObject',
'inProfile',
'profileUserId',
'virtualHidden'
'virtualHidden',
],
created () {
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault () {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
displayStyle() {
return this.mergedConfig.conversationDisplay
},
isTreeView () {
isTreeView() {
return !this.isLinearView
},
treeViewIsSimple () {
return !this.$store.getters.mergedConfig.conversationTreeAdvanced
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isLinearView () {
isLinearView() {
return this.displayStyle === 'linear'
},
shouldFadeAncestors () {
return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition () {
return this.$store.getters.mergedConfig.conversationOtherRepliesButton
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus () {
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus () {
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable () {
suspendable() {
if (this.isTreeView) {
return Object.entries(this.statusContentProperties)
.every(([, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
return Object.entries(this.statusContentProperties).every(
([, prop]) => !prop.replying && prop.mediaPlaying.length === 0,
)
}
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.$refs.statusComponent.every(s => s.suspendable)
return this.$refs.statusComponent.every((s) => s.suspendable)
} else {
return true
}
},
hideStatus () {
hideStatus() {
return this.virtualHidden && this.suspendable
},
status () {
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId () {
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId () {
conversationId() {
return this.getConversationId(this.statusId)
},
conversation () {
conversation() {
if (!this.status) {
return []
}
@ -147,7 +151,9 @@ const conversation = {
return [this.status]
}
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
@ -155,144 +161,188 @@ const conversation = {
return sortAndFilterConversation(conversation, this.status)
},
statusMap () {
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree () {
const reverseLookupTable = this.conversation.reduce((table, status, index) => {
table[status.id] = index
return table
}, {})
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce((a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id)
.map(s => s.id)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
}, {
forest: {}
})
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
if (processed[id]) {
return []
}
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [{
status: this.conversation[reverseLookupTable[id]],
id,
depth
}, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
}).reduce((a, b) => a.concat(b), [])
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].reduce((a, b) => a.concat(b), [])
})
.reduce((a, b) => a.concat(b), [])
const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds () {
return this.conversation.map(k => k.id)
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map(k => k.id)
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount () {
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map(k => k.id).map(subTreeSizeFor)
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth () {
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map(k => k.id).map(subTreeDepthFor)
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths () {
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel () {
const topLevel = this.conversation.reduce((tl, cur) =>
tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
this.getReplies(cur.id)
.map((v) => v.id)
.indexOf(k.id) === -1,
),
this.conversation,
)
return topLevel
},
otherTopLevelCount () {
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel () {
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot () {
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth () {
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode () {
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton () {
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors () {
return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies () {
replies() {
let i = 1
return reduce(this.conversation, (result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id
})
}
i++
return result
}, {})
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded () {
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle () {
hiddenStyle() {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus () {
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
@ -300,7 +350,7 @@ const conversation = {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
@ -311,7 +361,7 @@ const conversation = {
return a
}, {})
},
statusContentProperties () {
statusContentProperties() {
return this.conversation.reduce((a, k) => {
const id = k.id
const props = (() => {
@ -320,13 +370,13 @@ const conversation = {
expandingSubject: false,
showingLongSubject: false,
isReplying: false,
mediaPlaying: []
mediaPlaying: [],
}
if (this.statusContentPropertiesObject[id]) {
return {
...def,
...this.statusContentPropertiesObject[id]
...this.statusContentPropertiesObject[id],
}
}
return def
@ -336,54 +386,58 @@ const conversation = {
return a
}, {})
},
canDive () {
canDive() {
return this.isTreeView && this.isExpanded
},
maybeHighlight () {
maybeHighlight() {
return this.isExpanded ? this.highlight : null
},
...mapGetters(['mergedConfig']),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: store => store.layoutType === 'mobile'
})
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
Status,
ThreadTree,
QuickFilterSettings,
QuickViewSettings
QuickViewSettings,
},
watch: {
statusId (newVal, oldVal) {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded (value) {
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden () {
this.$store.dispatch(
'setVirtualHeight',
{ statusId: this.statusId, height: `${this.$el.clientHeight}px` }
)
}
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation () {
fetchConversation() {
if (this.status) {
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
this.$store.state.api.backendInteractor
.fetchConversation({ id: this.statusId })
.then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
@ -391,7 +445,8 @@ const conversation = {
})
} else {
this.loadStatusError = null
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
this.$store.state.api.backendInteractor
.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
@ -401,16 +456,16 @@ const conversation = {
})
}
},
isFocused (id) {
return (this.isExpanded) && id === this.highlight
isFocused(id) {
return this.isExpanded && id === this.highlight
},
getReplies (id) {
getReplies(id) {
return this.replies[id] || []
},
getHighlight () {
getHighlight() {
return this.isExpanded ? this.highlight : null
},
setHighlight (id) {
setHighlight(id) {
if (!id) return
this.highlight = id
@ -421,44 +476,54 @@ const conversation = {
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded () {
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId (statusId) {
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay (id, nextStatus) {
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus
[id]: nextStatus,
}
},
toggleThreadDisplay (id) {
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively (id, nextStatus) {
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively (id) {
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
setStatusContentProperty (id, name, value) {
setStatusContentProperty(id, name, value) {
this.statusContentPropertiesObject = {
...this.statusContentPropertiesObject,
[id]: {
...this.statusContentPropertiesObject[id],
[name]: value
}
[name]: value,
},
}
},
toggleStatusContentProperty (id, name) {
this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
toggleStatusContentProperty(id, name) {
this.setStatusContentProperty(
id,
name,
!this.statusContentProperties[id][name],
)
},
leastVisibleAncestor (id) {
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
@ -472,18 +537,20 @@ const conversation = {
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus (id) {
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel () {
this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive () {
undive() {
this.inlineDivePosition = null
this.setHighlight(this.statusId)
},
tryScrollTo (id) {
tryScrollTo(id) {
if (!id) {
return
}
@ -512,13 +579,13 @@ const conversation = {
this.setHighlight(id)
})
},
goToCurrent () {
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById (id) {
statusById(id) {
return this.statusMap[id]
},
parentOf (id) {
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
@ -529,11 +596,11 @@ const conversation = {
}
return parentId
},
parentOrSelf (id) {
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf (id) {
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
@ -542,7 +609,7 @@ const conversation = {
}
return ancestors
},
topLevelAncestorOrSelfId (id) {
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
@ -551,11 +618,11 @@ const conversation = {
}
return cur
},
resetDisplayState () {
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
}
}
},
},
}
export default conversation

View file

@ -88,38 +88,34 @@
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<status
<Status
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:statusoid="status"
:replies="getReplies(status.id)"
:expandable="!isExpanded"
:focused="isFocused(status.id)"
:highlight="getHighlight()"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
:simple-tree="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
:dive="() => diveIntoStatus(status.id)"
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-replying="statusContentProperties[status.id].replying"
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
:controlled-replying="statusContentProperties[status.id].replying"
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight"
@ -195,26 +191,18 @@
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="isFocused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:statusoid="status"
:replies="getReplies(status.id)"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
:expandable="!isExpanded"
:focused="isFocused(status.id)"
:highlight="getHighlight()"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
@goto="setHighlight"
@toggle-expanded="toggleExpanded"

View file

@ -1,20 +1,25 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faHome,
faInfoCircle,
faSearch,
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add(
faSignInAlt,
@ -27,91 +32,100 @@ library.add(
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
faInfoCircle,
)
export default {
components: {
SearchBar,
ConfirmModal
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data: () => ({
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
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
window.CSS.supports('-o-mask-size', 'contain')),
showingConfirmLogout: false,
}),
computed: {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
enableMask() {
return this.supportsMask && this.logoMask
},
logoStyle() {
return {
visibility: this.enableMask ? 'hidden' : 'visible'
visibility: this.enableMask ? 'hidden' : 'visible',
}
},
logoMaskStyle () {
logoMaskStyle() {
return this.enableMask
? {
'mask-image': `url(${this.$store.state.instance.logo})`
'mask-image': `url(${this.logo})`,
}
: {
'background-color': this.enableMask ? '' : 'transparent'
'background-color': this.enableMask ? '' : 'transparent',
}
},
logoBgStyle () {
return Object.assign({
margin: `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask
? {}
: {
'background-color': this.enableMask ? '' : 'transparent'
})
logoBgStyle() {
return Object.assign(
{
margin: `${this.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0,
},
this.enableMask
? {}
: {
'background-color': this.enableMask ? '' : 'transparent',
},
)
},
...mapState(useInstanceStore, ['privateMode']),
...mapState(useInstanceStore, {
logoMask: (store) => store.instanceIdentity.logoMask,
logo: (store) => store.instanceIdentity.logo,
logoLeft: (store) => store.instanceIdentity.logoLeft,
logoMargin: (store) => store.instanceIdentity.logoMargin,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
currentUser() {
return this.$store.state.users.currentUser
},
shouldConfirmLogout() {
return useMergedConfigStore().mergedConfig.modalOnLogout
},
logo () { return this.$store.state.instance.logo },
sitename () { return this.$store.state.instance.name },
hideSitename () { return this.$store.state.instance.hideSitename },
logoLeft () { return this.$store.state.instance.logoLeft },
currentUser () { return this.$store.state.users.currentUser },
privateMode () { return this.$store.state.instance.private },
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
}
},
methods: {
scrollToTop () {
scrollToTop() {
window.scrollTo(0, 0)
},
showConfirmLogout () {
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout () {
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout () {
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout () {
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
onSearchBarToggled (hidden) {
onSearchBarToggled(hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () {
useInterfaceStore().openSettingsModal('user')
},
openAdminModal () {
useInterfaceStore().openSettingsModal('admin')
}
}
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}

View file

@ -9,7 +9,7 @@
.inner-nav {
display: grid;
grid-template-rows: var(--navbar-height);
grid-template-columns: 2fr auto 2fr;
grid-template-columns: minmax(5em, 1fr) auto minmax(5em, 1fr);
grid-template-areas: "sitename logo actions";
box-sizing: border-box;
padding: 0 1.2em;
@ -31,7 +31,7 @@
}
&.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-columns: auto minmax(5em, 1fr) minmax(5em, 1fr);
grid-template-areas: "logo sitename actions";
}
@ -92,23 +92,18 @@
.actions {
grid-area: actions;
justify-content: flex-end;
text-align: right;
z-index: 1;
}
.item {
flex: 1;
line-height: var(--navbar-height);
height: var(--navbar-height);
overflow: hidden;
display: flex;
flex-wrap: wrap;
&.right {
justify-content: flex-end;
text-align: right;
}
}
.spacer {
width: 1em;
min-width: 1em;
}
}

View file

@ -32,61 +32,64 @@
>
</router-link>
<div class="item right actions">
<search-bar
<SearchBar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop
/>
<button
class="button-unstyled nav-icon"
:title="$t('nav.preferences')"
@click.stop="openSettingsModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
/>
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="button-unstyled nav-icon"
target="_blank"
:title="$t('nav.administration')"
@click.stop="openAdminModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
/>
</button>
<span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
:title="$t('login.logout')"
@click.stop.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
/>
</button>
<template v-if="searchBarHidden">
<button
class="button-unstyled nav-icon"
:title="$t('nav.preferences')"
@click.stop="openSettingsModal('user')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
/>
</button>
<button
v-if="currentUser && currentUser.role === 'admin'"
class="button-unstyled nav-icon"
target="_blank"
:title="$t('nav.administration')"
@click.stop="openSettingsModal('admin')"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
/>
</button>
<span class="spacer" />
<button
v-if="currentUser"
class="button-unstyled nav-icon"
:title="$t('login.logout')"
@click.stop.prevent="logout"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
/>
</button>
</template>
</div>
</div>
<teleport to="#modal">
<confirm-modal
<ConfirmModal
v-if="showingConfirmLogout"
:title="$t('login.logout_confirm_title')"
:confirm-danger="true"
:confirm-text="$t('login.logout_confirm_accept_button')"
:cancel-text="$t('login.logout_confirm_cancel_button')"
@accepted="doLogout"
@cancelled="hideConfirmLogout"
>
{{ $t('login.logout_confirm') }}
</confirm-modal>
</ConfirmModal>
</teleport>
</nav>
</template>

View file

@ -1,19 +1,23 @@
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const DialogModal = {
props: {
darkOverlay: {
default: true,
type: Boolean
type: Boolean,
},
onCancel: {
default: () => {},
type: Function
}
default: () => {
/* no-op */
},
type: Function,
},
},
computed: {
mobileCenter () {
return this.$store.getters.mergedConfig.modalMobileCenter
}
}
mobileCenter() {
return useMergedConfigStore().mergedConfig.modalMobileCenter
},
},
}
export default DialogModal

View file

@ -1,14 +1,14 @@
import Timeline from '../timeline/timeline.vue'
import Timeline from 'src/components/timeline/timeline.vue'
const DMs = {
computed: {
timeline () {
timeline() {
return this.$store.state.statuses.timelines.dms
}
},
},
components: {
Timeline
}
Timeline,
},
}
export default DMs

View file

@ -1,26 +1,26 @@
import ProgressButton from '../progress_button/progress_button.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
const DomainMuteCard = {
props: ['domain'],
components: {
ProgressButton
ProgressButton,
},
computed: {
user () {
user() {
return this.$store.state.users.currentUser
},
muted () {
muted() {
return this.user.domainMutes.includes(this.domain)
}
},
},
methods: {
unmuteDomain () {
unmuteDomain() {
return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain () {
muteDomain() {
return this.$store.dispatch('muteDomain', this.domain)
}
}
},
},
}
export default DomainMuteCard

View file

@ -1,42 +1,44 @@
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import EditStatusForm from 'src/components/edit_status_form/edit_status_form.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import { cloneDeep } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Gallery from 'src/components/gallery/gallery.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faPollH
} from '@fortawesome/free-solid-svg-icons'
import { faPollH } from '@fortawesome/free-solid-svg-icons'
library.add(
faPollH
)
library.add(faPollH)
const Draft = {
components: {
PostStatusForm,
EditStatusForm,
ConfirmModal,
EditStatusForm: defineAsyncComponent(
() => import('src/components/edit_status_form/edit_status_form.vue'),
),
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
StatusContent,
Gallery
Gallery,
},
props: {
draft: {
type: Object,
required: true
}
required: true,
},
},
data () {
data() {
return {
referenceDraft: cloneDeep(this.draft),
editing: false,
showingConfirmDialog: false
showingConfirmDialog: false,
}
},
computed: {
relAttrs () {
relAttrs() {
if (this.draft.type === 'edit') {
return { statusId: this.draft.refId }
} else if (this.draft.type === 'reply') {
@ -45,24 +47,29 @@ const Draft = {
return {}
}
},
safeToSave () {
return this.draft.status ||
safeToSave() {
return (
this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll
this.draft.hasPoll ||
this.draft.hasQuote
)
},
postStatusFormProps () {
postStatusFormProps() {
return {
draftId: this.draft.id,
...this.relAttrs
...this.relAttrs,
}
},
refStatus () {
return this.draft.refId ? this.$store.state.statuses.allStatusesObject[this.draft.refId] : undefined
refStatus() {
return this.draft.refId
? this.$store.state.statuses.allStatusesObject[this.draft.refId]
: undefined
},
localCollapseSubjectDefault () {
return this.$store.getters.mergedConfig.collapseMessageWithSubject
localCollapseSubjectDefault() {
return useMergedConfigStore().mergedConfig.collapseMessageWithSubject
},
nsfwClickthrough () {
nsfwClickthrough() {
if (!this.draft.nsfw) {
return false
}
@ -70,35 +77,34 @@ const Draft = {
return false
}
return true
}
},
},
watch: {
editing (newVal) {
editing(newVal) {
if (newVal) return
if (this.safeToSave) {
this.$store.dispatch('addOrSaveDraft', { draft: this.draft })
} else {
this.$store.dispatch('addOrSaveDraft', { draft: this.referenceDraft })
}
}
},
},
methods: {
toggleEditing () {
toggleEditing() {
this.editing = !this.editing
},
abandon () {
abandon() {
this.showingConfirmDialog = true
},
doAbandon () {
this.$store.dispatch('abandonDraft', { id: this.draft.id })
.then(() => {
this.hideConfirmDialog()
})
doAbandon() {
this.$store.dispatch('abandonDraft', { id: this.draft.id }).then(() => {
this.hideConfirmDialog()
})
},
hideConfirmDialog () {
hideConfirmDialog() {
this.showingConfirmDialog = false
}
}
},
},
}
export default Draft

View file

@ -39,7 +39,7 @@
class="faint"
>{{ $t('drafts.empty') }}</p>
</span>
<gallery
<Gallery
v-if="draft.files?.length !== 0"
class="attachments media-body"
:compact="true"
@ -77,7 +77,7 @@
/>
</div>
<teleport to="#modal">
<confirm-modal
<ConfirmModal
v-if="showingConfirmDialog"
:title="$t('drafts.abandon_confirm_title')"
:confirm-text="$t('drafts.abandon_confirm_accept_button')"
@ -86,7 +86,7 @@
@cancelled="hideConfirmDialog"
>
{{ $t('drafts.abandon_confirm') }}
</confirm-modal>
</ConfirmModal>
</teleport>
<div class="actions">
<button

View file

@ -1,32 +1,33 @@
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import { defineAsyncComponent } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const DraftCloser = {
data () {
data() {
return {
showing: false
showing: false,
}
},
components: {
DialogModal
DialogModal: defineAsyncComponent(
() => import('src/components/dialog_modal/dialog_modal.vue'),
),
},
emits: [
'save',
'discard'
],
emits: ['save', 'discard'],
computed: {
action () {
if (this.$store.getters.mergedConfig.autoSaveDraft) {
action() {
if (useMergedConfigStore().mergedConfig.autoSaveDraft) {
return 'save'
} else {
return this.$store.getters.mergedConfig.unsavedPostAction
return useMergedConfigStore().mergedConfig.unsavedPostAction
}
},
shouldConfirm () {
shouldConfirm() {
return this.action === 'confirm'
}
},
},
methods: {
requestClose () {
requestClose() {
if (this.shouldConfirm) {
this.showing = true
} else if (this.action === 'save') {
@ -35,18 +36,18 @@ const DraftCloser = {
this.discard()
}
},
save () {
save() {
this.$emit('save')
this.showing = false
},
discard () {
discard() {
this.$emit('discard')
this.showing = false
},
cancel () {
cancel() {
this.showing = false
}
}
},
},
}
export default DraftCloser

View file

@ -1,6 +1,6 @@
<template>
<teleport to="#modal">
<dialog-modal
<DialogModal
v-if="showing"
v-body-scroll-lock="true"
class="confirm-modal"
@ -36,7 +36,7 @@
{{ $t('post_status.close_confirm_continue_composing_button') }}
</button>
</template>
</dialog-modal>
</DialogModal>
</teleport>
</template>

View file

@ -1,16 +1,39 @@
import { defineAsyncComponent } from 'vue'
import Draft from 'src/components/draft/draft.vue'
import List from 'src/components/list/list.vue'
const Drafts = {
components: {
Draft,
List
List,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data() {
return {
showingConfirmDialog: false,
}
},
computed: {
drafts () {
drafts() {
return this.$store.getters.draftsArray
}
}
},
},
methods: {
abandonAll() {
this.showingConfirmDialog = true
},
doAbandonAll() {
this.$store
.dispatch('abandonAllDrafts')
.then(() => this.hideConfirmDialog())
},
hideConfirmDialog() {
this.showingConfirmDialog = false
},
},
}
export default Drafts

View file

@ -13,36 +13,66 @@
>
{{ $t('drafts.no_drafts') }}
</div>
<List
v-else
:items="drafts"
:non-interactive="true"
>
<template #item="{ item: draft }">
<Draft
class="draft"
:draft="draft"
/>
</template>
</List>
<template v-else>
<List
:items="drafts"
:non-interactive="true"
>
<template #item="{ item: draft }">
<Draft
class="draft"
:draft="draft"
/>
</template>
</List>
<div class="remove-all">
<button
class="btn -danger button-default"
@click="abandonAll"
>
{{ $t('drafts.clean_drafts') }}
</button>
</div>
</template>
</div>
</div>
<teleport to="#modal">
<ConfirmModal
v-if="showingConfirmDialog"
:confirm-danger="true"
:title="$t('drafts.abandon_confirm_title')"
:confirm-text="$t('drafts.abandon_confirm_accept_button')"
:cancel-text="$t('drafts.abandon_confirm_cancel_button')"
@accepted="doAbandonAll"
@cancelled="hideConfirmDialog"
>
{{ $t('drafts.abandon_all_confirm') }}
</ConfirmModal>
</teleport>
</div>
</template>
<script src="./drafts.js"></script>
<style lang="scss">
.draft {
margin: 1em 0;
}
.Drafts {
.draft {
margin: 1em 0;
}
.empty-drafs-list-alert {
padding: 3em;
font-size: 1.2em;
display: flex;
justify-content: center;
color: var(--textFaint);
.remove-all {
margin: 1em;
display: flex;
justify-content: center;
}
.empty-drafs-list-alert {
padding: 3em;
font-size: 1.2em;
display: flex;
justify-content: center;
color: var(--textFaint);
}
}
</style>

View file

@ -1,21 +1,21 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
const EditStatusForm = {
components: {
PostStatusForm
PostStatusForm,
},
props: {
params: {
type: Object,
required: true
}
required: true,
},
},
methods: {
requestClose () {
requestClose() {
this.$refs.postStatusForm.requestClose()
},
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
doEditStatus({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.params.statusId,
@ -24,21 +24,22 @@ const EditStatusForm = {
sensitive,
poll,
media,
contentType
contentType,
}
return statusPosterService.editStatus(params)
return statusPosterService
.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
error: err.message,
}
})
}
}
},
},
}
export default EditStatusForm

View file

@ -4,6 +4,7 @@
v-bind="params"
:post-handler="doEditStatus"
:disable-polls="true"
:disable-quotes="true"
:disable-visibility-selector="true"
/>
</template>

View file

@ -1,34 +1,38 @@
import EditStatusForm from '../edit_status_form/edit_status_form.vue'
import Modal from '../modal/modal.vue'
import get from 'lodash/get'
import { useEditStatusStore } from 'src/stores/editStatus'
import { get } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Modal from 'src/components/modal/modal.vue'
import { useEditStatusStore } from 'src/stores/editStatus.js'
const EditStatusModal = {
components: {
EditStatusForm,
Modal
EditStatusForm: defineAsyncComponent(
() => import('src/components/edit_status_form/edit_status_form.vue'),
),
Modal,
},
data () {
data() {
return {
resettingForm: false
resettingForm: false,
}
},
computed: {
isLoggedIn () {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
modalActivated () {
modalActivated() {
return useEditStatusStore().modalActivated
},
isFormVisible () {
isFormVisible() {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params () {
params() {
return useEditStatusStore().params || {}
}
},
},
watch: {
params (newVal, oldVal) {
params(newVal, oldVal) {
if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
@ -36,20 +40,22 @@ const EditStatusModal = {
})
}
},
isFormVisible (val) {
isFormVisible(val) {
if (val) {
this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
this.$nextTick(
() => this.$el && this.$el.querySelector('textarea').focus(),
)
}
}
},
},
methods: {
closeModal () {
closeModal() {
this.$refs.editStatusForm.requestClose()
},
doCloseModal () {
doCloseModal() {
useEditStatusStore().closeEditStatusModal()
}
}
},
},
}
export default EditStatusModal

View file

@ -1,20 +1,20 @@
import Completion from '../../services/completion/completion.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { take } from 'lodash'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam
} from '@fortawesome/free-regular-svg-icons'
import Completion from '../../services/completion/completion.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
library.add(
faSmileBeam
)
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
@ -60,14 +60,14 @@ const EmojiInput = {
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function
type: Function,
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String
type: String,
},
enableEmojiPicker: {
/**
@ -75,7 +75,7 @@ const EmojiInput = {
*/
required: false,
type: Boolean,
default: false
default: false,
},
hideEmojiButton: {
/**
@ -84,7 +84,7 @@ const EmojiInput = {
*/
required: false,
type: Boolean,
default: false
default: false,
},
enableStickerPicker: {
/**
@ -92,7 +92,7 @@ const EmojiInput = {
*/
required: false,
type: Boolean,
default: false
default: false,
},
placement: {
/**
@ -101,15 +101,15 @@ const EmojiInput = {
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto'
default: 'auto',
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false
}
default: false,
},
},
data () {
data() {
return {
randomSeed: genRandomSeed(),
input: undefined,
@ -122,58 +122,65 @@ const EmojiInput = {
disableClickOutside: false,
suggestions: [],
overlayStyle: {},
pickerShown: false
pickerShown: false,
}
},
components: {
Popover,
EmojiPicker,
UnicodeDomainIndicator,
ScreenReaderNotice
ScreenReaderNotice,
},
computed: {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
padEmoji() {
return useMergedConfigStore().mergedConfig.padEmoji
},
defaultCandidateIndex () {
return this.$store.getters.mergedConfig.autocompleteSelect ? 0 : -1
defaultCandidateIndex() {
return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1
},
preText () {
preText() {
return this.modelValue.slice(0, this.caret)
},
postText () {
postText() {
return this.modelValue.slice(this.caret)
},
showSuggestions () {
return this.focused &&
showSuggestions() {
return (
this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.pickerShown &&
!this.temporarilyHideSuggestions
)
},
textAtCaret () {
textAtCaret() {
return this.wordAtCaret?.word
},
wordAtCaret () {
wordAtCaret() {
if (this.modelValue && this.caret) {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
},
languages () {
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiNamesAndKeywords () {
return emoji => {
maybeLocalizedEmojiNamesAndKeywords() {
return (emoji) => {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
names.push(
this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args),
)
}
if (emoji.annotations) {
this.languages.forEach(lang => {
this.languages.forEach((lang) => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
@ -181,13 +188,13 @@ const EmojiInput = {
}
return {
names: names.filter(k => k),
keywords: keywords.filter(k => k)
names: names.filter((k) => k),
keywords: keywords.filter((k) => k),
}
}
},
maybeLocalizedEmojiName () {
return emoji => {
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
@ -205,16 +212,18 @@ const EmojiInput = {
return emoji.displayText
}
},
suggestionListId () {
suggestionListId() {
return `suggestions-${this.randomSeed}`
},
suggestionItemId () {
suggestionItemId() {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
}
},
},
mounted () {
mounted() {
const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.caretEl = hiddenOverlayCaret
@ -243,7 +252,7 @@ const EmojiInput = {
input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
},
unmounted () {
unmounted() {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
@ -273,36 +282,40 @@ const EmojiInput = {
this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
const matchedSuggestions = await this.suggest(
newWord,
this.maybeLocalizedEmojiNamesAndKeywords,
)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || ''
}))
img: imageUrl || '',
}),
)
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length
)
this.suggestions.length,
),
)
}
},
},
methods: {
onInputScroll (e) {
onInputScroll(e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
left: this.input.scrollLeft,
})
this.setCaret(e)
},
triggerShowPicker () {
triggerShowPicker() {
this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView()
@ -315,22 +328,25 @@ const EmojiInput = {
this.disableClickOutside = false
}, 0)
},
togglePicker () {
togglePicker() {
this.input.focus()
if (!this.pickerShown) {
this.scrollIntoView()
this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad()
} else {
this.$refs.picker.hidePicker()
}
},
replace (replacement) {
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert ({ insertion, keepOpen, surroundingSpace = true }) {
insert({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
@ -349,18 +365,24 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const newValue = [
before,
spaceBefore,
insertion,
spaceAfter,
after
].join('')
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
'',
)
this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
@ -372,13 +394,20 @@ const EmojiInput = {
this.caret = position
})
},
replaceText (e, suggestion) {
replaceText(e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) { return }
if (this.textAtCaret.length === 1) {
return
}
if (len > 0 || suggestion) {
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
@ -393,7 +422,7 @@ const EmojiInput = {
e.preventDefault()
}
},
cycleBackward (e) {
cycleBackward(e) {
const len = this.suggestions.length || 0
this.highlighted -= 1
@ -406,7 +435,7 @@ const EmojiInput = {
e.preventDefault()
}
},
cycleForward (e) {
cycleForward(e) {
const len = this.suggestions.length || 0
this.highlighted += 1
@ -418,26 +447,28 @@ const EmojiInput = {
e.preventDefault()
}
},
scrollIntoView () {
scrollIntoView() {
const rootRef = this.$refs.picker.$el
/* 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 scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll = scrollerRef === window
? scrollerRef.scrollY
: scrollerRef.scrollTop
const scrollerHeight = scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
@ -459,13 +490,13 @@ const EmojiInput = {
}
})
},
onPickerShown () {
onPickerShown() {
this.pickerShown = true
},
onPickerClosed () {
onPickerClosed() {
this.pickerShown = false
},
onBlur (e) {
onBlur(e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
@ -473,10 +504,10 @@ const EmojiInput = {
this.setCaret(e)
}, 200)
},
onClick (e, suggestion) {
onClick(e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus (e) {
onFocus(e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
@ -486,7 +517,7 @@ const EmojiInput = {
this.setCaret(e)
this.temporarilyHideSuggestions = false
},
onKeyUp (e) {
onKeyUp(e) {
const { key } = e
this.setCaret(e)
@ -498,10 +529,10 @@ const EmojiInput = {
this.temporarilyHideSuggestions = false
}
},
onPaste (e) {
onPaste(e) {
this.setCaret(e)
},
onKeyDown (e) {
onKeyDown(e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
@ -545,30 +576,30 @@ const EmojiInput = {
}
}
},
onInput (e) {
onInput(e) {
this.setCaret(e)
this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded (e) {
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
onStickerUploadFailed(e) {
this.$emit('sticker-upload-Failed', e)
},
setCaret ({ target: { selectionStart } }) {
setCaret({ target: { selectionStart } }) {
this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover.updateStyles()
this.$refs.suggestorPopover?.updateStyles()
})
},
autoCompleteItemLabel (suggestion) {
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
}
}
},
},
}
export default EmojiInput

View file

@ -2,7 +2,7 @@
<div
ref="root"
class="input emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
:class="{ '-with-picker': !hideEmojiButton, '-textarea': input?.tagName === 'TEXTAREA' }"
>
<slot
:id="'textbox-' + randomSeed"
@ -37,7 +37,7 @@
:title="$t('emoji.add_emoji')"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'smile-beam']" />
<FAIcon :icon="['far', 'face-smile-beam']" />
</button>
<EmojiPicker
v-if="enableEmojiPicker"
@ -118,9 +118,10 @@
.emoji-picker-icon {
position: absolute;
top: 0;
bottom: 0;
right: 0;
margin: 0.2em 0.25em;
height: 100%;
padding: 0 0.2em;
font-size: 1.3em;
cursor: pointer;
line-height: 1.2em;
@ -130,6 +131,13 @@
}
}
&.-textarea {
.emoji-picker-icon {
height: auto;
padding: 0.2em;
}
}
.emoji-picker-panel {
position: absolute;
z-index: 20;
@ -151,8 +159,11 @@
outline: none;
}
&.with-picker input {
padding-right: 2em;
&.-with-picker {
textarea,
input {
padding-right: 2.4em;
}
}
.hidden-overlay {

View file

@ -1,8 +1,10 @@
import { useEmojiStore } from 'src/stores/emoji.js'
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (getters.standardEmojiList + state.instance.customEmoji)
* (useEmojiStore().standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
@ -10,7 +12,7 @@
* doesn't support user linking you can just provide only emoji.
*/
export default data => {
export default (data) => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return (input, nameKeywordLocalizer) => {
@ -25,22 +27,35 @@ export default data => {
}
}
export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
.map(k => {
.map((emoji) => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.filter(
(emoji) =>
emoji.names
.concat(emoji.keywords)
.filter((kw) => kw.toLowerCase().match(noPrefix)).length,
)
.map((k) => {
let score = 0
// An exact match always wins
score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
score += Math.max(
...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)),
0,
)
// Prioritize custom emoji a lot
score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
score += Math.max(
...k.names.map((kw) =>
kw.toLowerCase().startsWith(noPrefix) ? 10 : 0,
),
0,
)
// Sort by length
score -= k.displayText.length
@ -78,7 +93,7 @@ export const suggestUsers = ({ dispatch, state }) => {
})
}
return async input => {
return async (input) => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
@ -92,37 +107,43 @@ export const suggestUsers = ({ dispatch, state }) => {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users.filter(
user =>
user.screen_name && user.name && (
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix))
).slice(0, 20).sort((a, b) => {
let aScore = 0
let bScore = 0
const newSuggestions = state.users.users
.filter(
(user) =>
user.screen_name &&
user.name &&
(user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)),
)
.slice(0, 20)
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
// Then sort alphabetically
const activity = a.last_status_at < b.last_status_at ? 100 : -100
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically
}).map((user) => ({
user,
displayText: user.screen_name_ui,
detailText: user.name,
imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' '
}))
return diff + nameAlphabetically + screenNameAlphabetically + activity
})
.map((user) => ({
user,
displayText: user.screen_name_ui,
detailText: user.name,
imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' ',
}))
suggestions = newSuggestions || []
return suggestions

View file

@ -1,24 +1,29 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import StillImage from '../still-image/still-image.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faBoxOpen,
faBus,
faCode,
faFlag
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
import { debounce, trim, chunk } from 'lodash'
library.add(
faBoxOpen,
@ -32,7 +37,7 @@ library.add(
faBasketballBall,
faLightbulb,
faCode,
faFlag
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
@ -44,16 +49,16 @@ const UNICODE_EMOJI_GROUP_ICON = {
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag'
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach(lang => {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...(keywords.concat([name]).filter(k => k)))
res.push(...keywords.concat([name]).filter((k) => k))
})
}
return res
@ -66,8 +71,8 @@ const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map(k => k.toLowerCase().indexOf(keywordLowercase))
.filter(k => k > -1)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
@ -84,11 +89,13 @@ const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) { return 0 }
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = id => {
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
@ -97,20 +104,20 @@ const EmojiPicker = {
enableStickerPicker: {
required: false,
type: Boolean,
default: true
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false
}
default: false,
},
},
inject: {
popoversZLayer: {
default: ''
}
default: '',
},
},
data () {
data() {
return {
keyword: '',
activeGroup: 'custom',
@ -121,24 +128,27 @@ const EmojiPicker = {
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
popoverShown: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
StickerPicker: defineAsyncComponent(
() => import('src/components/sticker_picker/sticker_picker.vue'),
),
Checkbox,
StillImage,
Popover
Popover,
},
methods: {
groupScroll (e) {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize () {
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
@ -163,56 +173,75 @@ const EmojiPicker = {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + (2 * 0.2 * fontSizeMultiplier * 14)
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
showPicker () {
togglePicker() {
if (this.popoverShown) {
this.hidePicker()
} else {
this.showPicker()
}
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker () {
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl (el) {
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef (name) {
return el => { this.groupRefs[name] = el }
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown () {
this.$emit('show')
onPopoverShown() {
this.popoverShown = true
},
onPopoverClosed () {
this.$emit('close')
onPopoverClosed() {
this.popoverShown = false
},
onStickerUploaded (e) {
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed (e) {
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
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 })
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup (target, start, end) {
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach(group => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) { return }
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) { return }
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
@ -220,7 +249,7 @@ const EmojiPicker = {
this.scrollHeader()
})
},
scrollHeader () {
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
@ -228,7 +257,9 @@ const EmojiPicker = {
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = s => { headerCont.scrollLeft = s }
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
@ -237,12 +268,12 @@ const EmojiPicker = {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight (groupId) {
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex(k => k.id === groupId)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass (target) {
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
@ -251,16 +282,21 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers () {
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers (value) {
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword (list, keyword) {
return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing () {
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
@ -277,108 +313,111 @@ const EmojiPicker = {
})
}
},
getFilteredEmojiGroups () {
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map(group => ({
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter(group => group.emojis.length > 0)
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow () {
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
}
},
},
watch: {
keyword () {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups () {
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}
},
},
computed: {
minItemSize () {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize () {
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return this.$store.getters.mergedConfig.fontSize
return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight () {
emojiHeight() {
return this.emojiSize
},
itemPerRow () {
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView () {
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable () {
if (this.$store.state.instance.stickers) {
return this.$store.state.instance.stickers.length > 0
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return 0
},
allCustomGroups () {
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = this.$store.getters.groupedCustomEmojis
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup () {
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups () {
return this.$store.getters.standardEmojiGroupList.map(group => ({
unicodeEmojiGroups() {
return useEmojiStore().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
emojis: group.emojis,
}))
},
allEmojiGroups () {
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange () {
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems () {
return this.filteredEmojiGroups.map(group =>
chunk(group.emojis, this.itemPerRow)
.map((items, index) => ({
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
})))
isFirstRow: index === 0,
})),
)
.reduce((a, c) => a.concat(c), [])
},
languages () {
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName () {
return emoji => {
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
@ -396,10 +435,10 @@ const EmojiPicker = {
return emoji.displayText
}
},
isInModal () {
isInModal() {
return this.popoversZLayer === 'modals'
}
}
},
},
}
export default EmojiPicker

View file

@ -4,6 +4,7 @@
trigger="click"
popover-class="emoji-picker popover-default"
:hide-trigger="true"
placement="bottom"
@show="onPopoverShown"
@close="onPopoverClosed"
>
@ -48,7 +49,7 @@
v-if="group.image"
class="emoji-picker-header-image"
>
<still-image
<StillImage
:alt="group.text"
:src="group.image"
/>
@ -130,7 +131,7 @@
v-if="!emoji.imageUrl"
class="emoji-picker-emoji -unicode"
>{{ emoji.replacement }}</span>
<still-image
<StillImage
v-else
class="emoji-picker-emoji -custom"
loading="lazy"

View file

@ -1,18 +1,13 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faPlus,
faMinus,
faCheck
} from '@fortawesome/free-solid-svg-icons'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
library.add(
faPlus,
faMinus,
faCheck
)
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCheck, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
library.add(faPlus, faMinus, faCheck)
const EMOJI_REACTION_COUNT_CUTOFF = 12
@ -21,57 +16,64 @@ const EmojiReactions = {
components: {
UserAvatar,
UserListPopover,
StillImage
},
props: ['status'],
data: () => ({
showAll: false
showAll: false,
}),
computed: {
tooManyReactions () {
tooManyReactions() {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions () {
emojiReactions() {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString () {
showMoreString() {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji () {
accountsForEmoji() {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn () {
loggedIn() {
return !!this.$store.state.users.currentUser
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
remoteInteractionLink() {
return useInstanceStore().getRemoteInteractionLink({
statusId: this.status.id,
})
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
methods: {
toggleShowAll () {
toggleShowAll() {
this.showAll = !this.showAll
},
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
reactedWith(emoji) {
return this.status.emoji_reactions.find((r) => r.name === emoji).me
},
async fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
async fetchEmojiReactionsByIfMissing() {
const hasNoAccounts = this.status.emoji_reactions.find((r) => !r.accounts)
if (hasNoAccounts) {
return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
return await this.$store.dispatch(
'fetchEmojiReactionsBy',
this.status.id,
)
}
},
reactWith (emoji) {
reactWith(emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
},
unreact (emoji) {
unreact(emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
async emojiOnClick (emoji) {
async emojiOnClick(emoji) {
if (!this.loggedIn) return
await this.fetchEmojiReactionsByIfMissing()
@ -81,19 +83,23 @@ const EmojiReactions = {
this.reactWith(emoji)
}
},
counterTriggerAttrs (reaction) {
counterTriggerAttrs(reaction) {
return {
class: [
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),
toggled: this.reactedWith(reaction.name)
}
toggled: this.reactedWith(reaction.name),
},
],
'aria-label': this.$t('status.reaction_count_label', { num: reaction.count }, reaction.count)
'aria-label': this.$t(
'status.reaction_count_label',
{ num: reaction.count },
reaction.count,
),
}
}
}
},
},
}
export default EmojiReactions

View file

@ -49,6 +49,12 @@
justify-content: center;
align-items: center;
&.-wide {
width: auto;
min-width: var(--emoji-size);
max-width: calc(var(--emoji-size) * 3);
}
--_still_image-label-scale: 0.3;
}
@ -62,6 +68,12 @@
font-size: calc(var(--emoji-size) * 0.8);
margin: 0;
&.-wide {
width: auto;
min-width: var(--emoji-size);
max-width: calc(var(--emoji-size) * 3);
}
img {
object-fit: contain;
}

View file

@ -17,11 +17,13 @@
>
<span
class="reaction-emoji"
:class="{ ['-wide']: allowNonSquareEmoji }"
>
<StillImage
v-if="reaction.url"
:src="reaction.url"
class="reaction-emoji-content"
:class="{ ['-wide']: allowNonSquareEmoji }"
/>
<span
v-else

View file

@ -1,45 +1,47 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleNotch
)
library.add(faCircleNotch)
const Exporter = {
props: {
getContent: {
type: Function,
required: true
required: true,
},
filename: {
type: String,
default: 'export.csv'
default: 'export.csv',
},
exportButtonLabel: { type: String },
processingMessage: { type: String }
processingMessage: { type: String },
},
data () {
data() {
return {
processing: false
processing: false,
}
},
methods: {
process () {
process() {
this.processing = true
this.getContent()
.then((content) => {
const fileToDownload = document.createElement('a')
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
fileToDownload.setAttribute('download', this.filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
// Add delay before hiding processing state since browser takes some time to handle file download
setTimeout(() => { this.processing = false }, 2000)
})
}
}
this.getContent().then((content) => {
const fileToDownload = document.createElement('a')
fileToDownload.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(content),
)
fileToDownload.setAttribute('download', this.filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
// Add delay before hiding processing state since browser takes some time to handle file download
setTimeout(() => {
this.processing = false
}, 2000)
})
},
},
}
export default Exporter

View file

@ -23,6 +23,9 @@
<style lang="scss">
.exporter {
display: flex;
flex-direction: column;
&-processing {
margin: 0.25em;
}

View file

@ -1,55 +1,75 @@
import { mapGetters } from 'vuex'
import { mapState as mapPiniaState } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { mapGetters } from 'vuex'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUserPlus,
faBullhorn,
faComments,
faBullhorn
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add(
faUserPlus,
faComments,
faBullhorn
)
library.add(faUserPlus, faComments, faBullhorn)
const ExtraNotifications = {
computed: {
shouldShowChats () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showChatsInExtraNotifications && this.unreadChatCount
shouldShowChats() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showChatsInExtraNotifications &&
this.unreadChatCount
)
},
shouldShowAnnouncements () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showAnnouncementsInExtraNotifications && this.unreadAnnouncementCount
shouldShowAnnouncements() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showAnnouncementsInExtraNotifications &&
this.unreadAnnouncementCount
)
},
shouldShowFollowRequests () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showFollowRequestsInExtraNotifications && this.followRequestCount
shouldShowFollowRequests() {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showFollowRequestsInExtraNotifications &&
this.followRequestCount
)
},
hasAnythingToShow () {
return this.shouldShowChats || this.shouldShowAnnouncements || this.shouldShowFollowRequests
hasAnythingToShow() {
return (
this.shouldShowChats ||
this.shouldShowAnnouncements ||
this.shouldShowFollowRequests
)
},
shouldShowCustomizationTip () {
return this.mergedConfig.showExtraNotificationsTip && this.hasAnythingToShow
shouldShowCustomizationTip() {
return (
this.mergedConfig.showExtraNotificationsTip && this.hasAnythingToShow
)
},
currentUser () {
currentUser() {
return this.$store.state.users.currentUser
},
...mapGetters(['unreadChatCount', 'followRequestCount', 'mergedConfig']),
...mapGetters(['unreadChatCount', 'followRequestCount']),
...mapPiniaState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount'
})
unreadAnnouncementCount: 'unreadAnnouncementCount',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
openNotificationSettings () {
openNotificationSettings() {
return useInterfaceStore().openSettingsModalTab('notifications')
},
dismissConfigurationTip () {
return this.$store.dispatch('setOption', { name: 'showExtraNotificationsTip', value: false })
}
}
dismissConfigurationTip() {
return useSyncConfigStore().setSimplePrefAndSave({
path: 'showExtraNotificationsTip',
value: false,
})
},
},
}
export default ExtraNotifications

View file

@ -1,16 +1,25 @@
import { mapState } from 'pinia'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
const FeaturesPanel = {
computed: {
shout: function () { return this.$store.state.instance.shoutAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode },
textlimit: function () { return this.$store.state.instance.textlimit },
uploadlimit: function () { return fileSizeFormatService.fileSizeFormat(this.$store.state.instance.uploadlimit) }
}
...mapState(useInstanceCapabilitiesStore, [
'shoutAvailable',
'pleromaChatMessagesAvailable',
'gopherAvailable',
'suggestionsEnabled',
'mediaProxyAvailable',
]),
...mapState(useInstanceStore, {
textLimit: (store) => store.limits.textLimit,
uploadlimit: (store) =>
fileSizeFormatService.fileSizeFormat(store.limits.uploadlimit),
}),
},
}
export default FeaturesPanel

View file

@ -8,23 +8,23 @@
</div>
<div class="panel-body">
<ul>
<li v-if="shout">
<li v-if="shoutAvailable">
{{ $t('features_panel.shout') }}
</li>
<li v-if="pleromaChatMessages">
<li v-if="pleromaChatMessagesAvailable">
{{ $t('features_panel.pleroma_chat_messages') }}
</li>
<li v-if="gopher">
<li v-if="gopherAvailable">
{{ $t('features_panel.gopher') }}
</li>
<li v-if="whoToFollow">
<li v-if="suggestionsEnabled">
{{ $t('features_panel.who_to_follow') }}
</li>
<li v-if="mediaProxy">
<li v-if="mediaProxyAvailable">
{{ $t('features_panel.media_proxy') }}
</li>
<li>{{ $t('features_panel.scope_options') }}</li>
<li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
<li>{{ $t('features_panel.text_limit') }} = {{ textLimit }}</li>
<li>{{ $t('features_panel.upload_limit') }} = {{ uploadlimit.num }} {{ $t('upload.file_size_units.' + uploadlimit.unit) }}</li>
</ul>
</div>

View file

@ -1,53 +1,54 @@
import RuffleService from '../../services/ruffle_service/ruffle_service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faStop,
faExclamationTriangle
} from '@fortawesome/free-solid-svg-icons'
library.add(
faStop,
faExclamationTriangle
)
library.add(faStop, faExclamationTriangle)
const Flash = {
props: ['src'],
data () {
data() {
return {
player: false, // can be true, "hidden", false. hidden = element exists
loaded: false,
ruffleInstance: null
ruffleInstance: null,
}
},
methods: {
openPlayer () {
openPlayer() {
if (this.player) return // prevent double-loading, or re-loading on failure
this.player = 'hidden'
RuffleService.getRuffle().then((ruffle) => {
const player = ruffle.newest().createPlayer()
player.config = {
letterbox: 'on'
letterbox: 'on',
}
const container = this.$refs.container
container.appendChild(player)
player.style.width = '100%'
player.style.height = '100%'
player.load(this.src).then(() => {
this.player = true
}).catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
player
.load(this.src)
.then(() => {
this.player = true
})
.catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
this.ruffleInstance = player
this.$emit('playerOpened')
})
},
closePlayer () {
closePlayer() {
this.ruffleInstance && this.ruffleInstance.remove()
this.player = false
this.$emit('playerClosed')
}
}
},
},
}
export default Flash

View file

@ -1,24 +1,32 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import { defineAsyncComponent } from 'vue'
import {
requestFollow,
requestUnfollow,
} from '../../services/follow_manipulate/follow_manipulate'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
components: {
ConfirmModal
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data () {
data() {
return {
inProgress: false,
showingConfirmUnfollow: false
showingConfirmUnfollow: false,
}
},
computed: {
shouldConfirmUnfollow () {
return this.$store.getters.mergedConfig.modalOnUnfollow
shouldConfirmUnfollow() {
return useMergedConfigStore().mergedConfig.modalOnUnfollow
},
isPressed () {
isPressed() {
return this.inProgress || this.relationship.following
},
title () {
title() {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
@ -27,7 +35,7 @@ export default {
return this.$t('user_card.follow')
}
},
label () {
label() {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else if (this.relationship.following) {
@ -38,42 +46,47 @@ export default {
return this.$t('user_card.follow')
}
},
disabled () {
disabled() {
return this.inProgress || this.user.deactivated
}
},
},
methods: {
showConfirmUnfollow () {
showConfirmUnfollow() {
this.showingConfirmUnfollow = true
},
hideConfirmUnfollow () {
hideConfirmUnfollow() {
this.showingConfirmUnfollow = false
},
onClick () {
this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
onClick() {
this.relationship.following || this.relationship.requested
? this.unfollow()
: this.follow()
},
follow () {
follow() {
this.inProgress = true
requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false
})
},
unfollow () {
unfollow() {
if (this.shouldConfirmUnfollow) {
this.showConfirmUnfollow()
} else {
this.doUnfollow()
}
},
doUnfollow () {
doUnfollow() {
const store = this.$store
this.inProgress = true
requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
store.commit('removeStatus', {
timeline: 'friends',
userId: this.relationship.id,
})
})
this.hideConfirmUnfollow()
}
}
},
},
}

View file

@ -8,7 +8,7 @@
>
{{ label }}
<teleport to="#modal">
<confirm-modal
<ConfirmModal
v-if="showingConfirmUnfollow"
:title="$t('user_card.unfollow_confirm_title')"
:confirm-text="$t('user_card.unfollow_confirm_accept_button')"
@ -27,7 +27,7 @@
/>
</template>
</i18n-t>
</confirm-modal>
</ConfirmModal>
</teleport>
</button>
</template>

View file

@ -1,30 +1,27 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue'
import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import FollowButton from 'src/components/follow_button/follow_button.vue'
import RemoteFollow from 'src/components/remote_follow/remote_follow.vue'
import RemoveFollowerButton from 'src/components/remove_follower_button/remove_follower_button.vue'
const FollowCard = {
props: [
'user',
'noFollowsYou'
],
props: ['user', 'noFollowsYou'],
components: {
BasicUserCard,
RemoteFollow,
FollowButton,
RemoveFollowerButton
RemoveFollowerButton,
},
computed: {
isMe () {
isMe() {
return this.$store.state.users.currentUser.id === this.user.id
},
loggedIn () {
loggedIn() {
return this.$store.state.users.currentUser
},
relationship () {
relationship() {
return this.$store.getters.relationship(this.user.id)
}
}
},
},
}
export default FollowCard

View file

@ -1,46 +1,53 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { defineAsyncComponent } from 'vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const FollowRequestCard = {
props: ['user'],
components: {
BasicUserCard,
ConfirmModal
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data () {
data() {
return {
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false
showingDenyConfirmDialog: false,
}
},
methods: {
findFollowRequestNotificationId () {
findFollowRequestNotificationId() {
const notif = notificationsFromStore(this.$store).find(
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
(notif) =>
notif.from_profile.id === this.user.id &&
notif.type === 'follow_request',
)
return notif && notif.id
},
showApproveConfirmDialog () {
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog () {
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog () {
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog () {
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser () {
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove () {
doApprove() {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
@ -48,40 +55,41 @@ const FollowRequestCard = {
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: notification => {
updater: (notification) => {
notification.type = 'follow'
}
},
})
this.hideApproveConfirmDialog()
},
denyUser () {
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny () {
doDeny() {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
this.$store.state.api.backendInteractor
.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
}
},
},
computed: {
mergedConfig () {
return this.$store.getters.mergedConfig
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
shouldConfirmApprove () {
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny () {
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
}
}
},
},
}
export default FollowRequestCard

View file

@ -15,7 +15,7 @@
</button>
</div>
<teleport to="#modal">
<confirm-modal
<ConfirmModal
v-if="showingApproveConfirmDialog"
:title="$t('user_card.approve_confirm_title')"
:confirm-text="$t('user_card.approve_confirm_accept_button')"
@ -24,8 +24,8 @@
@cancelled="hideApproveConfirmDialog"
>
{{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
</confirm-modal>
<confirm-modal
</ConfirmModal>
<ConfirmModal
v-if="showingDenyConfirmDialog"
:title="$t('user_card.deny_confirm_title')"
:confirm-text="$t('user_card.deny_confirm_accept_button')"
@ -34,7 +34,7 @@
@cancelled="hideDenyConfirmDialog"
>
{{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
</confirm-modal>
</ConfirmModal>
</teleport>
</basic-user-card>
</template>

View file

@ -1,14 +1,14 @@
import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
import FollowRequestCard from 'src/components/follow_request_card/follow_request_card.vue'
const FollowRequests = {
components: {
FollowRequestCard
FollowRequestCard,
},
computed: {
requests () {
requests() {
return this.$store.state.api.followRequests
}
}
},
},
}
export default FollowRequests

View file

@ -1,35 +1,32 @@
import Select from '../select/select.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { useInterfaceStore } from 'src/stores/interface'
import Select from 'src/components/select/select.vue'
import LocalSettingIndicator from 'src/components/settings_modal/helpers/local_setting_indicator.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faFont,
faKeyboard,
faFont
} from '@fortawesome/free-solid-svg-icons'
library.add(
faExclamationTriangle,
faKeyboard,
faFont
)
library.add(faExclamationTriangle, faKeyboard, faFont)
export default {
components: {
Select,
Checkbox,
Popover
Popover,
LocalSettingIndicator,
},
props: [
'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
],
mounted () {
props: ['name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'],
mounted() {
useInterfaceStore().queryLocalFonts()
},
emits: ['update:modelValue'],
data () {
data() {
return {
manualEntry: false,
availableOptions: [
@ -37,24 +34,24 @@ export default {
'serif',
'sans-serif',
'monospace',
...(this.options || [])
].filter(_ => _)
...(this.options || []),
].filter((_) => _),
}
},
methods: {
toggleManualEntry () {
toggleManualEntry() {
this.manualEntry = !this.manualEntry
}
},
},
computed: {
present () {
return typeof this.modelValue !== 'undefined'
present() {
return this.modelValue != null
},
localFontsList () {
localFontsList() {
return useInterfaceStore().localFonts
},
localFontsSize () {
localFontsSize() {
return useInterfaceStore().localFonts?.length
}
}
},
},
}

View file

@ -1,25 +1,30 @@
<template>
<div class="font-control">
<label
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.label', { label }) }}
</label>
<div class="setting-item">
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="font-checkbox setting-control setting-label"
:model-value="present"
@change="$emit('update:modelValue', modelValue == null ? fallback : null)"
>
<LocalSettingIndicator />
{{ ' ' }}
<i18n-t
scope="global"
keypath="settings.style.fonts.override"
tag="span"
>
<span>
{{ label }}
</span>
</i18n-t>
</Checkbox>
</div>
{{ ' ' }}
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
class="font-checkbox"
:model-value="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
{{ $t('settings.style.themes3.define') }}
</Checkbox>
<div
v-if="modelValue?.family"
class="font-input"
v-if="modelValue"
class="font-input setting-item"
>
<label
v-if="manualEntry"
@ -62,15 +67,15 @@
</button>
<input
:id="name"
:model-value="modelValue.family"
:model-value="modelValue"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', { ...(modelValue || {}), family: $event.target.value })"
@update:modelValue="$emit('update:modelValue', $event.target.value)"
>
</span>
<span
v-else
class="btn-group"
class="font-selector btn-group"
>
<button
class="btn button-default"
@ -84,9 +89,9 @@
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue?.family"
:model-value="modelValue"
class="custom-font"
@update:model-value="v => $emit('update:modelValue', { ...(modelValue || {}), family: v })"
@update:model-value="v => $emit('update:modelValue', v)"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
@ -133,22 +138,6 @@
<script src="./font_control.js"></script>
<style lang="scss">
.font-control {
.custom-font {
min-width: 20em;
max-width: 20em;
}
.font-input {
margin-left: 2em;
margin-top: 0.5em;
}
.font-checkbox {
margin-left: 1em;
}
}
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;

View file

@ -1,11 +1,14 @@
import Timeline from '../timeline/timeline.vue'
import Timeline from 'src/components/timeline/timeline.vue'
const FriendsTimeline = {
components: {
Timeline
Timeline,
},
computed: {
timeline () { return this.$store.state.statuses.timelines.friends }
}
timeline() {
return this.$store.state.statuses.timelines.friends
},
},
}
export default FriendsTimeline

View file

@ -4,37 +4,37 @@ export default {
virtual: true,
variants: {
greentext: '.greentext',
cyantext: '.cyantext'
cyantext: '.cyantext',
},
states: {
faint: '.faint'
faint: '.faint',
},
defaultRules: [
{
directives: {
textColor: '--text',
textAuto: 'preserve'
}
textAuto: 'preserve',
},
},
{
state: ['faint'],
directives: {
textOpacity: 0.5
}
textOpacity: 0.5,
},
},
{
variant: 'greentext',
directives: {
textColor: '--cGreen',
textAuto: 'preserve'
}
textAuto: 'preserve',
},
},
{
variant: 'cyantext',
directives: {
textColor: '--cBlue',
textAuto: 'preserve'
}
}
]
textAuto: 'preserve',
},
},
],
}

View file

@ -1,6 +1,10 @@
import { useMediaViewerStore } from 'src/stores/media_viewer'
import Attachment from '../attachment/attachment.vue'
import { sumBy, set } from 'lodash'
import { set, sumBy } from 'lodash'
import Attachment from 'src/components/attachment/attachment.vue'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
const displayTypes = new Set(['image', 'video', 'flash'])
const Gallery = {
props: [
@ -17,52 +21,79 @@ const Gallery = {
'shiftUpAttachment',
'shiftDnAttachment',
'editAttachment',
'grid'
'grid',
],
data () {
data() {
return {
sizes: {},
hidingLong: true
hidingLong: true,
}
},
components: { Attachment },
computed: {
rows () {
rows() {
if (!this.attachments) {
return []
}
const attachments = this.limit > 0
? this.attachments.slice(0, this.limit)
: this.attachments
const attachments =
this.limit > 0
? this.attachments.slice(0, this.limit)
: this.attachments
if (this.size === 'hide') {
return attachments.map(item => ({ minimal: true, items: [item] }))
return attachments.map((item) => ({ minimal: true, items: [item] }))
}
const rows = this.grid
? [{ grid: true, items: attachments }]
: attachments.reduce((acc, attachment, i) => {
if (attachment.mimetype.includes('audio')) {
return [...acc, { audio: true, items: [attachment] }, { items: [] }]
}
if (!(
attachment.mimetype.includes('image') ||
attachment.mimetype.includes('video') ||
attachment.mimetype.includes('flash')
)) {
return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
}
const maxPerRow = 3
const attachmentsRemaining = this.attachments.length - i + 1
const currentRow = acc[acc.length - 1].items
currentRow.push(attachment)
if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
return [...acc, { items: [] }]
} else {
return acc
}
}, [{ items: [] }]).filter(_ => _.items.length > 0)
: attachments
.reduce(
(acc, attachment, i) => {
const peek = attachments[i + 1]
const nextEnd = peek == null
const nextWide = !nextEnd && !displayTypes.has(peek?.type)
// Inserting new row
if (attachment.type === 'audio') {
return [
...acc,
{ audio: true, items: [attachment] },
{ items: [] },
]
}
if (!displayTypes.has(attachment.type)) {
return [
...acc,
{ minimal: true, items: [attachment] },
{ items: [] },
]
}
const maxPerRow = 3
const currentRow = acc[acc.length - 1]
const previousRow = acc[acc.length - 2]
if (currentRow.items.length >= maxPerRow) {
if (nextWide || nextEnd) {
if (previousRow?.items.length > 1) {
currentRow.items.push(attachment)
return [...acc, { items: [] }]
} else {
const last = currentRow.items.splice(-1)[0]
return [...acc, { items: [last, attachment] }]
}
} else {
return [...acc, { items: [attachment] }]
}
} else {
currentRow.items.push(attachment)
}
return acc
},
[{ items: [] }],
)
.filter((_) => _.items.length > 0)
return rows
},
attachmentsDimensionalScore () {
attachmentsDimensionalScore() {
return this.rows.reduce((acc, row) => {
let size = 0
if (row.minimal) {
@ -75,7 +106,7 @@ const Gallery = {
return acc + size
}, 0)
},
tooManyAttachments () {
tooManyAttachments() {
if (this.editable || this.size === 'small') {
return false
} else if (this.size === 'hide') {
@ -83,38 +114,38 @@ const Gallery = {
} else {
return this.attachmentsDimensionalScore > 1
}
}
},
},
methods: {
onNaturalSizeLoad ({ id, width, height }) {
onNaturalSizeLoad({ id, width, height }) {
set(this.sizes, id, { width, height })
},
rowStyle (row) {
rowStyle(row) {
if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
return { 'padding-bottom': `${100 / (row.items.length + 0.6)}%` }
}
},
itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id))
itemStyle(id, row) {
const total = sumBy(row, (item) => this.getAspectRatio(item.id))
return { flex: `${this.getAspectRatio(id) / total} 1 0%` }
},
getAspectRatio (id) {
getAspectRatio(id) {
const size = this.sizes[id]
return size ? size.width / size.height : 1
},
toggleHidingLong (event) {
toggleHidingLong(event) {
this.hidingLong = event
},
openGallery () {
openGallery() {
useMediaViewerStore().setMedia(this.attachments)
useMediaViewerStore().setCurrentMedia(this.attachments[0])
},
onMedia () {
onMedia() {
useMediaViewerStore().setMedia(this.attachments)
}
}
},
},
}
export default Gallery

View file

@ -129,7 +129,7 @@
.gallery-item {
margin: 0;
height: 15em;
height: 20em;
}
}
}

View file

@ -1,24 +1,21 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
import { useInterfaceStore } from 'src/stores/interface.js'
library.add(
faTimes
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const GlobalNoticeList = {
computed: {
notices () {
notices() {
return useInterfaceStore().globalNotices
}
},
},
methods: {
closeNotice (notice) {
closeNotice(notice) {
useInterfaceStore().removeGlobalNotice(notice)
}
}
},
},
}
export default GlobalNoticeList

View file

@ -5,20 +5,20 @@ const HashtagLink = {
props: {
url: {
required: true,
type: String
type: String,
},
content: {
required: true,
type: String
type: String,
},
tag: {
required: false,
type: String,
default: ''
}
default: '',
},
},
methods: {
onClick () {
onClick() {
const tag = this.tag || extractTagFromUrl(this.url)
if (tag) {
const link = this.generateTagLink(tag)
@ -27,10 +27,10 @@ const HashtagLink = {
window.open(this.url, '_blank')
}
},
generateTagLink (tag) {
generateTagLink(tag) {
return `/tag/${tag}`
}
}
},
},
}
export default HashtagLink

View file

@ -7,8 +7,8 @@ export default {
component: 'Icon',
directives: {
textColor: '$blend(--stack 0.5 --parent--text)',
textAuto: 'no-auto'
}
}
]
textAuto: 'no-auto',
},
},
],
}

View file

@ -1,29 +1,26 @@
import 'cropperjs' // This adds all of the cropperjs's components into DOM
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCircleNotch
)
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const ImageCropper = {
props: {
// Mime-types to accept, i.e. which filetypes to accept (.gif, .png, etc.)
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon',
},
// Fixed aspect-ratio for selection box
aspectRatio: {
type: Number
}
type: Number,
},
},
data () {
data() {
return {
dataUrl: undefined,
filename: undefined
filename: undefined,
}
},
emits: [
@ -31,12 +28,12 @@ const ImageCropper = {
'close', // cropper is closed
],
methods: {
destroy () {
destroy() {
this.$refs.input.value = ''
this.dataUrl = undefined
this.$emit('close')
},
submit (cropping = true) {
submit(cropping = true) {
let cropperPromise
if (cropping) {
cropperPromise = this.$refs.cropperSelection.$toCanvas()
@ -44,14 +41,14 @@ const ImageCropper = {
cropperPromise = Promise.resolve()
}
cropperPromise.then(canvas => {
cropperPromise.then((canvas) => {
this.$emit('submit', { canvas, file: this.file })
})
},
pickImage () {
pickImage() {
this.$refs.input.click()
},
readFile () {
readFile() {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
@ -66,10 +63,10 @@ const ImageCropper = {
},
inSelection(selection, maxSelection) {
return (
selection.x >= maxSelection.x
&& selection.y >= maxSelection.y
&& (selection.x + selection.width) <= (maxSelection.x + maxSelection.width)
&& (selection.y + selection.height) <= (maxSelection.y + maxSelection.height)
selection.x >= maxSelection.x &&
selection.y >= maxSelection.y &&
selection.x + selection.width <= maxSelection.x + maxSelection.width &&
selection.y + selection.height <= maxSelection.y + maxSelection.height
)
},
onCropperSelectionChange(event) {
@ -84,11 +81,11 @@ const ImageCropper = {
}
if (!this.inSelection(selection, maxSelection)) {
event.preventDefault();
event.preventDefault()
}
}
},
},
mounted () {
mounted() {
// listen for input file changes
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
@ -96,7 +93,7 @@ const ImageCropper = {
beforeUnmount: function () {
const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile)
}
},
}
export default ImageCropper

Some files were not shown because too many files have changed in this diff Show more