Merge remote-tracking branch 'origin/develop' into migrate/vuex-to-pinia

This commit is contained in:
Henry Jameson 2025-01-30 18:08:05 +02:00
commit 58e18d48df
489 changed files with 31167 additions and 9871 deletions

View file

@ -9,60 +9,80 @@
<template #content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
<div
v-if="relationship.showing_reblogs"
class="btn button-default dropdown-item"
@click="hideRepeats"
class="menu-item dropdown-item"
>
{{ $t('user_card.hide_repeats') }}
</button>
<button
<button
class="main-button"
@click="hideRepeats"
>
{{ $t('user_card.hide_repeats') }}
</button>
</div>
<div
v-if="!relationship.showing_reblogs"
class="btn button-default dropdown-item"
@click="showRepeats"
class="menu-item dropdown-item"
>
{{ $t('user_card.show_repeats') }}
</button>
<button
class="main-button"
@click="showRepeats"
>
{{ $t('user_card.show_repeats') }}
</button>
</div>
<div
role="separator"
class="dropdown-divider"
/>
</template>
<UserListMenu :user="user" />
<button
<div
v-if="relationship.followed_by"
class="btn button-default btn-block dropdown-item"
@click="removeUserFromFollowers"
class="menu-item dropdown-item"
>
{{ $t('user_card.remove_follower') }}
</button>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"
@click="unblockUser"
>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="btn button-default btn-block dropdown-item"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
<button
class="btn button-default btn-block dropdown-item"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
<button
<button
class="main-button"
@click="removeUserFromFollowers"
>
{{ $t('user_card.remove_follower') }}
</button>
</div>
<div class="menu-item dropdown-item">
<button
v-if="relationship.blocking"
class="main-button"
@click="unblockUser"
>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="main-button"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
</div>
<div class="menu-item dropdown-item">
<button
class="main-button"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
</div>
<div
v-if="pleromaChatMessagesAvailable"
class="btn button-default btn-block dropdown-item"
@click="openChat"
class="menu-item dropdown-item"
>
{{ $t('user_card.message') }}
</button>
<button
class="main-button"
@click="openChat"
>
{{ $t('user_card.message') }}
</button>
</div>
</div>
</template>
<template #trigger>
@ -86,6 +106,7 @@
<i18n-t
keypath="user_card.block_confirm"
tag="span"
scope="global"
>
<template #user>
<span
@ -107,6 +128,7 @@
<i18n-t
keypath="user_card.remove_follower_confirm"
tag="span"
scope="global"
>
<template #user>
<span
@ -122,19 +144,12 @@
<script src="./account_actions.js"></script>
<style lang="scss">
@import "../../variables";
.AccountActions {
.ellipsis-button {
width: 2.5em;
margin: -0.5em 0;
padding: 0.5em 0;
text-align: center;
&:not(:hover) .icon {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
}
</style>

View file

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

View file

@ -99,16 +99,14 @@
<script src="./announcement.js"></script>
<style lang="scss">
@import "../../variables";
.announcement {
border-bottom: 1px solid var(--border, $fallback--border);
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: var(--status-margin, $status-margin);
padding: var(--status-margin);
.heading,
.body {
margin-bottom: var(--status-margin, $status-margin);
margin-bottom: var(--status-margin);
}
.footer {

View file

@ -3,7 +3,7 @@
<textarea
ref="textarea"
v-model="announcement.content"
class="post-textarea"
class="input post-textarea"
rows="1"
cols="1"
:placeholder="$t('announcements.post_placeholder')"
@ -14,6 +14,7 @@
<input
id="announcement-start-time"
v-model="announcement.startsAt"
class="input"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
@ -23,6 +24,7 @@
<input
id="announcement-end-time"
v-model="announcement.endsAt"
class="input"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
>
@ -32,8 +34,9 @@
id="announcement-all-day"
v-model="announcement.allDay"
:disabled="disabled"
/>
<label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
>
{{ $t('announcements.all_day_prompt') }}
</Checkbox>
</span>
</div>
</template>

View file

@ -1,9 +1,9 @@
<template>
<div class="panel panel-default announcements-page">
<div class="panel-heading">
<span>
<h1 class="title">
{{ $t('announcements.page_header') }}
</span>
</h1>
</div>
<div class="panel-body">
<section
@ -61,15 +61,13 @@
<script src="./announcements_page.js"></script>
<style lang="scss">
@import "../../variables";
.announcements-page {
.post-form {
padding: var(--status-margin, $status-margin);
padding: var(--status-margin);
.heading,
.body {
margin-bottom: var(--status-margin, $status-margin);
margin-bottom: var(--status-margin);
}
.post-button {

View file

@ -1,5 +1,3 @@
@import "../../variables";
.Attachment {
display: inline-flex;
flex-direction: column;
@ -9,10 +7,8 @@
height: 100%;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-radius: var(--roundness);
border-color: var(--border);
.attachment-wrapper {
flex: 1 1 auto;
@ -84,6 +80,13 @@
}
}
.video-container {
border: none;
outline: none;
color: inherit;
background: transparent;
}
.audio-container {
display: flex;
align-items: flex-end;
@ -126,23 +129,12 @@
.attachment-button {
padding: 0;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
border-radius: var(--roundness);
text-align: center;
width: 2em;
height: 2em;
margin-left: 0.5em;
font-size: 1.25em;
// TODO: theming? hard to theme with unknown background image color
background: rgb(230 230 230 / 70%);
.svg-inline--fa {
color: rgb(0 0 0 / 60%);
}
&:hover .svg-inline--fa {
color: rgb(0 0 0 / 90%);
}
}
}
@ -217,8 +209,7 @@
&.-placeholder {
display: inline-block;
color: $fallback--link;
color: var(--postLink, $fallback--link);
color: var(--link);
overflow: hidden;
white-space: nowrap;
height: auto;

View file

@ -0,0 +1,25 @@
export default {
name: 'Attachment',
selector: '.Attachment',
notEditable: true,
validInnerComponents: [
'Border',
'ButtonUnstyled',
'Input'
],
defaultRules: [
{
directives: {
roundness: 3
}
},
{
component: 'ButtonUnstyled',
parent: { component: 'Attachment' },
directives: {
background: '#FFFFFF',
opacity: 0.5
}
}
]
}

View file

@ -38,7 +38,7 @@
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
class="input description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
@ -175,7 +175,6 @@
:is="videoTag"
v-if="type === 'video' && !hidden"
class="video-container"
:class="{ 'button-unstyled': 'isModal' }"
:href="attachment.url"
@click.stop.prevent="openModal"
>
@ -253,7 +252,7 @@
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
class="input description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>

View file

@ -1,3 +1,4 @@
<!-- FIXME THIS NEEDS TO BE REFACTORED TO USE POPOVER -->
<template>
<div
v-click-outside="onClickOutside"
@ -6,12 +7,12 @@
<input
v-model="term"
:placeholder="placeholder"
class="autosuggest-input"
class="input autosuggest-input"
@click="onInputClick"
>
<div
v-if="resultsVisible && filtered.length > 0"
class="autosuggest-results"
class="panel autosuggest-results"
>
<slot
v-for="item in filtered"
@ -24,8 +25,6 @@
<script src="./autosuggest.js"></script>
<style lang="scss">
@import "../../variables";
.autosuggest {
position: relative;
@ -40,18 +39,15 @@
top: 100%;
right: 0;
max-height: 400px;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
background-color: var(--bg);
border-style: solid;
border-width: 1px;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
border-color: var(--border);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 1px 1px 4px rgb(0 0 0 / 60%);
box-shadow: var(--panelShadow);
box-shadow: var(--shadow);
overflow-y: auto;
z-index: 1;
}

View file

@ -17,8 +17,6 @@
<script src="./avatar_list.js"></script>
<style lang="scss">
@import "../../variables";
.avatars {
display: flex;
margin: 0;
@ -36,8 +34,7 @@
}
.avatar-small {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
border-radius: var(--roundness);
height: 24px;
width: 24px;
}

View file

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

View file

@ -47,9 +47,8 @@
display: flex;
flex: 1 0;
margin: 0;
padding: 0.6em 1em;
--emoji-size: 14px;
--emoji-size: 1em;
&-collapsed-content {
margin-left: 0.7em;

View file

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

View file

@ -0,0 +1,111 @@
<template>
<div
v-if="allBookmarks"
class="bookmark-folder-card"
>
<router-link
:to="{ name: 'bookmarks' }"
class="bookmark-folder-name"
>
<span class="icon">
<FAIcon
fixed-width
class="fa-scale-110 menu-icon"
icon="bookmark"
/>
</span>{{ $t('nav.all_bookmarks') }}
</router-link>
</div>
<div
v-else
class="bookmark-folder-card"
>
<router-link
:to="{ name: 'bookmark-folder', params: { id: folder.id } }"
class="bookmark-folder-name"
>
<img
v-if="folder.emoji_url"
class="iconEmoji iconEmoji-image"
:src="folder.emoji_url"
:alt="folder.emoji"
:title="folder.emoji"
>
<span
v-else-if="folder.emoji"
class="iconEmoji"
>
<span>
{{ folder.emoji }}
</span>
</span>
<span
v-else-if="firstLetter"
class="icon iconLetter fa-scale-110"
>{{ firstLetter }}</span>{{ folder.name }}
</router-link>
<router-link
:to="{ name: 'bookmark-folder-edit', params: { id: folder.id } }"
class="button-folder-edit"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="ellipsis-h"
/>
</router-link>
</div>
</template>
<script src="./bookmark_folder_card.js"></script>
<style lang="scss">
.bookmark-folder-card {
display: flex;
align-items: center;
}
a.bookmark-folder-name {
display: flex;
align-items: center;
flex-grow: 1;
.icon,
.iconLetter,
.iconEmoji {
display: inline-block;
height: 2.5rem;
width: 2.5rem;
margin-right: 0.5rem;
}
.icon,
.iconLetter {
font-size: 1.5rem;
line-height: 2.5rem;
text-align: center;
}
.iconEmoji {
text-align: center;
object-fit: contain;
vertical-align: middle;
> span {
font-size: 1.5rem;
line-height: 2.5rem;
}
}
img.iconEmoji {
padding: 0.25em;
box-sizing: border-box;
}
}
.bookmark-folder-name,
.button-folder-edit {
margin: 0;
padding: 1em;
color: var(--link);
}
</style>

View file

@ -0,0 +1,80 @@
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import apiService from '../../services/api/api.service'
const BookmarkFolderEdit = {
data () {
return {
name: '',
nameDraft: '',
emoji: '',
emojiUrl: null,
emojiDraft: '',
emojiUrlDraft: null,
emojiPickerExpanded: false,
reallyDelete: false
}
},
components: {
EmojiPicker
},
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
this.nameDraft = this.name = folder.name
this.emojiDraft = this.emoji = folder.emoji
this.emojiUrlDraft = this.emojiUrl = folder.emoji_url
})
},
computed: {
id () {
return this.$route.params.id
}
},
methods: {
selectEmoji (event) {
this.emojiDraft = event.insertion
this.emojiUrlDraft = event.insertionUrl
},
showEmojiPicker () {
if (!this.emojiPickerExpanded) {
this.$refs.picker.showPicker()
}
},
onShowPicker () {
this.emojiPickerExpanded = true
},
onClosePicker () {
this.emojiPickerExpanded = false
},
updateFolder () {
this.$store.dispatch('setBookmarkFolder', { folderId: this.id, name: this.nameDraft, emoji: this.emojiDraft })
.then(() => {
this.$router.push({ name: 'bookmark-folders' })
})
},
createFolder () {
this.$store.dispatch('createBookmarkFolder', { name: this.nameDraft, emoji: this.emojiDraft })
.then(() => {
this.$router.push({ name: 'bookmark-folders' })
})
.catch((e) => {
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'bookmark_folders.error',
messageArgs: [e.message],
level: 'error'
})
})
},
deleteFolder () {
this.$store.dispatch('deleteBookmarkFolder', { folderId: this.id })
this.$router.push({ name: 'bookmark-folders' })
}
}
}
export default BookmarkFolderEdit

View file

@ -0,0 +1,200 @@
<template>
<div class="panel-default panel BookmarkFolderEdit">
<div
ref="header"
class="panel-heading folder-edit-heading"
>
<button
class="button-unstyled go-back-button"
@click="$router.back"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<h1 class="title">
<i18n-t
v-if="id"
keypath="bookmark_folders.editing_folder"
scope="global"
>
<template #folderName>
{{ name }}
</template>
</i18n-t>
<i18n-t
v-else
keypath="bookmark_folders.creating_folder"
scope="global"
/>
</h1>
</div>
<div class="panel-body">
<div class="input-wrap">
<label for="folder-edit-title">{{ $t('bookmark_folders.emoji') }}</label>
<button
class="input input-emoji"
:title="$t('bookmark_folder.emoji_pick')"
@click="showEmojiPicker"
>
<img
v-if="emojiUrlDraft"
class="iconEmoji iconEmoji-image"
:src="emojiUrlDraft"
:alt="emojiDraft"
:title="emojiDraft"
>
<span
v-else-if="emojiDraft"
class="iconEmoji"
>
<span>
{{ emojiDraft }}
</span>
</span>
</button>
<EmojiPicker
ref="picker"
class="emoji-picker-panel"
@emoji="selectEmoji"
@show="onShowPicker"
@close="onClosePicker"
/>
</div>
<div class="input-wrap">
<label for="folder-edit-title">{{ $t('bookmark_folders.name') }}</label>
<input
id="folder-edit-title"
ref="name"
v-model="nameDraft"
class="input"
>
</div>
</div>
<div class="panel-footer">
<span class="spacer" />
<button
v-if="!id"
class="btn button-default footer-button"
@click="createFolder"
>
{{ $t('bookmark_folders.create') }}
</button>
<button
v-else-if="!reallyDelete"
class="btn button-default footer-button"
@click="reallyDelete = true"
>
{{ $t('bookmark_folders.delete') }}
</button>
<template v-else>
{{ $t('bookmark_folders.really_delete') }}
<button
class="btn button-default footer-button"
@click="deleteFolder"
>
{{ $t('general.yes') }}
</button>
<button
class="btn button-default footer-button"
@click="reallyDelete = false"
>
{{ $t('general.no') }}
</button>
</template>
<div
v-if="id && !reallyDelete"
>
<button
class="btn button-default follow-button"
@click="updateFolder"
>
{{ $t('bookmark_folders.update_folder') }}
</button>
</div>
</div>
</div>
</template>
<script src="./bookmark_folder_edit.js"></script>
<style lang="scss">
.BookmarkFolderEdit {
--panel-body-padding: 0.5em;
overflow: hidden;
display: flex;
flex-direction: column;
.folder-edit-heading {
grid-template-columns: auto minmax(50%, 1fr);
}
.panel-body {
display: flex;
gap: 0.5em;
}
.emoji-picker-panel {
position: absolute;
z-index: 20;
margin-top: 2px;
&.hide {
display: none;
}
}
.input-emoji {
height: 2.5em;
width: 2.5em;
padding: 0;
.iconEmoji {
display: inline-block;
text-align: center;
object-fit: contain;
vertical-align: middle;
height: 2.5em;
width: 2.5em;
> span {
font-size: 1.5rem;
line-height: 2.5rem;
}
}
img.iconEmoji {
padding: 0.25em;
box-sizing: border-box;
}
}
.input-wrap {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.btn {
margin: 0 0.5em;
}
.panel-footer {
grid-template-columns: minmax(10%, 1fr);
.footer-button {
min-width: 9em;
}
}
}
</style>

View file

@ -0,0 +1,27 @@
import BookmarkFolderCard from '../bookmark_folder_card/bookmark_folder_card.vue'
const BookmarkFolders = {
data () {
return {
isNew: false
}
},
components: {
BookmarkFolderCard
},
computed: {
bookmarkFolders () {
return this.$store.state.bookmarkFolders.allFolders
}
},
methods: {
cancelNewFolder () {
this.isNew = false
},
newFolder () {
this.isNew = true
}
}
}
export default BookmarkFolders

View file

@ -0,0 +1,37 @@
<template>
<div class="Bookmark-folders panel panel-default">
<div class="panel-heading">
<h1 class="title">
{{ $t('nav.bookmark_folders') }}
</h1>
<router-link
:to="{ name: 'bookmark-folder-new' }"
class="button-default btn new-folder-button"
>
{{ $t("bookmark_folders.new") }}
</router-link>
</div>
<div class="panel-body">
<BookmarkFolderCard
:all-bookmarks="true"
class="list-item"
/>
<BookmarkFolderCard
v-for="folder in bookmarkFolders.slice().reverse()"
:key="folder"
:folder="folder"
class="list-item"
/>
</div>
</div>
</template>
<script src="./bookmark_folders.js"></script>
<style lang="scss">
.Bookmark-folders {
.new-folder-button {
padding: 0 0.5em;
}
}
</style>

View file

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

View file

@ -0,0 +1,21 @@
<template>
<ul>
<NavigationEntry
:item="{
name: 'bookmarks',
routeObject: { name: 'bookmarks' },
label: 'nav.all_bookmarks',
icon: 'bookmark'
}"
:show-pin="showPin"
/>
<NavigationEntry
v-for="item in folders"
:key="item.id"
:show-pin="showPin"
:item="item"
/>
</ul>
</template>
<script src="./bookmark_folders_menu_content.js"></script>

View file

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

View file

@ -3,6 +3,7 @@
:title="$t('nav.bookmarks')"
:timeline="timeline"
:timeline-name="'bookmarks'"
:bookmark-folder-id="folderId"
/>
</template>

View file

@ -0,0 +1,13 @@
export default {
name: 'Border',
selector: '/*border*/',
virtual: true,
defaultRules: [
{
directives: {
textColor: '$mod(--parent 10)',
textAuto: 'no-auto'
}
}
]
}

View file

@ -0,0 +1,129 @@
export default {
name: 'Button', // Name of the component
selector: '.button-default', // CSS selector/prefix
// outOfTreeSelector: '' // out-of-tree selector is used when other components are laid over it but it's not part of the tree, see Underlay component
// States, system witll calculate ALL possible combinations of those and prepend "normal" to them + standalone "normal" state
states: {
// States are a bit expensive - the amount of combinations generated is about (1/6)n^3+n, so adding more state increased number of combination by an order of magnitude!
// All states inherit from "normal" state, there is no other inheirtance, i.e. hover+disabled only inherits from "normal", not from hover nor disabled.
// However, cascading still works, so resulting state will be result of merging of all relevant states/variants
// normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled',
focused: ':focus-visible',
pressed: ':focus:active',
hover: ':hover:not(: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'
// 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'
},
// 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'
],
// Default rules, used as "default theme", essentially.
defaultRules: [
{
component: 'Root',
directives: {
'--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)'
}
},
{
// component: 'Button', // no need to specify components every time unless you're specifying how other component should look
// like within it
directives: {
background: '--fg',
shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3
}
},
{
state: ['hover'],
directives: {
shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel']
}
},
{
state: ['pressed'],
directives: {
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
},
{
state: ['pressed', 'hover'],
directives: {
shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow']
}
},
{
state: ['toggled'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'hover'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{
state: ['toggled', 'disabled'],
directives: {
background: '$blend(--accent 0.25 --parent)',
shadow: ['--buttonPressedBevel']
}
},
{
state: ['disabled'],
directives: {
background: '$blend(--accent 0.25 --parent)',
shadow: ['--buttonDefaultBevel']
}
},
{
component: 'Text',
parent: {
component: 'Button',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
},
{
component: 'Icon',
parent: {
component: 'Button',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
}

View file

@ -0,0 +1,96 @@
export default {
name: 'ButtonUnstyled',
selector: '.button-unstyled',
notEditable: true,
states: {
toggled: '.toggled',
disabled: ':disabled',
hover: ':hover:not(:disabled)',
focused: ':focus-within'
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Badge'
],
defaultRules: [
{
directives: {
shadow: []
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['hover']
},
directives: {
textColor: '--parent--text'
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled']
},
directives: {
textColor: '--parent--text'
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'hover']
},
directives: {
textColor: '--parent--text'
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'focused']
},
directives: {
textColor: '--parent--text'
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['toggled', 'focused', 'hover']
},
directives: {
textColor: '--parent--text'
}
},
{
component: 'Text',
parent: {
component: 'ButtonUnstyled',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
},
{
component: 'Icon',
parent: {
component: 'ButtonUnstyled',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
}

View file

@ -11,15 +11,15 @@
.chat-view-body {
box-sizing: border-box;
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: calc(100vh - var(--navbar-height));
margin: 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
border-radius: var(--roundness);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::after {
border-radius: 0;
@ -37,8 +37,6 @@
.footer {
position: sticky;
bottom: 0;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
z-index: 1;
}
@ -61,8 +59,6 @@
position: absolute;
right: 1.3em;
top: -3.2em;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
@ -79,12 +75,6 @@
visibility: visible;
}
i {
font-size: 1em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.unread-message-count {
font-size: 0.8em;
left: 50%;

View file

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

View file

@ -26,7 +26,7 @@
</div>
</div>
<div
class="message-list"
class="chat-message-list message-list"
:style="{ height: scrollableContainerHeight }"
>
<template v-if="!errorLoadingChat">
@ -61,7 +61,7 @@
<FAIcon icon="chevron-down" />
<div
v-if="newMessageCount"
class="badge badge-notification unread-chat-count unread-message-count"
class="badge -notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
@ -76,6 +76,7 @@
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
:disable-draft="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
@ -95,6 +96,5 @@
<script src="./chat.js"></script>
<style lang="scss">
@import "../../variables";
@import "./chat";
</style>

View file

@ -7,9 +7,9 @@
class="chat-list panel panel-default"
>
<div class="panel-heading -sticky">
<span class="title">
<h1 class="title">
{{ $t("chats.chats") }}
</span>
</h1>
<button
class="button-default"
@click="newChat"
@ -45,8 +45,6 @@
<script src="./chat_list.js"></script>
<style lang="scss">
@import "../../variables";
.chat-list {
min-height: 25em;
margin-bottom: 0;
@ -57,8 +55,7 @@
font-size: 1.2em;
display: flex;
justify-content: center;
color: $fallback--text;
color: var(--faint, $fallback--text);
color: var(--textFaint);
}
</style>

View file

@ -1,8 +1,6 @@
.chat-list-item {
display: flex;
flex-direction: row;
padding: 0.75em;
height: 5em;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
@ -11,11 +9,6 @@
outline: none;
}
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0 3px 1px rgb(0 0 0 / 10%);
}
.chat-list-item-left {
margin-right: 1em;
}
@ -29,7 +22,7 @@
.heading {
width: 100%;
display: inline-flex;
display: flex;
justify-content: space-between;
line-height: 1em;
}
@ -47,18 +40,17 @@
}
.chat-preview {
display: inline-flex;
display: flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0.35em 0;
color: $fallback--text;
color: var(--faint, $fallback--text);
color: var(--textFaint);
width: 100%;
}
a {
color: var(--faintLink, $fallback--link);
color: var(--linkFaint);
text-decoration: none;
pointer-events: none;
}
@ -73,11 +65,6 @@
}
}
.Avatar {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.chat-preview-body {
--emoji-size: 1.4em;

View file

@ -36,7 +36,7 @@
/>
<div
v-if="chat.unread > 0"
class="badge badge-notification unread-chat-count"
class="badge -notification unread-chat-count"
>
{{ chat.unread }}
</div>
@ -48,6 +48,5 @@
<script src="./chat_list_item.js"></script>
<style lang="scss">
@import "../../variables";
@import "./chat_list_item";
</style>

View file

@ -1,5 +1,3 @@
@import "../../variables";
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
@ -27,12 +25,6 @@
.menu-icon {
cursor: pointer;
&:hover,
.extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.popover {
@ -61,10 +53,12 @@
}
.status {
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
background-color: var(--background);
color: var(--text);
border-radius: var(--roundness);
display: flex;
padding: 0.75em;
border: 1px solid var(--border);
}
.created-at {
@ -97,8 +91,7 @@
.error {
.status-content.media-body,
.created-at {
color: $fallback--cRed;
color: var(--badgeNotification, $fallback--cRed);
color: var(--badgeNotification);
}
}
@ -117,16 +110,6 @@
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status {
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
@ -137,22 +120,6 @@
}
.incoming {
a {
color: var(--chatMessageIncomingLink, $fallback--link);
}
.status {
color: var(--chatMessageIncomingText, $fallback--text);
background-color: var(--chatMessageIncomingBg, $fallback--bg);
border: 1px solid var(--chatMessageIncomingBorder, --border);
}
.created-at {
a {
color: var(--chatMessageIncomingText, $fallback--text);
}
}
.chat-message-menu {
left: 0.4rem;
}
@ -176,6 +143,5 @@
margin: 1.4em 0;
font-size: 0.9em;
user-select: none;
color: $fallback--text;
color: var(--faintedText, $fallback--text);
color: var(--textFaint);
}

View file

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

View file

@ -20,7 +20,6 @@
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="author"
/>
</UserPopover>
@ -52,12 +51,14 @@
>
<template #content>
<div class="dropdown-menu">
<button
class="button-default dropdown-item dropdown-item-icon"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>

View file

@ -16,11 +16,6 @@
padding-bottom: 0.7rem;
}
.basic-user-card:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
text-align: center;
line-height: 1;

View file

@ -16,27 +16,29 @@
/>
</button>
</div>
<div class="input-wrap">
<div class="input-search">
<FAIcon
class="search-icon fa-scale-110 fa-old-padding"
icon="search"
/>
<div class="panel-body">
<div class="input-wrap">
<div class="input-search">
<FAIcon
class="search-icon fa-scale-110 fa-old-padding"
icon="search"
/>
</div>
<input
ref="search"
v-model="query"
class="input"
placeholder="Search people"
@input="onInput"
>
</div>
<input
ref="search"
v-model="query"
placeholder="Search people"
@input="onInput"
>
</div>
<div class="member-list">
<div
v-for="user in availableUsers"
:key="user.id"
class="member"
>
<div @click.capture.prevent="goToChat(user)">
<div class="member-list">
<div
v-for="user in availableUsers"
:key="user.id"
class="list-item"
@click.capture.prevent="goToChat(user)"
>
<BasicUserCard :user="user" />
</div>
</div>
@ -46,6 +48,5 @@
<script src="./chat_new.js"></script>
<style lang="scss">
@import "../../variables";
@import "./chat_new";
</style>

View file

@ -26,15 +26,13 @@
<script src="./chat_title.js"></script>
<style lang="scss">
@import "../../variables";
.chat-title {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
--emoji-size: 14px;
--emoji-size: 1em;
.username {
max-width: 100%;
@ -54,8 +52,7 @@
margin-right: 0.5em;
height: 1.5em;
width: 1.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
border-radius: var(--roundness);
&.animated::before {
display: none;

View file

@ -1,8 +1,15 @@
<template>
<label
class="checkbox"
:class="{ disabled, indeterminate }"
:class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }"
>
<span
v-if="!!$slots.before"
class="label -before"
:class="{ faint: disabled }"
>
<slot name="before" />
</span>
<input
type="checkbox"
class="visible-for-screenreader-only"
@ -12,12 +19,15 @@
@change="$emit('update:modelValue', $event.target.checked)"
>
<i
class="checkbox-indicator"
class="input -checkbox checkbox-indicator"
:aria-hidden="true"
:class="{ disabled }"
@transitionend.capture="onTransitionEnd"
/>
<span
v-if="!!$slots.default"
class="label"
class="label -after"
:class="{ faint: disabled }"
>
<slot />
</span>
@ -31,12 +41,28 @@ export default {
'indeterminate',
'disabled'
],
emits: ['update:modelValue']
emits: ['update:modelValue'],
data: (vm) => ({
indeterminateTransitionFix: vm.indeterminate
}),
watch: {
indeterminate (e) {
if (e) {
this.indeterminateTransitionFix = true
}
}
},
methods: {
onTransitionEnd (e) {
if (!this.indeterminate) {
this.indeterminateTransitionFix = false
}
}
}
}
</script>
<style lang="scss">
@import "../../variables";
@import "../../mixins";
.checkbox {
@ -44,26 +70,34 @@ export default {
display: inline-block;
min-height: 1.2em;
&-indicator {
&-indicator,
& .label {
vertical-align: middle;
}
& > &-indicator {
/* Reset .input stuff */
padding: 0;
margin: 0;
position: relative;
padding-left: 1.2em;
line-height: inherit;
display: inline-block;
width: 1.2em;
height: 1.2em;
box-shadow: none;
}
&-indicator::before {
position: absolute;
right: 0;
top: 0;
inset: 0;
display: block;
content: "✓";
transition: color 200ms;
width: 1.1em;
height: 1.1em;
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0 0 2px black inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
border-radius: var(--roundness);
box-shadow: var(--shadow);
background-color: var(--background);
vertical-align: top;
text-align: center;
line-height: 1.1em;
@ -73,33 +107,37 @@ export default {
box-sizing: border-box;
}
&.disabled {
.checkbox-indicator::before,
.label {
opacity: 0.5;
}
.label {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
.disabled {
.checkbox-indicator::before {
background-color: var(--background);
}
}
input[type="checkbox"] {
&:checked + .checkbox-indicator::before {
color: $fallback--text;
color: var(--inputText, $fallback--text);
color: var(--text);
}
&:indeterminate + .checkbox-indicator::before {
content: "";
color: $fallback--text;
color: var(--inputText, $fallback--text);
color: var(--text);
}
}
& > span {
margin-left: 0.5em;
&.indeterminate-fix {
input[type="checkbox"] + .checkbox-indicator::before {
content: "";
}
}
& > .label {
&.-after {
margin-left: 0.5em;
}
&.-before {
margin-right: 0.5em;
}
}
}
</style>

View file

@ -1,19 +1,23 @@
@import "../../variables";
.color-input {
display: inline-flex;
.label {
flex: 1 1 auto;
}
.opt {
margin-right: 0.5em;
}
&-field.input {
display: inline-flex;
flex: 0 0 0;
max-width: 9em;
align-items: stretch;
padding: 0.2em 8px;
input {
color: var(--text);
background: none;
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
border: none;
padding: 0;
margin: 0;
@ -23,21 +27,39 @@
min-width: 3em;
padding: 0;
}
}
&.nativeColor {
flex: 0 0 2em;
min-width: 2em;
align-self: stretch;
min-height: 100%;
.nativeColor {
cursor: pointer;
flex: 0 0 auto;
padding: 0;
input {
appearance: none;
max-width: 0;
min-width: 0;
max-height: 0;
/* stylelint-disable-next-line declaration-no-important */
opacity: 0 !important;
}
}
.computedIndicator,
.validIndicator,
.invalidIndicator,
.transparentIndicator {
flex: 0 0 2em;
margin: 0.2em 0.5em;
min-width: 2em;
align-self: stretch;
min-height: 100%;
min-height: 1.1em;
border-radius: var(--roundness);
}
.invalidIndicator {
background: transparent;
box-sizing: border-box;
border: 2px solid var(--cRed);
}
.transparentIndicator {
@ -58,16 +80,26 @@
&::after {
top: 0;
left: 0;
border-top-left-radius: var(--roundness);
}
&::before {
bottom: 0;
right: 0;
border-bottom-right-radius: var(--roundness);
}
}
&.disabled,
&:disabled {
.nativeColor input,
.computedIndicator,
.validIndicator,
.invalidIndicator,
.transparentIndicator {
/* stylelint-disable-next-line declaration-no-important */
opacity: 0.25 !important;
}
}
}
.label {
flex: 1 1 auto;
}
}

View file

@ -6,49 +6,77 @@
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
@update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
@update:modelValue="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div class="input color-input-field">
<div
class="input color-input-field"
:class="{ disabled: !present || disabled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
<input
v-if="validColor"
:id="name"
class="nativeColor unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
@input="updateValue($event.target.value)"
>
<div
v-if="transparentColor"
v-if="validColor"
class="validIndicator"
:style="{backgroundColor: modelValue || fallback}"
/>
<div
v-else-if="transparentColor"
class="transparentIndicator"
/>
<div
v-if="computedColor"
v-else-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
<div
v-else
class="invalidIndicator"
/>
<label class="nativeColor">
<FAIcon icon="eye-dropper" />
<input
:id="name"
class="unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
@input="updateValue($event.target.value)"
>
</label>
</div>
</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'
library.add(
faEyeDropper
)
export default {
components: {
Checkbox
@ -84,10 +112,16 @@ export default {
default: false
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalTickbox: {
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
@ -102,18 +136,14 @@ export default {
return this.modelValue === 'transparent'
},
computedColor () {
return this.modelValue && this.modelValue.startsWith('--')
return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
}
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100)
}
}
</script>
<style lang="scss" src="./color_input.scss"></style>
<style lang="scss">
.color-control {
input.text-input {
max-width: 7em;
flex: 1;
}
}
</style>

View file

@ -0,0 +1,323 @@
<template>
<div
class="ComponentPreview"
:class="{ '-shadow-controls': shadowControl }"
>
<!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="previewCss"
/>
<!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
<label
v-show="shadowControl"
role="heading"
class="header"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<label
v-show="shadowControl && !hideControls"
class="x-shift-number"
>
{{ $t('settings.style.shadows.offset-x') }}
<input
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
</label>
<label
v-show="shadowControl && !hideControls"
class="y-shift-number"
>
{{ $t('settings.style.shadows.offset-y') }}
<input
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
</label>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl && !hideControls"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div
class="preview-window"
:class="{ '-light-grid': lightGrid }"
>
<div
class="preview-block"
:class="previewClass"
:style="style"
>
{{ $t('settings.style.themes3.editor.test_string') }}
</div>
<div
v-if="invalid"
class="invalid-container"
>
<div class="alert error invalid-label">
{{ $t('settings.style.themes3.editor.invalid') }}
</div>
</div>
</div>
<div class="assists">
<Checkbox
v-model="lightGrid"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
<div class="style-control">
<label class="label">
{{ $t('settings.style.shadows.zoom') }}
</label>
<input
v-model="zoom"
class="input input-number y-shift-number"
type="number"
>
</div>
<ColorInput
v-if="!noColorControl"
v-model="colorOverride"
class="input-color-input"
fallback="#606060"
:label="$t('settings.style.shadows.color_override')"
/>
</div>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
export default {
components: {
Checkbox,
ColorInput
},
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'previewCss',
'disabled',
'invalid',
'noColorControl'
],
emits: ['update:shadow'],
data () {
return {
colorOverride: undefined,
lightGrid: false,
zoom: 100
}
},
computed: {
style () {
const result = [
this.previewStyle,
`zoom: ${this.zoom / 100}`
]
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
return result
},
hideControls () {
return typeof this.shadow === 'string'
}
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) })
}
}
}
</script>
<style lang="scss">
.ComponentPreview {
display: grid;
grid-template-columns: 1em 1fr 1fr 1em;
grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content;
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"x-slide x-slide x-slide . "
"x-num x-num y-num y-num "
"assists assists assists assists";
grid-gap: 0.5em;
&:not(.-shadow-controls) {
grid-template-areas:
"header header header header "
"preview preview preview y-slide"
"preview preview preview y-slide"
"preview preview preview y-slide"
"assists assists assists assists";
grid-template-rows: 2em 1fr 1fr 1fr max-content;
}
.header {
grid-area: header;
justify-self: center;
align-self: baseline;
line-height: 2;
}
.invalid-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
align-items: center;
justify-items: center;
background-color: rgba(100 0 0 / 50%);
.alert {
padding: 0.5em 1em;
}
}
.assists {
grid-area: assists;
display: grid;
grid-auto-flow: rows;
grid-auto-rows: 2em;
grid-gap: 0.5em;
}
.input-light-grid {
justify-self: center;
}
.input-number {
min-width: 2em;
}
.x-shift-number {
grid-area: x-num;
justify-self: right;
}
.y-shift-number {
grid-area: y-num;
justify-self: left;
}
.x-shift-number,
.y-shift-number {
input {
max-width: 4em;
}
}
.x-shift-slider {
grid-area: x-slide;
height: auto;
align-self: start;
min-width: 10em;
}
.y-shift-slider {
grid-area: y-slide;
writing-mode: vertical-lr;
justify-self: left;
min-height: 10em;
}
.x-shift-slider,
.y-shift-slider {
padding: 0;
}
.preview-window {
--__grid-color1: rgb(102 102 102);
--__grid-color2: rgb(153 153 153);
--__grid-color1-disabled: rgba(102 102 102 / 20%);
--__grid-color2-disabled: rgba(153 153 153 / 20%);
&.-light-grid {
--__grid-color1: rgb(205 205 205);
--__grid-color2: rgb(255 255 255);
--__grid-color1-disabled: rgba(205 205 205 / 20%);
--__grid-color2-disabled: rgba(255 255 255 / 20%);
}
position: relative;
grid-area: preview;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 10em;
min-height: 10em;
background-color: var(--__grid-color2);
background-image:
linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
border-radius: var(--roundness);
&.disabled {
background-color: var(--__grid-color2-disabled);
background-image:
linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%),
linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%);
}
.preview-block {
background: var(--background, var(--bg));
display: flex;
justify-content: center;
align-items: center;
min-width: 33%;
min-height: 33%;
max-width: 80%;
max-height: 80%;
border-width: 0;
border-style: solid;
border-color: var(--border);
border-radius: var(--roundness);
box-shadow: var(--shadow);
}
}
}
</style>

View file

@ -0,0 +1,109 @@
import { unitToSeconds } from 'src/services/date_utils/date_utils.js'
import { mapGetters } from 'vuex'
import ConfirmModal from './confirm_modal.vue'
import Select from 'src/components/select/select.vue'
export default {
props: ['type', 'user', 'status'],
emits: ['hide', 'show', 'muted'],
data: () => ({
showing: false,
muteExpiryAmount: 2,
muteExpiryUnit: 'hours'
}),
components: {
ConfirmModal,
Select
},
computed: {
muteExpiryValue () {
unitToSeconds(this.muteExpiryUnit, this.muteExpiryAmount)
},
muteExpiryUnits () {
return ['minutes', 'hours', 'days']
},
domain () {
return this.user.fqn.split('@')[1]
},
keypath () {
if (this.type === 'domain') {
return 'status.mute_domain_confirm'
} else if (this.type === 'conversation') {
return 'status.mute_conversation_confirm'
} else {
return 'user_card.mute_confirm'
}
},
userIsMuted () {
return this.$store.getters.relationship(this.user.id).muting
},
conversationIsMuted () {
return this.status.conversation_muted
},
domainIsMuted () {
return new Set(this.$store.state.users.currentUser.domainMutes).has(this.domain)
},
shouldConfirm () {
switch (this.type) {
case 'domain': {
return this.mergedConfig.modalOnMuteDomain
}
case 'conversation': {
return this.mergedConfig.modalOnMuteConversation
}
default: {
return this.mergedConfig.modalOnMute
}
}
},
...mapGetters(['mergedConfig'])
},
methods: {
optionallyPrompt () {
if (this.shouldConfirm) {
this.show()
} else {
this.doMute()
}
},
show () {
this.showing = true
this.$emit('show')
},
hide () {
this.showing = false
this.$emit('hide')
},
doMute () {
switch (this.type) {
case 'domain': {
if (!this.domainIsMuted) {
this.$store.dispatch('muteDomain', { id: this.domain, expiresIn: this.muteExpiryValue })
} else {
this.$store.dispatch('unmuteDomain', { id: this.domain })
}
break
}
case 'conversation': {
if (!this.conversationIsMuted) {
this.$store.dispatch('muteConversation', { id: this.status.id, expiresIn: this.muteExpiryValue })
} else {
this.$store.dispatch('unmuteConversation', { id: this.status.id })
}
break
}
default: {
if (!this.userIsMuted) {
this.$store.dispatch('muteUser', { id: this.user.id, expiresIn: this.muteExpiryValue })
} else {
this.$store.dispatch('unmuteUser', { id: this.user.id })
}
break
}
}
this.$emit('muted')
this.hide()
}
}
}

View file

@ -0,0 +1,61 @@
<template>
<confirm-modal
v-if="showing"
:title="$t('user_card.mute_confirm_title')"
:confirm-text="$t('user_card.mute_confirm_accept_button')"
:cancel-text="$t('user_card.mute_confirm_cancel_button')"
@accepted="doMute"
@cancelled="hide"
>
<i18n-t
:keypath="keypath"
tag="div"
>
<template #domain>
<span v-text="domain" />
</template>
<template #user>
<span v-text="user.screen_name_ui" />
</template>
</i18n-t>
<div
v-if="type !== 'domain'"
class="mute-expiry"
>
<p>
<label>
{{ $t('user_card.mute_duration_prompt') }}
</label>
<input
v-model="muteExpiryAmount"
type="number"
class="input expiry-amount hide-number-spinner"
:min="0"
>
{{ ' ' }}
<Select
v-model="muteExpiryUnit"
unstyled="true"
class="expiry-unit"
>
<option
v-for="unit in muteExpiryUnits"
:key="unit"
:value="unit"
>
{{ $t(`time.unit.${unit}_short`, ['']) }}
</option>
</Select>
</p>
</div>
</confirm-modal>
</template>
<script src="./mute_confirm.js" />
<style lang="scss">
.expiry-amount {
width: 4em;
text-align: right;
}
</style>

View file

@ -3,39 +3,62 @@
v-if="contrast"
class="contrast-ratio"
>
<span
:title="hint"
<span v-if="showRatio">
{{ contrast.text }}
</span>
<Tooltip
:text="hint"
class="rating"
>
<span v-if="contrast.aaa">
<FAIcon icon="thumbs-up" />
<FAIcon
icon="thumbs-up"
:size="showRatio ? 'lg' : ''"
/>
</span>
<span v-if="!contrast.aaa && contrast.aa">
<FAIcon icon="adjust" />
<FAIcon
icon="adjust"
:size="showRatio ? 'lg' : ''"
/>
</span>
<span v-if="!contrast.aaa && !contrast.aa">
<FAIcon icon="exclamation-triangle" />
<FAIcon
icon="exclamation-triangle"
:size="showRatio ? 'lg' : ''"
/>
</span>
</span>
<span
</Tooltip>
<Tooltip
v-if="contrast && large"
:text="hint_18pt"
class="rating"
:title="hint_18pt"
>
<span v-if="contrast.laaa">
<FAIcon icon="thumbs-up" />
<FAIcon
icon="thumbs-up"
:size="showRatio ? 'large' : ''"
/>
</span>
<span v-if="!contrast.laaa && contrast.laa">
<FAIcon icon="adjust" />
<FAIcon
icon="adjust"
:size="showRatio ? 'lg' : ''"
/>
</span>
<span v-if="!contrast.laaa && !contrast.laa">
<FAIcon icon="exclamation-triangle" />
<FAIcon
icon="exclamation-triangle"
:size="showRatio ? 'lg' : ''"
/>
</span>
</span>
</Tooltip>
</span>
</template>
<script>
import Tooltip from 'src/components/tooltip/tooltip.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAdjust,
@ -50,6 +73,9 @@ library.add(
)
export default {
components: {
Tooltip
},
props: {
large: {
required: false,
@ -62,6 +88,11 @@ export default {
required: false,
type: Object,
default: () => ({})
},
showRatio: {
required: false,
type: Boolean,
default: false
}
},
computed: {
@ -87,8 +118,7 @@ export default {
.contrast-ratio {
display: flex;
justify-content: flex-end;
margin-top: -4px;
margin-bottom: 5px;
align-items: baseline;
.label {
margin-right: 1em;
@ -96,7 +126,6 @@ export default {
.rating {
display: inline-block;
text-align: center;
margin-left: 0.5em;
}
}

View file

@ -56,7 +56,8 @@ const conversation = {
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null
inlineDivePosition: null,
loadStatusError: null
}
},
props: [
@ -349,6 +350,7 @@ const conversation = {
},
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.layoutType === 'mobile',
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
})
},
@ -392,11 +394,15 @@ const conversation = {
this.setHighlight(this.originalStatusId)
})
} else {
this.loadStatusError = null
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
this.loadStatusError = error
})
}
},
getReplies (id) {

View file

@ -9,7 +9,9 @@
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<span class="title"> {{ $t('timeline.conversation') }} </span>
<h1 class="title">
{{ $t('timeline.conversation') }}
</h1>
<button
v-if="collapsable"
class="button-unstyled -link"
@ -18,7 +20,7 @@
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable"
v-if="!collapsable && mobileLayout"
:conversation="true"
class="rightside-button"
/>
@ -28,7 +30,27 @@
class="rightside-button"
/>
</div>
<div class="conversation-body panel-body">
<div
v-if="isPage && !status"
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
@ -51,7 +73,7 @@
</template>
<template #text>
<span>
{{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
{{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
@ -124,7 +146,7 @@
</template>
<template #text>
<span>
{{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
{{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
</span>
</template>
</i18n-t>
@ -203,6 +225,7 @@
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
@ -210,14 +233,17 @@
<script src="./conversation.js"></script>
<style lang="scss">
@import "../../variables";
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin, $status-margin);
border-bottom: 1px solid var(--border, $fallback--border);
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
@ -227,20 +253,22 @@
}
.thread-ancestors {
margin-left: var(--status-margin, $status-margin);
border-left: 2px solid var(--border, $fallback--border);
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .StatusContent {
--link: var(--faintLink);
--text: var(--faint);
color: var(--text);
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin, $status-margin);
border-bottom: 1px solid var(--border, $fallback--border);
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
@ -253,16 +281,17 @@
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin, $status-margin);
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border, $fallback--border);
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
@ -270,20 +299,36 @@
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border, $fallback--border);
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid $fallback--cRed;
border-left-color: var(--cRed, $fallback--cRed);
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: 1px solid var(--border, $fallback--border);
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
margin: calc(var(--status-margin, $status-margin) / 2);
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
top: calc(var(--___margin) * -1);
bottom: calc(var(--___margin) * -1);
left: calc(var(--___margin) * -1);
right: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
}
</style>

View file

@ -108,7 +108,10 @@ export default {
this.searchBarHidden = hidden
},
openSettingsModal () {
useInterfaceStore().openSettingsModal()
useInterfaceStore().openSettingsModal('user')
},
openAdminModal () {
useInterfaceStore().openSettingsModal('admin')
}
}
}

View file

@ -1,5 +1,3 @@
@import "../../variables";
.DesktopNav {
width: 100%;
z-index: var(--ZI_navbar);
@ -8,10 +6,6 @@
color: var(--inputTopbarText, var(--inputText));
}
a {
color: var(--topBarLink, $fallback--link);
}
.inner-nav {
display: grid;
grid-template-rows: var(--navbar-height);
@ -54,27 +48,7 @@
.button-default {
&,
svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedTopBar, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedTopBarText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledTopBarText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg);
color: var(--text);
}
}
@ -94,8 +68,7 @@
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
background-color: var(--text);
position: absolute;
top: 0;
bottom: 0;
@ -114,11 +87,6 @@
width: 2em;
height: 100%;
text-align: center;
.svg-inline--fa {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
}
.sitename {

View file

@ -48,20 +48,19 @@
icon="cog"
/>
</button>
<a
<button
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
class="button-unstyled nav-icon"
target="_blank"
:title="$t('nav.administration')"
@click.stop
@click.stop="openAdminModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
/>
</a>
</button>
<span class="spacer" />
<button
v-if="currentUser"

View file

@ -8,6 +8,11 @@ const DialogModal = {
default: () => {},
type: Function
}
},
computed: {
mobileCenter () {
return this.$store.getters.mergedConfig.modalMobileCenter
}
}
}

View file

@ -1,6 +1,7 @@
<template>
<span
:class="{ 'dark-overlay': darkOverlay }"
class="dialog-container"
:class="{ 'dark-overlay': darkOverlay, '-center-mobile': mobileCenter }"
@click.self.stop="onCancel()"
>
<div
@ -8,11 +9,11 @@
@click.stop=""
>
<div class="panel-heading dialog-modal-heading">
<div class="title">
<h1 class="title">
<slot name="header" />
</div>
</h1>
</div>
<div class="dialog-modal-content">
<div class="panel-body dialog-modal-content">
<slot name="default" />
</div>
<div class="dialog-modal-footer user-interactions panel-footer">
@ -25,8 +26,6 @@
<script src="./dialog_modal.js"></script>
<style lang="scss">
@import "../../variables";
// TODO: unify with other modals.
.dark-overlay {
&::before {
@ -43,19 +42,24 @@
}
}
.dialog-modal.panel {
.dialog-container {
display: grid;
position: fixed;
top: 0;
left: 50%;
bottom: 0;
left: 0;
right: 0;
justify-content: center;
align-items: center;
justify-items: center;
}
.dialog-modal.panel {
max-height: 80vh;
max-width: 90vw;
margin: 15vh auto;
position: fixed;
transform: translateX(-50%);
z-index: 2001;
cursor: default;
display: block;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.dialog-modal-heading {
.title {
@ -66,24 +70,57 @@
.dialog-modal-content {
margin: 0;
padding: 1rem;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
white-space: normal;
text-align: center;
}
.dialog-modal-footer {
margin: 0;
padding: 0.5em;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--border;
border-top: 1px solid var(--border, $fallback--border);
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border);
display: grid;
justify-content: end;
grid-gap: 0.5em;
grid-template-columns: min-content;
grid-auto-columns: min-content;
grid-auto-flow: column dense;
height: auto;
button {
width: auto;
margin-left: 0.5rem;
white-space: nowrap;
padding-left: 2em;
padding-right: 2em;
}
}
}
#modal.-mobile {
.dialog-container {
justify-content: stretch;
align-items: end;
justify-items: stretch;
&.-center-mobile {
align-items: center;
}
}
.dialog-modal.panel {
min-width: 100vw;
}
.dialog-modal-footer {
flex-direction: column;
justify-content: flex-end;
grid-template-columns: 1fr;
grid-auto-columns: none;
grid-auto-rows: auto;
grid-auto-flow: row dense;
button {
grid-column: 1;
height: 3em;
}
}
}

View file

@ -0,0 +1,104 @@
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 { library } from '@fortawesome/fontawesome-svg-core'
import {
faPollH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPollH
)
const Draft = {
components: {
PostStatusForm,
EditStatusForm,
ConfirmModal,
StatusContent,
Gallery
},
props: {
draft: {
type: Object,
required: true
}
},
data () {
return {
referenceDraft: cloneDeep(this.draft),
editing: false,
showingConfirmDialog: false
}
},
computed: {
relAttrs () {
if (this.draft.type === 'edit') {
return { statusId: this.draft.refId }
} else if (this.draft.type === 'reply') {
return { replyTo: this.draft.refId }
} else {
return {}
}
},
safeToSave () {
return this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll
},
postStatusFormProps () {
return {
draftId: this.draft.id,
...this.relAttrs
}
},
refStatus () {
return this.draft.refId ? this.$store.state.statuses.allStatusesObject[this.draft.refId] : undefined
},
localCollapseSubjectDefault () {
return this.$store.getters.mergedConfig.collapseMessageWithSubject
},
nsfwClickthrough () {
if (!this.draft.nsfw) {
return false
}
if (this.draft.summary && this.localCollapseSubjectDefault) {
return false
}
return true
}
},
watch: {
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 () {
this.editing = !this.editing
},
abandon () {
this.showingConfirmDialog = true
},
doAbandon () {
this.$store.dispatch('abandonDraft', { id: this.draft.id })
.then(() => {
this.hideConfirmDialog()
})
},
hideConfirmDialog () {
this.showingConfirmDialog = false
}
}
}
export default Draft

View file

@ -0,0 +1,174 @@
<template>
<article class="Draft">
<div
v-if="!editing"
class="status-content"
>
<div>
<i18n-t
v-if="draft.type === 'reply' || draft.type === 'edit'"
tag="span"
:keypath="draft.type === 'reply' ? 'drafts.replying' : 'drafts.editing'"
>
<template #statusLink>
<router-link
class="faint-link"
:to="{ name: 'conversation', params: { id: draft.refId } }"
>
{{ refStatus ? refStatus.external_url : $t('drafts.unavailable') }}
</router-link>
</template>
</i18n-t>
<StatusContent
v-if="draft.refId && refStatus"
class="status-content"
:status="refStatus"
:compact="true"
/>
</div>
<div class="status-preview">
<span class="status_content">
<p v-if="draft.spoilerText">
<i>
{{ draft.spoilerText }}:
</i>
</p>
<p v-if="draft.status">{{ draft.status }}</p>
<p
v-else
class="faint"
>{{ $t('drafts.empty') }}</p>
</span>
<gallery
v-if="draft.files?.length !== 0"
class="attachments media-body"
:compact="true"
:nsfw="nsfwClickthrough"
:attachments="draft.files"
:limit="1"
size="small"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<div
v-if="draft.poll.options"
class="poll-indicator-container"
:title="$t('drafts.poll_tooltip')"
>
<div class="poll-indicator">
<FAIcon
icon="poll-h"
size="3x"
/>
</div>
</div>
</div>
</div>
<div v-if="editing">
<PostStatusForm
v-if="draft.type !== 'edit'"
:hide-draft="true"
v-bind="postStatusFormProps"
/>
<EditStatusForm
v-else
:hide-draft="true"
:params="postStatusFormProps"
/>
</div>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmDialog"
:title="$t('drafts.abandon_confirm_title')"
:confirm-text="$t('drafts.abandon_confirm_accept_button')"
:cancel-text="$t('drafts.abandon_confirm_cancel_button')"
@accepted="doAbandon"
@cancelled="hideConfirmDialog"
>
{{ $t('drafts.abandon_confirm') }}
</confirm-modal>
</teleport>
<div class="actions">
<button
class="btn button-default"
:aria-expanded="editing"
@click.prevent.stop="toggleEditing"
>
{{ editing ? $t('drafts.save') : $t('drafts.continue') }}
</button>
<button
class="btn button-default"
@click.prevent.stop="abandon"
>
{{ $t('drafts.abandon') }}
</button>
</div>
</article>
</template>
<script src="./draft.js"></script>
<style lang="scss">
.Draft {
position: relative;
.status-content {
padding: 0.5em;
margin: 0.5em 0;
}
.status-preview {
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: 10em;
grid-auto-flow: column;
grid-gap: 0.5em;
align-items: start;
max-width: 100%;
p {
word-wrap: break-word;
white-space: normal;
overflow-x: hidden;
}
.poll-indicator-container {
border-radius: var(--roundness);
display: grid;
justify-items: center;
align-items: center;
align-self: start;
height: 0;
padding-bottom: 62.5%;
position: relative;
}
.poll-indicator {
box-sizing: border-box;
border: 1px solid var(--border);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
justify-items: center;
align-items: center;
width: 100%;
height: 100%;
}
}
.actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
.btn {
flex: 1;
margin-left: 1em;
margin-right: 1em;
}
}
}
</style>

View file

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

View file

@ -0,0 +1,43 @@
<template>
<teleport to="#modal">
<dialog-modal
v-if="showing"
v-body-scroll-lock="true"
class="confirm-modal"
:on-cancel="cancel"
>
<template #header>
<span>
{{ $t('post_status.close_confirm_title') }}
</span>
</template>
{{ $t('post_status.close_confirm') }}
<template #footer>
<button
class="btn button-default"
@click.prevent="save"
>
{{ $t('post_status.close_confirm_save_button') }}
</button>
<button
class="btn button-default"
@click.prevent="discard"
>
{{ $t('post_status.close_confirm_discard_button') }}
</button>
<button
class="btn button-default"
@click.prevent="cancel"
>
{{ $t('post_status.close_confirm_continue_composing_button') }}
</button>
</template>
</dialog-modal>
</teleport>
</template>
<script src="./draft_closer.js"></script>

View file

@ -0,0 +1,16 @@
import Draft from 'src/components/draft/draft.vue'
import List from 'src/components/list/list.vue'
const Drafts = {
components: {
Draft,
List
},
computed: {
drafts () {
return this.$store.getters.draftsArray
}
}
}
export default Drafts

View file

@ -0,0 +1,36 @@
<template>
<div class="Drafts">
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('drafts.drafts') }}
</div>
</div>
<div class="panel-body">
<p v-if="drafts.length === 0">
{{ $t('drafts.no_drafts') }}
</p>
<List
v-else
:items="drafts"
:non-interactive="true"
>
<template #item="{ item: draft }">
<Draft
class="draft"
:draft="draft"
/>
</template>
</List>
</div>
</div>
</div>
</template>
<script src="./drafts.js"></script>
<style lang="scss">
.draft {
margin: 1em 0;
}
</style>

View file

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

View file

@ -0,0 +1,11 @@
<template>
<PostStatusForm
ref="postStatusForm"
v-bind="params"
:post-handler="doEditStatus"
:disable-polls="true"
:disable-visibility-selector="true"
/>
</template>
<script src="./edit_status_form.js"></script>

View file

@ -1,12 +1,11 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import EditStatusForm from '../edit_status_form/edit_status_form.vue'
import Modal from '../modal/modal.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import get from 'lodash/get'
import { useEditStatusStore } from '../../stores/editStatus'
const EditStatusModal = {
components: {
PostStatusForm,
EditStatusForm,
Modal
},
data () {
@ -44,30 +43,10 @@ const EditStatusModal = {
}
},
methods: {
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: useEditStatusStore().params.statusId,
status,
spoilerText,
sensitive,
poll,
media,
contentType
}
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
}
})
},
closeModal () {
this.$refs.editStatusForm.requestClose()
},
doCloseModal () {
useEditStatusStore().closeEditStatusModal()
}
}

View file

@ -6,15 +6,17 @@
>
<div class="edit-form-modal-panel panel">
<div class="panel-heading">
{{ $t('post_status.edit_status') }}
<h1 class="title">
{{ $t('post_status.edit_status') }}
</h1>
</div>
<PostStatusForm
<EditStatusForm
ref="editStatusForm"
class="panel-body"
v-bind="params"
:post-handler="doEditStatus"
:disable-polls="true"
:disable-visibility-selector="true"
@posted="closeModal"
:params="params"
@posted="doCloseModal"
@draft-done="doCloseModal"
@can-close="doCloseModal"
/>
</div>
</Modal>

View file

@ -1,4 +1,5 @@
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 Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
@ -110,7 +111,7 @@ const EmojiInput = {
},
data () {
return {
randomSeed: `${Math.random()}`.replace('.', '-'),
randomSeed: genRandomSeed(),
input: undefined,
caretEl: undefined,
highlighted: -1,
@ -292,9 +293,12 @@ const EmojiInput = {
}))
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$tc('tool_tip.autocomplete_available',
this.suggestions.length,
{ number: this.suggestions.length }))
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length
)
)
}
},
methods: {

View file

@ -1,7 +1,7 @@
<template>
<div
ref="root"
class="emoji-input"
class="input emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
>
<slot
@ -68,9 +68,9 @@
v-for="(suggestion, index) in suggestions"
:id="suggestionItemId(index)"
:key="index"
class="autocomplete-item"
class="menu-item autocomplete-item"
role="option"
:class="{ highlighted: index === highlighted }"
:class="{ '-active': index === highlighted }"
:aria-label="autoCompleteItemLabel(suggestion)"
:aria-selected="index === highlighted"
@click.stop.prevent="onClick($event, suggestion)"
@ -110,9 +110,8 @@
<script src="./emoji_input.js"></script>
<style lang="scss">
@import "../../variables";
.emoji-input {
.input.emoji-input {
padding: 0;
display: flex;
flex-direction: column;
position: relative;
@ -127,8 +126,7 @@
line-height: 24px;
&:hover i {
color: $fallback--text;
color: var(--text, $fallback--text);
color: var(--text);
}
}
@ -145,6 +143,12 @@
input,
textarea {
flex: 1 0 auto;
color: inherit;
/* stylelint-disable-next-line declaration-no-important */
background: none !important;
box-shadow: none;
border: none;
outline: none;
}
&.with-picker input {
@ -179,26 +183,28 @@
position: absolute;
}
&-item {
&-item.menu-item {
display: flex;
cursor: pointer;
padding: 0.2em 0.4em;
border-bottom: 1px solid rgb(0 0 0 / 40%);
height: 32px;
padding-top: 0;
padding-bottom: 0;
.image {
width: 32px;
height: 32px;
line-height: 32px;
width: calc(var(--__line-height) + var(--__vertical-gap) * 2);
height: calc(var(--__line-height) + var(--__vertical-gap) * 2);
line-height: var(--__line-height);
text-align: center;
font-size: 32px;
margin-right: 4px;
margin-right: var(--__horizontal-gap);
img {
width: 32px;
height: 32px;
width: calc(var(--__line-height) + var(--__vertical-gap) * 2);
height: calc(var(--__line-height) + var(--__vertical-gap) * 2);
object-fit: contain;
}
span {
font-size: var(--__line-height);
line-height: var(--__line-height);
}
}
.label {
@ -216,17 +222,6 @@
line-height: 9px;
}
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--selectedMenuPopover, $fallback--fg);
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
}
}
</style>

View file

@ -97,7 +97,7 @@ const EmojiPicker = {
enableStickerPicker: {
required: false,
type: Boolean,
default: false
default: true
},
hideCustomEmoji: {
required: false,
@ -105,6 +105,11 @@ const EmojiPicker = {
default: false
}
},
inject: {
popoversZLayer: {
default: ''
}
},
data () {
return {
keyword: '',
@ -113,11 +118,13 @@ const EmojiPicker = {
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0
}
},
@ -128,9 +135,42 @@ const EmojiPicker = {
Popover
},
methods: {
groupScroll (e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize () {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '14px'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '')
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '')
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + (2 * 0.2 * fontSizeMultiplier * 14)
this.emojiSize = fullEmojiSize
},
showPicker () {
this.$refs.popover.showPopover()
this.onShowing()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker () {
this.$refs.popover.hidePopover()
@ -158,7 +198,7 @@ const EmojiPicker = {
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
this.$emit('emoji', { insertion: value, insertionUrl: emoji.imageUrl, keepOpen: this.keepOpen })
},
onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
@ -222,6 +262,7 @@ const EmojiPicker = {
},
onShowing () {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
@ -264,16 +305,20 @@ const EmojiPicker = {
},
computed: {
minItemSize () {
return this.emojiHeight
return this.emojiSize
},
// used to watch it
fontSize () {
this.$nextTick(() => {
this.updateEmojiSize()
})
return this.$store.getters.mergedConfig.fontSize
},
emojiHeight () {
return 32 + 4
},
emojiWidth () {
return 32 + 4
return this.emojiSize
},
itemPerRow () {
return this.width ? Math.floor(this.width / this.emojiWidth - 1) : 6
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@ -285,7 +330,7 @@ const EmojiPicker = {
return 0
},
allCustomGroups () {
if (this.hideCustomEmoji) {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = this.$store.getters.groupedCustomEmojis
@ -350,6 +395,9 @@ const EmojiPicker = {
return emoji.displayText
}
},
isInModal () {
return this.popoversZLayer === 'modals'
}
}
}

View file

@ -1,62 +1,56 @@
@import "../../variables";
$emoji-picker-header-height: 36px;
$emoji-picker-header-picture-width: 32px;
$emoji-picker-header-picture-height: 32px;
$emoji-picker-emoji-size: 32px;
.emoji-picker {
--__emoji-picker-header: 2.2em;
width: 25em;
max-width: calc(100vw - 20px); // popover gives 10px margin from window edge
display: flex;
flex-direction: column;
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
&-header-image {
display: inline-flex;
justify-content: center;
align-items: center;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
.still-image {
max-width: 100%;
max-height: 100%;
height: 100%;
width: 100%;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
object-fit: contain;
--_still_image-label-scale: 0.5;
}
}
.keep-open,
.too-many-emoji {
padding: 7px;
.too-many-emoji,
.hide-custom-emoji {
padding: 0.5em;
line-height: normal;
}
.hide-custom-emoji {
padding-top: 0;
}
.too-many-emoji {
display: flex;
flex-direction: column;
}
.keep-open-label {
padding: 0 7px;
padding: 0 0.5em;
display: flex;
}
.heading {
display: flex;
padding: 10px 7px 5px;
flex-direction: column;
padding: 0.7em 0.5em 0;
}
.content {
@ -71,14 +65,14 @@ $emoji-picker-emoji-size: 32px;
display: flex;
flex-flow: row nowrap;
overflow-x: auto;
overflow-y: hidden;
}
.additional-tabs {
display: flex;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
padding-left: 7px;
border-left-color: var(--border);
padding-left: 0.5em;
flex: 0 0 auto;
}
@ -87,30 +81,29 @@ $emoji-picker-emoji-size: 32px;
flex-basis: auto;
display: flex;
align-content: center;
scrollbar-width: thin;
&-item {
padding: 0 7px;
padding: 0 0.5em;
cursor: pointer;
font-size: 1.85em;
width: $emoji-picker-header-picture-width;
max-width: $emoji-picker-header-picture-width;
height: $emoji-picker-header-picture-height;
max-height: $emoji-picker-header-picture-height;
width: var(--__emoji-picker-header);
max-width: var(--__emoji-picker-header);
height: var(--__emoji-picker-header);
max-height: var(--__emoji-picker-header);
display: flex;
align-items: center;
.svg-inline--fa {
font-size: 1.85em;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.active {
border-bottom: 4px solid;
svg {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
&.toggled {
border-bottom: 0.2em solid;
}
}
}
@ -137,7 +130,7 @@ $emoji-picker-emoji-size: 32px;
.emoji {
&-search {
padding: 5px;
padding-bottom: 0.5em;
flex: 0 0 auto;
input {
@ -151,6 +144,7 @@ $emoji-picker-emoji-size: 32px;
flex: 1 1 1px;
position: relative;
overflow: auto;
scrollbar-gutter: stable both-edges;
user-select: none;
mask:
linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
@ -173,45 +167,60 @@ $emoji-picker-emoji-size: 32px;
}
}
&-group {
&-item {
width: var(--emoji-size);
height: var(--emoji-size);
box-sizing: border-box;
display: flex;
line-height: var(--emoji-size);
align-items: center;
flex-wrap: wrap;
padding-left: 5px;
justify-content: left;
justify-content: center;
margin: 0.2em;
cursor: pointer;
.emoji-picker-emoji.-custom {
object-fit: contain;
width: var(--emoji-size);
max-width: var(--emoji-size);
height: var(--emoji-size);
max-height: var(--emoji-size);
--_still_image-label-scale: 0.5;
}
.emoji-picker-emoji.-unicode {
font-size: calc(var(--emoji-size) * 0.8);
overflow: hidden;
}
}
&-group {
display: grid;
grid-template-columns: repeat(var(--__amount), 1fr);
align-items: center;
justify-items: center;
justify-content: center;
grid-template-rows: repeat(1, auto);
&.first-row {
grid-template-rows: repeat(2, auto);
.emoji-item {
grid-row: 2;
}
}
&-title {
font-size: 0.85em;
grid-column: span var(--__amount);
width: 100%;
margin: 0;
padding-left: 0.3em;
&.disabled {
display: none;
}
}
}
&-item {
width: $emoji-picker-emoji-size;
height: $emoji-picker-emoji-size;
box-sizing: border-box;
display: flex;
line-height: $emoji-picker-emoji-size;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
.emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
.emoji-picker-emoji.-unicode {
font-size: 24px;
overflow: hidden;
}
}
}
}

View file

@ -3,25 +3,45 @@
ref="popover"
trigger="click"
popover-class="emoji-picker popover-default"
:trigger-attrs="{ 'aria-hidden': true }"
:trigger-attrs="{ 'aria-hidden': true, tabindex: -1 }"
@show="onPopoverShown"
@close="onPopoverClosed"
>
<template #content>
<div class="heading">
<div
class="heading"
>
<div class="emoji-search">
<input
ref="search"
v-model="keyword"
type="text"
class="input form-control"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<!--
Body scroll lock needs to be on every scrollable element on safari iOS.
Here we tell it to enable scrolling for this element.
See https://github.com/willmcpo/body-scroll-lock#vanilla-js
-->
<span
ref="header"
v-body-scroll-lock="isInModal"
class="emoji-tabs"
@wheel.prevent="groupScroll"
>
<span
v-for="group in filteredEmojiGroups"
:ref="setGroupRef('group-header-' + group.id)"
:key="group.id"
class="emoji-tabs-item"
class="button-unstyled emoji-tabs-item"
:class="{
active: activeGroupView === group.id
toggled: activeGroupView === group.id
}"
:title="group.text"
role="button"
@click.prevent="highlight(group.id)"
>
<span
@ -45,8 +65,8 @@
class="additional-tabs"
>
<span
class="stickers-tab-icon additional-tabs-item"
:class="{active: showingStickers}"
class="button-unstyled stickers-tab-icon additional-tabs-item"
:class="{toggled: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
@ -65,21 +85,14 @@
class="emoji-content"
:class="{hidden: showingStickers}"
>
<div class="emoji-search">
<input
ref="search"
v-model="keyword"
type="text"
class="form-control"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<!-- Enables scrolling for this element on safari iOS. See comments for header. -->
<DynamicScroller
ref="emoji-groups"
v-body-scroll-lock="isInModal"
class="emoji-groups"
:class="groupsScrolledClass"
:min-item-size="minItemSize"
:buffer="minItemSize"
:items="emojiItems"
:emit-update="true"
@update="onScroll"
@ -96,6 +109,8 @@
>
<div
class="emoji-group"
:class="{ ['first-row']: group.isFirstRow }"
:style="{ '--__amount': itemPerRow }"
>
<h6
v-if="group.isFirstRow"
@ -108,6 +123,7 @@
:key="group.id + emoji.displayText"
:title="maybeLocalizedEmojiName(emoji)"
class="emoji-item"
role="button"
@click.stop.prevent="onEmoji(emoji)"
>
<span
@ -118,6 +134,7 @@
v-else
class="emoji-picker-emoji -custom"
loading="lazy"
:alt="maybeLocalizedEmojiName(emoji)"
:src="emoji.imageUrl"
:data-emoji-name="group.id + emoji.displayText"
/>
@ -131,6 +148,17 @@
{{ $t('emoji.keep_open') }}
</Checkbox>
</div>
<div
v-if="!hideCustomEmoji"
class="hide-custom-emoji"
>
<Checkbox
v-model="hideCustomEmojiInPicker"
@change="onShowing"
>
{{ $t('emoji.hide_custom_emoji') }}
</Checkbox>
</div>
</div>
<div
v-if="showingStickers"

View file

@ -1,5 +1,18 @@
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'
library.add(
faPlus,
faMinus,
faCheck
)
const EMOJI_REACTION_COUNT_CUTOFF = 12
@ -7,7 +20,8 @@ const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
UserListPopover
UserListPopover,
StillImage
},
props: ['status'],
data: () => ({
@ -33,6 +47,9 @@ const EmojiReactions = {
},
loggedIn () {
return !!this.$store.state.users.currentUser
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
},
methods: {
@ -42,10 +59,10 @@ const EmojiReactions = {
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
},
fetchEmojiReactionsByIfMissing () {
async fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
if (hasNoAccounts) {
this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
}
},
reactWith (emoji) {
@ -54,14 +71,29 @@ const EmojiReactions = {
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
emojiOnClick (emoji, event) {
async emojiOnClick (emoji, event) {
if (!this.loggedIn) return
await this.fetchEmojiReactionsByIfMissing()
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
},
counterTriggerAttrs (reaction) {
return {
class: [
'btn',
'button-default',
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),
toggled: this.reactedWith(reaction.name)
}
],
'aria-label': this.$t('status.reaction_count_label', { num: reaction.count }, reaction.count)
}
}
}
}

View file

@ -1,34 +1,63 @@
<template>
<div class="EmojiReactions">
<UserListPopover
<span
v-for="(reaction) in emojiReactions"
:key="reaction.url || reaction.name"
:users="accountsForEmoji[reaction.name]"
class="emoji-reaction-container btn-group"
>
<button
<component
:is="loggedIn ? 'button' : 'a'"
v-bind="!loggedIn ? { href: remoteInteractionLink } : {}"
role="button"
class="emoji-reaction btn button-default"
:class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
:class="{ '-picked-reaction': reactedWith(reaction.name), toggled: reactedWith(reaction.name) }"
:title="reaction.url ? reaction.name : undefined"
:aria-pressed="reactedWith(reaction.name)"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
<span
class="reaction-emoji"
>
<img
<StillImage
v-if="reaction.url"
:src="reaction.url"
:title="reaction.name"
class="reaction-emoji-content"
width="1em"
>
/>
<span
v-else
class="reaction-emoji reaction-emoji-content"
>{{ reaction.name }}</span>
</span>
<span>{{ reaction.count }}</span>
</button>
</UserListPopover>
<FALayers>
<FAIcon
v-if="reactedWith(reaction.name)"
class="active-marker"
transform="shrink-6 up-9"
icon="check"
/>
<FAIcon
v-if="!reactedWith(reaction.name)"
class="focus-marker"
transform="shrink-6 up-9"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9"
icon="minus"
/>
</FALayers>
</component>
<UserListPopover
:users="accountsForEmoji[reaction.name]"
class="emoji-reaction-popover"
:trigger-attrs="counterTriggerAttrs(reaction)"
@show="fetchEmojiReactionsByIfMissing()"
>
<span class="emoji-reaction-counts">{{ reaction.count }}</span>
</UserListPopover>
</span>
<a
v-if="tooManyReactions"
class="emoji-reaction-expand faint"
@ -42,23 +71,47 @@
<script src="./emoji_reactions.js"></script>
<style lang="scss">
@import "../../variables";
@import "../../mixins";
.EmojiReactions {
display: flex;
margin-top: 0.25em;
flex-wrap: wrap;
--emoji-size: calc(1.25em * var(--emojiReactionsScale, 1));
--emoji-size: calc(var(--emojiSize, 1.25em) * var(--emojiReactionsScale, 1));
.emoji-reaction-container {
display: flex;
align-items: stretch;
margin-top: 0.5em;
margin-right: 0.5em;
.emoji-reaction-popover {
padding: 0;
.emoji-reaction-count-button {
margin: 0;
height: 100%;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-sizing: border-box;
min-width: 2em;
display: inline-flex;
justify-content: center;
align-items: center;
}
}
}
.emoji-reaction {
padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
padding-left: 0.5em;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin: 0;
.reaction-emoji {
width: var(--emoji-size);
@ -68,36 +121,61 @@
display: flex;
justify-content: center;
align-items: center;
--_still_image-label-scale: 0.3;
}
.reaction-emoji-content {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
width: var(--emoji-size);
height: var(--emoji-size);
line-height: inherit;
overflow: hidden;
font-size: calc(var(--emoji-size) * 0.8);
margin: 0;
img {
object-fit: contain;
}
}
&:focus {
outline: none;
}
&.not-clickable {
cursor: default;
&:hover {
box-shadow: $fallback--buttonShadow;
box-shadow: var(--buttonShadow);
}
.svg-inline--fa {
color: var(--text);
}
&.-picked-reaction {
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
.svg-inline--fa {
color: var(--accent);
}
}
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
.active-marker {
visibility: visible;
}
}
@include focused-style {
.svg-inline--fa {
color: var(--accent);
}
.focus-marker {
visibility: visible;
}
.active-marker {
visibility: hidden;
}
}
}

View file

@ -1,162 +0,0 @@
import Popover from '../popover/popover.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisH,
faBookmark,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faHistory,
faPlus,
faTimes
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
faFlag
} from '@fortawesome/free-regular-svg-icons'
import { useEditStatusStore } from '../../stores/editStatus'
import { useStatusHistoryStore } from '../../stores/statusHistory'
import { useReportsStore } from '../../stores/reports'
library.add(
faEllipsisH,
faBookmark,
faBookmarkReg,
faEyeSlash,
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faFlag,
faHistory,
faPlus,
faTimes
)
const ExtraButtons = {
props: ['status'],
components: {
Popover,
ConfirmModal
},
data () {
return {
expanded: false,
showingDeleteDialog: false
}
},
methods: {
onShow () {
this.expanded = true
},
onClose () {
this.expanded = false
},
deleteStatus () {
if (this.shouldConfirmDelete) {
this.showDeleteStatusConfirmDialog()
} else {
this.doDeleteStatus()
}
},
doDeleteStatus () {
this.$store.dispatch('deleteStatus', { id: this.status.id })
this.hideDeleteStatusConfirmDialog()
},
showDeleteStatusConfirmDialog () {
this.showingDeleteDialog = true
},
hideDeleteStatusConfirmDialog () {
this.showingDeleteDialog = false
},
pinStatus () {
this.$store.dispatch('pinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unpinStatus () {
this.$store.dispatch('unpinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
muteConversation () {
this.$store.dispatch('muteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unmuteConversation () {
this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
reportStatus () {
useReportsStore().openUserReportingModal({ userId: this.status.user.id, statusIds: [this.status.id] })
},
editStatus () {
this.$store.dispatch('fetchStatusSource', { id: this.status.id })
.then(data => useEditStatusStore().openEditStatusModal({
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
visibility: this.status.visibility,
statusContentType: data.content_type
}))
},
showStatusHistory () {
const originalStatus = { ...this.status }
const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
stripFieldsList.forEach(p => delete originalStatus[p])
useStatusHistoryStore().openStatusHistoryModal(originalStatus)
}
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
canDelete () {
if (!this.currentUser) { return }
return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id
},
ownStatus () {
return this.status.user.id === this.currentUser.id
},
canPin () {
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
},
canMute () {
return !!this.currentUser
},
canBookmark () {
return !!this.currentUser
},
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
},
isEdited () {
return this.status.edited_at !== null
},
editingAvailable () { return this.$store.state.instance.editingAvailable },
shouldConfirmDelete () {
return this.$store.getters.mergedConfig.modalOnDelete
}
}
}
export default ExtraButtons

View file

@ -1,219 +0,0 @@
<template>
<Popover
class="ExtraButtons"
trigger="click"
placement="top"
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
remove-padding
@show="onShow"
@close="onClose"
>
<template #content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="muteConversation"
>
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t("status.mute_conversation") }}</span>
</button>
<button
v-if="canMute && status.thread_muted"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation"
>
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t("status.unmute_conversation") }}</span>
</button>
<button
v-if="!status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="pinStatus"
@click="close"
>
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t("status.pin") }}</span>
</button>
<button
v-if="status.pinned && canPin"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus"
@click="close"
>
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span>
</button>
<template v-if="canBookmark">
<button
v-if="!status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
<FAIcon
fixed-width
:icon="['far', 'bookmark']"
/><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
<FAIcon
fixed-width
icon="bookmark"
/><span>{{ $t("status.unbookmark") }}</span>
</button>
</template>
<button
v-if="ownStatus && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="editStatus"
@click="close"
>
<FAIcon
fixed-width
icon="pen"
/><span>{{ $t("status.edit") }}</span>
</button>
<button
v-if="isEdited && editingAvailable"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="showStatusHistory"
@click="close"
>
<FAIcon
fixed-width
icon="history"
/><span>{{ $t("status.status_history") }}</span>
</button>
<button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
@click="close"
>
<FAIcon
fixed-width
icon="times"
/><span>{{ $t("status.delete") }}</span>
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="copyLink"
@click="close"
>
<FAIcon
fixed-width
icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span>
</button>
<a
v-if="!status.is_local"
class="button-default dropdown-item dropdown-item-icon"
title="Source"
:href="status.external_url"
target="_blank"
>
<FAIcon
fixed-width
icon="external-link-alt"
/><span>{{ $t("status.external_source") }}</span>
</a>
<button
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="reportStatus"
@click="close"
>
<FAIcon
fixed-width
:icon="['far', 'flag']"
/><span>{{ $t("user_card.report") }}</span>
</button>
</div>
</template>
<template #trigger>
<span class="button-unstyled popover-trigger">
<FALayers class="fa-old-padding-layer">
<FAIcon
class="fa-scale-110 "
icon="ellipsis-h"
/>
<FAIcon
v-show="!expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="plus"
/>
<FAIcon
v-show="expanded"
class="focus-marker"
transform="shrink-6 up-8 right-16"
icon="times"
/>
</FALayers>
</span>
<teleport to="#modal">
<ConfirmModal
v-if="showingDeleteDialog"
:title="$t('status.delete_confirm_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="hideDeleteStatusConfirmDialog"
@accepted="doDeleteStatus"
>
{{ $t('status.delete_confirm') }}
</ConfirmModal>
</teleport>
</template>
</Popover>
</template>
<script src="./extra_buttons.js"></script>
<style lang="scss">
@import "../../variables";
@import "../../mixins";
.ExtraButtons {
.popover-trigger {
position: static;
padding: 10px;
margin: -10px;
&:hover .svg-inline--fa {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.popover-trigger-button {
/* override of popover internal stuff */
width: auto;
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
}
}
}
</style>

View file

@ -0,0 +1,48 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUserPlus,
faComments,
faBullhorn
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUserPlus,
faComments,
faBullhorn
)
const ExtraNotifications = {
computed: {
shouldShowChats () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showChatsInExtraNotifications && this.unreadChatCount
},
shouldShowAnnouncements () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showAnnouncementsInExtraNotifications && this.unreadAnnouncementCount
},
shouldShowFollowRequests () {
return this.mergedConfig.showExtraNotifications && this.mergedConfig.showFollowRequestsInExtraNotifications && this.followRequestCount
},
hasAnythingToShow () {
return this.shouldShowChats || this.shouldShowAnnouncements || this.shouldShowFollowRequests
},
shouldShowCustomizationTip () {
return this.mergedConfig.showExtraNotificationsTip && this.hasAnythingToShow
},
currentUser () {
return this.$store.state.users.currentUser
},
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount', 'followRequestCount', 'mergedConfig'])
},
methods: {
openNotificationSettings () {
return this.$store.dispatch('openSettingsModalTab', 'notifications')
},
dismissConfigurationTip () {
return this.$store.dispatch('setOption', { name: 'showExtraNotificationsTip', value: false })
}
}
}
export default ExtraNotifications

View file

@ -0,0 +1,111 @@
<template>
<div class="ExtraNotifications">
<div
v-if="shouldShowChats"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="comments"
/>
{{ $t('notifications.unread_chats', { num: unreadChatCount }, unreadChatCount) }}
</router-link>
</div>
<div
v-if="shouldShowAnnouncements"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'announcements' }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="bullhorn"
/>
{{ $t('notifications.unread_announcements', { num: unreadAnnouncementCount }, unreadAnnouncementCount) }}
</router-link>
</div>
<div
v-if="shouldShowFollowRequests"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'friend-requests' }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="user-plus"
/>
{{ $t('notifications.unread_follow_requests', { num: followRequestCount }, followRequestCount) }}
</router-link>
</div>
<i18n-t
v-if="shouldShowCustomizationTip"
tag="span"
class="notification tip extra-notification"
keypath="notifications.configuration_tip"
scope="global"
>
<template #theSettings>
<button
class="button-unstyled -link"
@click="openNotificationSettings"
>
{{ $t('notifications.configuration_tip_settings') }}
</button>
</template>
<template #dismiss>
<button
class="button-unstyled -link"
@click="dismissConfigurationTip"
>
{{ $t('notifications.configuration_tip_dismiss') }}
</button>
</template>
</i18n-t>
</div>
</template>
<script src="./extra_notifications.js" />
<style lang="scss">
.ExtraNotifications {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
.notification {
width: 100%;
border-bottom: 1px solid;
border-color: var(--border);
display: flex;
flex-direction: column;
align-items: stretch;
}
.extra-notification {
padding: 1em;
}
.icon {
margin-right: 0.5em;
}
.tip {
display: inline;
}
}
</style>

View file

@ -1,49 +0,0 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faStar,
faPlus,
faMinus,
faCheck
} from '@fortawesome/free-solid-svg-icons'
import {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faStar,
faStarRegular,
faPlus,
faMinus,
faCheck
)
const FavoriteButton = {
props: ['status', 'loggedIn'],
data () {
return {
animated: false
}
},
methods: {
favorite () {
if (!this.status.favorited) {
this.$store.dispatch('favorite', { id: this.status.id })
} else {
this.$store.dispatch('unfavorite', { id: this.status.id })
}
this.animated = true
setTimeout(() => {
this.animated = false
}, 500)
}
},
computed: {
...mapGetters(['mergedConfig']),
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}
export default FavoriteButton

View file

@ -1,116 +0,0 @@
<template>
<div class="FavoriteButton">
<button
v-if="loggedIn"
class="button-unstyled interactive"
:class="status.favorited && '-favorited'"
:title="$t('tool_tip.favorite')"
@click.prevent="favorite()"
>
<FALayers class="fa-scale-110 fa-old-padding-layer">
<FAIcon
class="fa-scale-110"
:icon="[status.favorited ? 'fas' : 'far', 'star']"
:spin="animated"
/>
<FAIcon
v-if="status.favorited"
class="active-marker"
transform="shrink-6 up-9 right-12"
icon="check"
/>
<FAIcon
v-if="!status.favorited"
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="plus"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="minus"
/>
</FALayers>
</button>
<a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:title="$t('tool_tip.favorite')"
:href="remoteInteractionLink"
>
<FALayers class="fa-scale-110 fa-old-padding-layer">
<FAIcon
class="fa-scale-110"
:icon="['far', 'star']"
/>
<FAIcon
class="focus-marker"
transform="shrink-6 up-9 right-12"
icon="plus"
/>
</FALayers>
</a>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter"
>
{{ status.fave_num }}
</span>
</div>
</template>
<script src="./favorite_button.js"></script>
<style lang="scss">
@import "../../variables";
@import "../../mixins";
.FavoriteButton {
display: flex;
> :first-child {
padding: 10px;
margin: -10px -8px -10px -10px;
}
.action-counter {
pointer-events: none;
user-select: none;
}
.interactive {
.svg-inline--fa {
animation-duration: 0.6s;
}
&:hover .svg-inline--fa,
&.-favorited .svg-inline--fa {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
@include unfocused-style {
.focus-marker {
visibility: hidden;
}
.active-marker {
visibility: visible;
}
}
@include focused-style {
.focus-marker {
visibility: visible;
}
.active-marker {
visibility: hidden;
}
}
}
}
</style>

View file

@ -2,9 +2,9 @@
<div class="features-panel">
<div class="panel panel-default base01-background">
<div class="panel-heading timeline-heading base02-background base04">
<div class="title">
<h1 class="title">
{{ $t('features_panel.title') }}
</div>
</h1>
</div>
<div class="panel-body features-panel">
<ul>

View file

@ -42,8 +42,6 @@
<script src="./flash.js"></script>
<style lang="scss">
@import "../../variables";
.Flash {
display: inline-block;
width: 100%;

View file

@ -17,6 +17,7 @@
@cancelled="hideConfirmUnfollow"
>
<i18n-t
scope="global"
keypath="user_card.unfollow_confirm"
tag="span"
>

View file

@ -1,9 +1,9 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<div class="title">
<h1 class="title">
{{ $t('nav.friend_requests') }}
</div>
</h1>
</div>
<div class="panel-body">
<FollowRequestCard

View file

@ -1,63 +1,59 @@
import { set } from 'lodash'
import Select from '../select/select.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faKeyboard,
faFont
} from '@fortawesome/free-solid-svg-icons'
library.add(
faExclamationTriangle,
faKeyboard,
faFont
)
export default {
components: {
Select
Select,
Checkbox,
Popover
},
props: [
'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
],
mounted () {
this.$store.dispatch('queryLocalFonts')
},
emits: ['update:modelValue'],
data () {
return {
lValue: this.modelValue,
manualEntry: false,
availableOptions: [
this.noInherit ? '' : 'inherit',
'custom',
...(this.options || []),
'serif',
'sans-serif',
'monospace',
'sans-serif'
...(this.options || [])
].filter(_ => _)
}
},
beforeUpdate () {
this.lValue = this.modelValue
methods: {
toggleManualEntry () {
this.manualEntry = !this.manualEntry
}
},
computed: {
present () {
return typeof this.lValue !== 'undefined'
return typeof this.modelValue !== 'undefined'
},
dValue () {
return this.lValue || this.fallback || {}
localFontsList () {
return this.$store.state.interface.localFonts
},
family: {
get () {
return this.dValue.family
},
set (v) {
set(this.lValue, 'family', v)
this.$emit('update:modelValue', this.lValue)
}
},
isCustom () {
return this.preset === 'custom'
},
preset: {
get () {
if (this.family === 'serif' ||
this.family === 'sans-serif' ||
this.family === 'monospace' ||
this.family === 'inherit') {
return this.family
} else {
return 'custom'
}
},
set (v) {
this.family = v === 'custom' ? '' : v
}
localFontsSize () {
return this.$store.state.interface.localFonts?.length
}
}
}

View file

@ -1,76 +1,157 @@
<template>
<div
class="font-control style-control"
:class="{ custom: isCustom }"
>
<div class="font-control">
<label
:id="name + '-label'"
:for="preset === 'custom' ? name : name + '-font-switcher'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ label }}
{{ $t('settings.style.themes3.font.label', { label }) }}
</label>
<input
{{ ' ' }}
<Checkbox
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
:aria-labelledby="name + '-label'"
class="opt exlcude-disabled visible-for-screenreader-only"
type="checkbox"
:checked="present"
class="font-checkbox"
:model-value="present"
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
:aria-hidden="true"
/>
{{ ' ' }}
<Select
:id="name + '-font-switcher'"
v-model="preset"
:disabled="!present"
class="font-switcher"
{{ $t('settings.style.themes3.define') }}
</Checkbox>
<div
v-if="modelValue?.family"
class="font-input"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
<label
v-if="manualEntry"
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ option === 'custom' ? $t('settings.style.fonts.custom') : option }}
</option>
</Select>
<input
v-if="isCustom"
:id="name"
v-model="family"
class="custom-font"
type="text"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
scope="global"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue.family"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', { ...(modelValue || {}), family: $event.target.value })"
>
</span>
<span
v-else
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.enter_manually')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue?.family"
class="custom-font"
@update:modelValue="v => $emit('update:modelValue', { ...(modelValue || {}), family: v })"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</div>
</div>
</template>
<script src="./font_control.js"></script>
<style lang="scss">
@import "../../variables";
.font-control {
input.custom-font {
min-width: 10em;
.custom-font {
min-width: 20em;
max-width: 20em;
}
&.custom {
/* TODO Should make proper joiners... */
.font-switcher {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.font-input {
margin-left: 2em;
margin-top: 0.5em;
}
.custom-font {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.font-checkbox {
margin-left: 1em;
}
}
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>

View file

@ -0,0 +1,40 @@
export default {
name: 'FunText',
selector: '/*fun-text*/',
virtual: true,
variants: {
greentext: '.greentext',
cyantext: '.cyantext'
},
states: {
faint: '.faint'
},
defaultRules: [
{
directives: {
textColor: '--text',
textAuto: 'preserve'
}
},
{
state: ['faint'],
directives: {
textOpacity: 0.5
}
},
{
variant: 'greentext',
directives: {
textColor: '--cGreen',
textAuto: 'preserve'
}
},
{
variant: 'cyantext',
directives: {
textColor: '--cBlue',
textAuto: 'preserve'
}
}
]
}

View file

@ -87,8 +87,6 @@
<script src='./gallery.js'></script>
<style lang="scss">
@import "../../variables";
.Gallery {
.gallery-rows {
display: flex;

View file

@ -4,7 +4,7 @@
v-for="(notice, index) in notices"
:key="index"
class="alert global-notice"
:class="{ ['global-' + notice.level]: true }"
:class="{ [notice.level]: true }"
>
<div class="notice-message">
{{ $t(notice.messageKey, notice.messageArgs) }}
@ -25,14 +25,12 @@
<script src="./global_notice_list.js"></script>
<style lang="scss">
@import "../../variables";
.global-notice-list {
position: fixed;
top: calc(var(--navbar-height) + 0.5em);
width: 100%;
pointer-events: none;
z-index: var(--ZI_navbar_popovers);
z-index: var(--ZI_modals_popovers);
display: flex;
flex-direction: column;
align-items: center;
@ -52,48 +50,8 @@
}
}
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupErrorText, $fallback--text);
}
}
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupWarningText, $fallback--text);
}
}
.global-success {
background-color: var(--alertPopupSuccess, $fallback--cGreen);
color: var(--alertPopupSuccessText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupSuccessText, $fallback--text);
}
}
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
.svg-inline--fa {
color: var(--alertPopupNeutralText, $fallback--text);
}
}
.close-notice {
padding-right: 0.2em;
.svg-inline--fa:hover {
opacity: 0.6;
}
}
}
</style>

View file

@ -0,0 +1,14 @@
export default {
name: 'Icon',
virtual: true,
selector: '.svg-inline--fa',
defaultRules: [
{
component: 'Icon',
directives: {
textColor: '$blend(--stack 0.5 --parent--text)',
textAuto: 'no-auto'
}
}
]
}

View file

@ -41,7 +41,7 @@
<input
ref="input"
type="file"
class="image-cropper-img-input"
class="input image-cropper-img-input"
:accept="mimes"
>
</div>

View file

@ -3,6 +3,7 @@
<form>
<input
ref="input"
class="input"
type="file"
@change="change"
>

View file

@ -0,0 +1,94 @@
export default {
name: 'Input',
selector: '.input',
states: {
hover: ':hover:not(.disabled)',
focused: ':focus-within',
disabled: '.disabled'
},
variants: {
checkbox: '.-checkbox',
radio: '.-radio'
},
validInnerComponents: [
'Text',
'Icon'
],
defaultRules: [
{
component: 'Root',
directives: {
'--defaultInputBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2), $borderSide(#000000 top 0.2), inset 0 0 2 #000000 / 0.15, 1 0 1 1 --text / 0.15, -1 0 1 1 --text / 0.15',
'--defaultInputHoverGlow': 'shadow | 0 0 4 --text / 0.5',
'--defaultInputFocusGlow': 'shadow | 0 0 4 4 --link / 0.5'
}
},
{
variant: 'checkbox',
directives: {
roundness: 1
}
},
{
directives: {
'--font': 'generic | inherit',
background: '--fg, -5',
roundness: 3,
shadow: [{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1
}, '--defaultInputBevel']
}
},
{
state: ['hover'],
directives: {
shadow: ['--defaultInputHoverGlow', '--defaultInputBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputBevel']
}
},
{
state: ['focused', 'hover'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputHoverGlow', '--defaultInputBevel']
}
},
{
state: ['disabled'],
directives: {
background: '--parent'
}
},
{
component: 'Text',
parent: {
component: 'Input',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
},
{
component: 'Icon',
parent: {
component: 'Input',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
}

View file

@ -3,6 +3,7 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
const tabModeDict = {
mentions: ['mention'],
statuses: ['status'],
'likes+repeats': ['repeat', 'like'],
follows: ['follow'],
reactions: ['pleroma:emoji_reaction'],

View file

@ -1,18 +1,22 @@
<template>
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
<h1 class="title">
{{ $t("nav.interactions") }}
</div>
</h1>
</div>
<tab-switcher
ref="tabSwitcher"
:on-switch="onModeSwitch"
>
<span
key="mentions"
key="statuses"
:label="$t('nav.mentions')"
/>
<span
key="statuses"
:label="$t('interactions.statuses')"
/>
<span
key="likes+repeats"
:label="$t('interactions.favs_repeats')"
@ -39,6 +43,7 @@
<Notifications
ref="notifications"
:no-heading="true"
:no-extra="true"
:minimal-mode="true"
:filter-mode="filterMode"
/>

View file

@ -9,7 +9,7 @@
:key="index"
>
<label>
{{ index === 0 ? $t('settings.primary_language') : $tc('settings.fallback_language', index, { index }) }}
{{ index === 0 ? $t('settings.primary_language') : $t('settings.fallback_language', { index }, index) }}
<Select
class="language-select"
:model-value="controlledLanguage[index]"
@ -36,7 +36,9 @@
<button
class="button-default btn"
@click="addLanguage"
>{{ $t('settings.add_language') }}</button>
>
{{ $t('settings.add_language') }}
</button>
</li>
</ul>
</div>
@ -102,8 +104,6 @@ export default {
</script>
<style lang="scss">
@import "../../variables";
.interface-language-switcher {
.language-select {
margin-right: 1em;

View file

@ -33,8 +33,6 @@
<script src="./link-preview.js"></script>
<style lang="scss">
@import "../../variables";
.link-preview-card {
display: flex;
flex-direction: row;
@ -51,8 +49,7 @@
width: 100%;
height: 100%;
object-fit: cover;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-radius: var(--roundness);
}
}
@ -82,13 +79,10 @@
margin: 2em 0;
}
color: $fallback--text;
color: var(--text, $fallback--text);
color: var(--text);
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-radius: var(--roundness);
border-color: var(--border);
}
</style>

View file

@ -0,0 +1,24 @@
export default {
name: 'Link',
selector: 'a',
virtual: true,
states: {
faint: '.faint'
},
defaultRules: [
{
component: 'Link',
directives: {
textColor: '--link'
}
},
{
component: 'Link',
state: ['faint'],
directives: {
textOpacity: 0.5,
textOpacityMode: 'fake'
}
}
]
}

View file

@ -1,9 +1,14 @@
<template>
<div class="list">
<div
class="list"
role="list"
>
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
>
<slot
name="item"
@ -29,24 +34,15 @@ export default {
getKey: {
type: Function,
default: item => item.id
},
getClass: {
type: Function,
default: item => ''
},
nonInteractive: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss">
@import "../../variables";
.list {
&-item:not(:last-child) {
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
&-empty-content {
text-align: center;
padding: 10px;
}
}
</style>

View file

@ -0,0 +1,48 @@
export default {
name: 'ListItem',
selector: '.list-item',
states: {
active: '.-active',
hover: ':hover:not(.-non-interactive)'
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar'
],
defaultRules: [
{
directives: {
background: '--bg',
opacity: 0
}
},
{
state: ['active'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover', 'active'],
directives: {
background: '--inheritedBackground, 20',
opacity: 1
}
}
]
}

View file

@ -2,7 +2,9 @@
<div class="Lists panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('lists.lists') }}
<h1 class="title">
{{ $t('lists.lists') }}
</h1>
</div>
<router-link
:to="{ name: 'lists-new' }"

View file

@ -21,8 +21,6 @@
<script src="./lists_card.js"></script>
<style lang="scss">
@import "../../variables";
.list-card {
display: flex;
}
@ -35,18 +33,6 @@
.button-list-edit {
margin: 0;
padding: 1em;
color: $fallback--link;
color: var(--link, $fallback--link);
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
}
color: var(--link);
}
</style>

View file

@ -17,6 +17,7 @@
<i18n-t
v-if="id"
keypath="lists.editing_list"
scope="global"
>
<template #listTitle>
{{ title }}
@ -25,6 +26,7 @@
<i18n-t
v-else
keypath="lists.creating_list"
scope="global"
/>
</div>
</div>
@ -36,6 +38,7 @@
id="list-edit-title"
ref="title"
v-model="titleDraft"
class="input"
>
<button
v-if="id"
@ -164,8 +167,6 @@
<script src="./lists_edit.js"></script>
<style lang="scss">
@import "../../variables";
.ListEdit {
--panel-body-padding: 0.5em;

View file

@ -10,6 +10,7 @@
<input
ref="search"
v-model="query"
class="input"
:placeholder="$t('lists.search')"
@input="onInput"
>
@ -27,8 +28,6 @@
<script src="./lists_user_search.js"></script>
<style lang="scss">
@import "../../variables";
.ListsUserSearch {
.input-wrap {
display: flex;

View file

@ -3,7 +3,9 @@
<!-- Default panel contents -->
<div class="panel-heading">
{{ $t('login.login') }}
<h1 class="title">
{{ $t('login.login') }}
</h1>
</div>
<div class="panel-body">
@ -18,7 +20,7 @@
id="username"
v-model="user.username"
:disabled="loggingIn"
class="form-control"
class="input form-control"
:placeholder="$t('login.placeholder')"
>
</div>
@ -29,7 +31,7 @@
ref="passwordInput"
v-model="user.password"
:disabled="loggingIn"
class="form-control"
class="input form-control"
type="password"
>
</div>
@ -93,8 +95,6 @@
<script src="./login_form.js"></script>
<style lang="scss">
@import "../../variables";
.login-form {
display: flex;
flex-direction: column;

View file

@ -98,7 +98,7 @@
<span
class="counter"
>
{{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
{{ $t('media_modal.counter', { current: currentIndex + 1, total: media.length }, currentIndex + 1) }}
</span>
<span
v-if="loading"

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