diff options
Diffstat (limited to 'src/components')
218 files changed, 6176 insertions, 1787 deletions
diff --git a/src/components/about/about.vue b/src/components/about/about.vue index 33586c97..8a551485 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -9,6 +9,3 @@ </template> <script src="./about.js"></script> - -<style lang="scss"> -</style> diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 99762562..c23407f9 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,6 +1,7 @@ import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' +import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faEllipsisV @@ -19,7 +20,8 @@ const AccountActions = { }, components: { ProgressButton, - Popover + Popover, + UserListMenu }, methods: { showRepeats () { @@ -34,6 +36,9 @@ const AccountActions = { unblockUser () { this.$store.dispatch('unblockUser', this.user.id) }, + removeUserFromFollowers () { + this.$store.dispatch('removeUserFromFollowers', this.user.id) + }, reportUser () { this.$store.dispatch('openUserReportingModal', { userId: this.user.id }) }, diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 23547f2c..973a5935 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -28,6 +28,14 @@ class="dropdown-divider" /> </template> + <UserListMenu :user="user" /> + <button + v-if="relationship.followed_by" + class="btn button-default btn-block dropdown-item" + @click="removeUserFromFollowers" + > + {{ $t('user_card.remove_follower') }} + </button> <button v-if="relationship.blocking" class="btn button-default btn-block dropdown-item" @@ -72,7 +80,8 @@ <script src="./account_actions.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; + .AccountActions { .ellipsis-button { width: 2.5em; diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js new file mode 100644 index 00000000..30254926 --- /dev/null +++ b/src/components/announcement/announcement.js @@ -0,0 +1,108 @@ +import { mapState } from 'vuex' +import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' +import RichContent from '../rich_content/rich_content.jsx' +import localeService from '../../services/locale/locale.service.js' + +const Announcement = { + components: { + AnnouncementEditor, + RichContent + }, + data () { + return { + editing: false, + editedAnnouncement: { + content: '', + startsAt: undefined, + endsAt: undefined, + allDay: undefined + }, + editError: '' + } + }, + props: { + announcement: Object + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + canEditAnnouncement () { + return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') + }, + content () { + return this.announcement.content + }, + isRead () { + return this.announcement.read + }, + publishedAt () { + const time = this.announcement.published_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + startsAt () { + const time = this.announcement.starts_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + endsAt () { + const time = this.announcement.ends_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + inactive () { + return this.announcement.inactive + } + }, + methods: { + markAsRead () { + if (!this.isRead) { + return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id) + } + }, + deleteAnnouncement () { + return this.$store.dispatch('deleteAnnouncement', this.announcement.id) + }, + formatTimeOrDate (time, locale) { + const d = new Date(time) + return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale) + }, + enterEditMode () { + this.editedAnnouncement.content = this.announcement.pleroma.raw_content + this.editedAnnouncement.startsAt = this.announcement.starts_at + this.editedAnnouncement.endsAt = this.announcement.ends_at + this.editedAnnouncement.allDay = this.announcement.all_day + this.editing = true + }, + submitEdit () { + this.$store.dispatch('editAnnouncement', { + id: this.announcement.id, + ...this.editedAnnouncement + }) + .then(() => { + this.editing = false + }) + .catch(error => { + this.editError = error.error + }) + }, + cancelEdit () { + this.editing = false + }, + clearError () { + this.editError = undefined + } + } +} + +export default Announcement diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue new file mode 100644 index 00000000..a1c5791e --- /dev/null +++ b/src/components/announcement/announcement.vue @@ -0,0 +1,136 @@ +<template> + <div class="announcement"> + <div class="heading"> + <h4>{{ $t('announcements.title') }}</h4> + </div> + <div class="body"> + <rich-content + v-if="!editing" + :html="content" + :emoji="announcement.emojis" + :handle-links="true" + /> + <announcement-editor + v-else + :announcement="editedAnnouncement" + /> + </div> + <div class="footer"> + <div + v-if="!editing" + class="times" + > + <span v-if="publishedAt"> + {{ $t('announcements.published_time_display', { time: publishedAt }) }} + </span> + <span v-if="startsAt"> + {{ $t('announcements.start_time_display', { time: startsAt }) }} + </span> + <span v-if="endsAt"> + {{ $t('announcements.end_time_display', { time: endsAt }) }} + </span> + </div> + <div + v-if="!editing" + class="actions" + > + <button + v-if="currentUser" + class="btn button-default" + :class="{ toggled: isRead }" + :disabled="inactive" + :title="inactive ? $t('announcements.inactive_message') : ''" + @click="markAsRead" + > + {{ $t('announcements.mark_as_read_action') }} + </button> + <button + v-if="canEditAnnouncement" + class="btn button-default" + @click="enterEditMode" + > + {{ $t('announcements.edit_action') }} + </button> + <button + v-if="canEditAnnouncement" + class="btn button-default" + @click="deleteAnnouncement" + > + {{ $t('announcements.delete_action') }} + </button> + </div> + <div + v-else + class="actions" + > + <button + class="btn button-default" + @click="submitEdit" + > + {{ $t('announcements.submit_edit_action') }} + </button> + <button + class="btn button-default" + @click="cancelEdit" + > + {{ $t('announcements.cancel_edit_action') }} + </button> + <div + v-if="editing && editError" + class="alert error" + > + {{ $t('announcements.edit_error', { error }) }} + <button + class="button-unstyled" + @click="clearError" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + :title="$t('announcements.close_error')" + /> + </button> + </div> + </div> + </div> + </div> +</template> + +<script src="./announcement.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.announcement { + border-bottom: 1px solid var(--border, $fallback--border); + border-radius: 0; + padding: var(--status-margin, $status-margin); + + .heading, + .body { + margin-bottom: var(--status-margin, $status-margin); + } + + .footer { + display: flex; + flex-direction: column; + + .times { + display: flex; + flex-direction: column; + } + } + + .footer .actions { + display: flex; + flex-direction: row; + justify-content: space-evenly; + + .btn { + flex: 1; + margin: 1em; + max-width: 10em; + } + } +} +</style> diff --git a/src/components/announcement_editor/announcement_editor.js b/src/components/announcement_editor/announcement_editor.js new file mode 100644 index 00000000..79a03afe --- /dev/null +++ b/src/components/announcement_editor/announcement_editor.js @@ -0,0 +1,13 @@ +import Checkbox from '../checkbox/checkbox.vue' + +const AnnouncementEditor = { + components: { + Checkbox + }, + props: { + announcement: Object, + disabled: Boolean + } +} + +export default AnnouncementEditor diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue new file mode 100644 index 00000000..0f29f9f7 --- /dev/null +++ b/src/components/announcement_editor/announcement_editor.vue @@ -0,0 +1,60 @@ +<template> + <div class="announcement-editor"> + <textarea + ref="textarea" + v-model="announcement.content" + class="post-textarea" + rows="1" + cols="1" + :placeholder="$t('announcements.post_placeholder')" + :disabled="disabled" + /> + <span class="announcement-metadata"> + <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label> + <input + id="announcement-start-time" + v-model="announcement.startsAt" + :type="announcement.allDay ? 'date' : 'datetime-local'" + :disabled="disabled" + > + </span> + <span class="announcement-metadata"> + <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label> + <input + id="announcement-end-time" + v-model="announcement.endsAt" + :type="announcement.allDay ? 'date' : 'datetime-local'" + :disabled="disabled" + > + </span> + <span class="announcement-metadata"> + <Checkbox + id="announcement-all-day" + v-model="announcement.allDay" + :disabled="disabled" + /> + <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label> + </span> + </div> +</template> + +<script src="./announcement_editor.js"></script> + +<style lang="scss"> +.announcement-editor { + display: flex; + align-items: stretch; + flex-direction: column; + + .announcement-metadata { + margin-top: 0.5em; + } + + .post-textarea { + resize: vertical; + height: 10em; + overflow: none; + box-sizing: content-box; + } +} +</style> diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js new file mode 100644 index 00000000..8d1204d4 --- /dev/null +++ b/src/components/announcements_page/announcements_page.js @@ -0,0 +1,58 @@ +import { mapState } from 'vuex' +import Announcement from '../announcement/announcement.vue' +import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' + +const AnnouncementsPage = { + components: { + Announcement, + AnnouncementEditor + }, + data () { + return { + newAnnouncement: { + content: '', + startsAt: undefined, + endsAt: undefined, + allDay: false + }, + posting: false, + error: undefined + } + }, + mounted () { + this.$store.dispatch('fetchAnnouncements') + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + announcements () { + return this.$store.state.announcements.announcements + }, + canPostAnnouncement () { + return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') + } + }, + methods: { + postAnnouncement () { + this.posting = true + this.$store.dispatch('postAnnouncement', this.newAnnouncement) + .then(() => { + this.newAnnouncement.content = '' + this.startsAt = undefined + this.endsAt = undefined + }) + .catch(error => { + this.error = error.error + }) + .finally(() => { + this.posting = false + }) + }, + clearError () { + this.error = undefined + } + } +} + +export default AnnouncementsPage diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue new file mode 100644 index 00000000..78d3ecee --- /dev/null +++ b/src/components/announcements_page/announcements_page.vue @@ -0,0 +1,80 @@ +<template> + <div class="panel panel-default announcements-page"> + <div class="panel-heading"> + <span> + {{ $t('announcements.page_header') }} + </span> + </div> + <div class="panel-body"> + <section + v-if="canPostAnnouncement" + > + <div class="post-form"> + <div class="heading"> + <h4>{{ $t('announcements.post_form_header') }}</h4> + </div> + <div class="body"> + <announcement-editor + :announcement="newAnnouncement" + :disabled="posting" + /> + </div> + <div class="footer"> + <button + class="btn button-default post-button" + :disabled="posting" + @click.prevent="postAnnouncement" + > + {{ $t('announcements.post_action') }} + </button> + <div + v-if="error" + class="alert error" + > + {{ $t('announcements.post_error', { error }) }} + <button + class="button-unstyled" + @click="clearError" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + :title="$t('announcements.close_error')" + /> + </button> + </div> + </div> + </div> + </section> + <section + v-for="announcement in announcements" + :key="announcement.id" + > + <announcement + :announcement="announcement" + /> + </section> + </div> + </div> +</template> + +<script src="./announcements_page.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.announcements-page { + .post-form { + padding: var(--status-margin, $status-margin); + + .heading, + .body { + margin-bottom: var(--status-margin, $status-margin); + } + + .post-button { + min-width: 10em; + } + } +} +</style> diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue index 26ab5d21..2ff8974c 100644 --- a/src/components/async_component_error/async_component_error.vue +++ b/src/components/async_component_error/async_component_error.vue @@ -34,9 +34,10 @@ export default { height: 100%; align-items: center; justify-content: center; + .btn { - margin: .5em; - padding: .5em 2em; + margin: 0.5em; + padding: 0.5em 2em; } } </style> diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index d62a4adc..6e14b24d 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -36,6 +36,7 @@ library.add( const Attachment = { props: [ 'attachment', + 'compact', 'description', 'hideDescription', 'nsfw', @@ -71,7 +72,8 @@ const Attachment = { { '-loading': this.loading, '-nsfw-placeholder': this.hidden, - '-editable': this.edit !== undefined + '-editable': this.edit !== undefined, + '-compact': this.compact }, '-type-' + this.type, this.size && '-size-' + this.size, @@ -129,6 +131,9 @@ const Attachment = { ...mapGetters(['mergedConfig']) }, watch: { + 'attachment.description' (newVal) { + this.localDescription = newVal + }, localDescription (newVal) { this.onEdit(newVal) } diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss index b2dea98d..681bab29 100644 --- a/src/components/attachment/attachment.scss +++ b/src/components/attachment/attachment.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .Attachment { display: inline-flex; @@ -102,14 +102,13 @@ padding-top: 0.5em; } - .play-icon { position: absolute; font-size: 64px; top: calc(50% - 32px); left: calc(50% - 32px); - color: rgba(255, 255, 255, 0.75); - text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); + color: rgb(255 255 255 / 75%); + text-shadow: 0 0 2px rgb(0 0 0 / 40%); &::before { margin: 0; @@ -135,18 +134,32 @@ margin-left: 0.5em; font-size: 1.25em; // TODO: theming? hard to theme with unknown background image color - background: rgba(230, 230, 230, 0.7); + background: rgb(230 230 230 / 70%); .svg-inline--fa { - color: rgba(0, 0, 0, 0.6); + color: rgb(0 0 0 / 60%); } &:hover .svg-inline--fa { - color: rgba(0, 0, 0, 0.9); + color: rgb(0 0 0 / 90%); } } } + &.-contain-fit { + img, + canvas { + object-fit: contain; + } + } + + &.-cover-fit { + img, + canvas { + object-fit: cover; + } + } + .oembed-container { line-height: 1.2em; flex: 1 0 100%; @@ -160,8 +173,9 @@ .image { flex: 1; + img { - border: 0px; + border: 0; border-radius: 5px; height: 100%; object-fit: cover; @@ -172,9 +186,10 @@ flex: 2; margin: 8px; word-break: break-all; + h1 { font-size: 1rem; - margin: 0px; + margin: 0; } } } @@ -252,17 +267,9 @@ cursor: progress; } - &.-contain-fit { - img, - canvas { - object-fit: contain; - } - } - - &.-cover-fit { - img, - canvas { - object-fit: cover; + &.-compact { + .placeholder-container { + padding-bottom: 0.5em; } } } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 2a89886d..79f62806 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -162,10 +162,11 @@ target="_blank" > <FAIcon - size="5x" + :size="compact ? '2x' : '5x'" :icon="placeholderIconClass" + :title="localDescription" /> - <p> + <p v-if="!compact"> {{ localDescription }} </p> </a> diff --git a/src/components/autosuggest/autosuggest.vue b/src/components/autosuggest/autosuggest.vue index f283ab82..7b7102fd 100644 --- a/src/components/autosuggest/autosuggest.vue +++ b/src/components/autosuggest/autosuggest.vue @@ -24,7 +24,7 @@ <script src="./autosuggest.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .autosuggest { position: relative; @@ -50,7 +50,7 @@ border-radius: var(--inputRadius, $fallback--inputRadius); border-top-left-radius: 0; border-top-right-radius: 0; - box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: 1px 1px 4px rgb(0 0 0 / 60%); box-shadow: var(--panelShadow); overflow-y: auto; z-index: 1; diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue index 9a6ca3f6..2d00cb7b 100644 --- a/src/components/avatar_list/avatar_list.vue +++ b/src/components/avatar_list/avatar_list.vue @@ -17,7 +17,7 @@ <script src="./avatar_list.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .avatars { display: flex; diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index 8b1a2c38..31de2d75 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,5 +1,6 @@ import UserPopover from '../user_popover/user_popover.vue' import UserAvatar from '../user_avatar/user_avatar.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -10,7 +11,8 @@ const BasicUserCard = { components: { UserPopover, UserAvatar, - RichContent + RichContent, + UserLink }, methods: { userProfileLink (user) { diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 9cca7840..705e20f5 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -30,12 +30,10 @@ /> </div> <div> - <router-link + <user-link class="basic-user-card-screen-name" - :to="userProfileLink(user)" - > - @{{ user.screen_name_ui }} - </router-link> + :user="user" + /> </div> <slot /> </div> @@ -51,7 +49,7 @@ margin: 0; padding: 0.6em 1em; - --emoji-size: 14px; + --emoji-size: 14px; &-collapsed-content { margin-left: 0.7em; diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue index 2fe66d4c..b14ef844 100644 --- a/src/components/block_card/block_card.vue +++ b/src/components/block_card/block_card.vue @@ -37,6 +37,7 @@ .block-card-content-container { margin-top: 0.5em; text-align: right; + button { width: 10em; } diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 5a5c37b6..79f24771 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -57,6 +57,7 @@ const Chat = { }, unmounted () { window.removeEventListener('scroll', this.handleScroll) + window.removeEventListener('resize', this.handleResize) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.dispatch('clearCurrentChat') }, @@ -135,7 +136,7 @@ const Chat = { }, // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport handleResize (opts = {}) { - const { expand = false, delayed = false } = opts + const { delayed = false } = opts if (delayed) { setTimeout(() => { @@ -146,10 +147,10 @@ const Chat = { this.$nextTick(() => { const { offsetHeight = undefined } = getScrollPosition() - const diff = this.lastScrollPosition.offsetHeight - offsetHeight - if (diff !== 0 || (!this.bottomedOut() && expand)) { + const diff = offsetHeight - this.lastScrollPosition.offsetHeight + if (diff !== 0 && !this.bottomedOut()) { this.$nextTick(() => { - window.scrollTo({ top: window.scrollY + diff }) + window.scrollBy({ top: -Math.trunc(diff) }) }) } this.lastScrollPosition = getScrollPosition() @@ -187,6 +188,7 @@ const Chat = { }, 5000) }, handleScroll: _.throttle(function () { + this.lastScrollPosition = getScrollPosition() if (!this.currentChat) { return } if (this.reachedTop()) { diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index f2e154ab..43e7a5e4 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -17,7 +17,7 @@ width: 100%; overflow: visible; min-height: calc(100vh - var(--navbar-height)); - margin: 0 0 0 0; + margin: 0; border-radius: 10px 10px 0 0; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0; @@ -66,7 +66,7 @@ display: flex; justify-content: center; align-items: center; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 1px rgb(0 0 0 / 30%), 0 2px 4px rgb(0 0 0 / 30%); z-index: 10; transition: 0.35s all; transition-timing-function: cubic-bezier(0, 1, 0.5, 1); diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index 2e7df7bd..b1e5468c 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -95,6 +95,6 @@ <script src="./chat.js"></script> <style lang="scss"> -@import '../../_variables.scss'; -@import './chat.scss'; +@import "../../variables"; +@import "./chat"; </style> diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue index 1248c4c8..27a475ed 100644 --- a/src/components/chat_list/chat_list.vue +++ b/src/components/chat_list/chat_list.vue @@ -45,7 +45,7 @@ <script src="./chat_list.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .chat-list { min-height: 25em; diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index c6b45c34..3a84672b 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -13,7 +13,7 @@ &:hover { background-color: var(--selectedPost, $fallback--lightBg); - box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); + box-shadow: 0 0 3px 1px rgb(0 0 0 / 10%); } .chat-list-item-left { @@ -67,6 +67,7 @@ canvas { display: none; } + img { visibility: visible; } @@ -79,13 +80,11 @@ .chat-preview-body { --emoji-size: 1.4em; + + padding-right: 1em; } .time-wrapper { line-height: var(--post-line-height); } - - .chat-preview-body { - padding-right: 1em; - } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index c7c0e878..69ad609b 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -48,6 +48,6 @@ <script src="./chat_list_item.js"></script> <style lang="scss"> -@import '../../_variables.scss'; -@import './chat_list_item.scss'; +@import "../../variables"; +@import "./chat_list_item"; </style> diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index 1913479f..fd5b7aa4 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -1,12 +1,12 @@ -@import '../../_variables.scss'; +@import "../../variables"; .chat-message-wrapper { - &.hovered-message-chain { .animated.Avatar { canvas { display: none; } + img { visibility: visible; } @@ -28,7 +28,8 @@ .menu-icon { cursor: pointer; - &:hover, .extra-button-popover.open & { + &:hover, + .extra-button-popover.open & { color: $fallback--text; color: var(--text, $fallback--text); } @@ -54,27 +55,11 @@ width: 32px; } - .link-preview, .attachments { + .link-preview, + .attachments { margin-bottom: 1em; } - .chat-message-inner { - display: flex; - flex-direction: column; - align-items: flex-start; - max-width: 80%; - min-width: 10em; - width: 100%; - - &.with-media { - width: 100%; - - .status { - width: 100%; - } - } - } - .status { border-radius: $fallback--chatMessageRadius; border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius); @@ -86,7 +71,7 @@ position: relative; float: right; font-size: 0.8em; - margin: -1em 0 -0.5em 0; + margin: -1em 0 -0.5em; font-style: italic; opacity: 0.8; } @@ -103,18 +88,54 @@ } .pending { - .status-content.media-body, .created-at { + .status-content.media-body, + .created-at { color: var(--faint); } } .error { - .status-content.media-body, .created-at { + .status-content.media-body, + .created-at { color: $fallback--cRed; color: var(--badgeNotification, $fallback--cRed); } } + .chat-message-inner { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 80%; + min-width: 10em; + width: 100%; + } + + .outgoing { + display: flex; + flex-flow: row wrap; + 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; + } + + .chat-message-menu { + right: 0.4rem; + } + } + .incoming { a { color: var(--chatMessageIncomingLink, $fallback--link); @@ -137,36 +158,17 @@ } } - .outgoing { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-content: end; - justify-content: flex-end; - - a { - color: var(--chatMessageOutgoingLink, $fallback--link); - } + .chat-message-inner.with-media { + width: 100%; .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; - } - - .chat-message-menu { - right: 0.4rem; + width: 100%; } } .visible { opacity: 1; } - } .chat-message-date-separator { diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index d635c47e..381574c3 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -33,7 +33,7 @@ <div class="media status" :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }" - style="position: relative" + style="position: relative;" @mouseenter="hovered = true" @mouseleave="hovered = false" > @@ -98,6 +98,6 @@ <script src="./chat_message.js"></script> <style lang="scss"> -@import './chat_message.scss'; +@import "./chat_message"; </style> diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss index 240e1a38..b145ecf9 100644 --- a/src/components/chat_new/chat_new.scss +++ b/src/components/chat_new/chat_new.scss @@ -1,7 +1,7 @@ .chat-new { .input-wrap { display: flex; - margin: 0.7em 0.5em 0.7em 0.5em; + margin: 0.7em 0.5em; input { width: 100%; diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue index bf09a379..52306c1d 100644 --- a/src/components/chat_new/chat_new.vue +++ b/src/components/chat_new/chat_new.vue @@ -46,6 +46,6 @@ <script src="./chat_new.js"></script> <style lang="scss"> -@import '../../_variables.scss'; -@import './chat_new.scss'; +@import "../../variables"; +@import "./chat_new"; </style> diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue index ab7491fa..93db4fa7 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -26,7 +26,7 @@ <script src="./chat_title.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .chat-title { display: flex; diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index b6768d67..7139d4fc 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -32,7 +32,7 @@ export default { </script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .checkbox { position: relative; @@ -49,13 +49,13 @@ export default { right: 0; top: 0; display: block; - content: '✓'; + content: "✓"; transition: color 200ms; width: 1.1em; height: 1.1em; border-radius: $fallback--checkboxRadius; border-radius: var(--checkboxRadius, $fallback--checkboxRadius); - box-shadow: 0px 0px 2px black inset; + box-shadow: 0 0 2px black inset; box-shadow: var(--inputShadow); background-color: $fallback--fg; background-color: var(--input, $fallback--fg); @@ -71,15 +71,16 @@ export default { &.disabled { .checkbox-indicator::before, .label { - opacity: .5; + opacity: 0.5; } + .label { color: $fallback--faint; color: var(--faint, $fallback--faint); } } - input[type=checkbox] { + input[type="checkbox"] { display: none; &:checked + .checkbox-indicator::before { @@ -88,15 +89,14 @@ export default { } &:indeterminate + .checkbox-indicator::before { - content: '–'; + content: "–"; color: $fallback--text; color: var(--inputText, $fallback--text); } - } & > span { - margin-left: .5em; + margin-left: 0.5em; } } </style> diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss index 8e9923cf..ca46199a 100644 --- a/src/components/color_input/color_input.scss +++ b/src/components/color_input/color_input.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .color-input { display: inline-flex; @@ -8,7 +8,7 @@ flex: 0 0 0; max-width: 9em; align-items: stretch; - padding: .2em 8px; + padding: 0.2em 8px; input { background: none; @@ -27,33 +27,39 @@ &.nativeColor { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } } + .computedIndicator, .transparentIndicator { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } + .transparentIndicator { // forgot to install counter-strike source, ooops - background-color: #FF00FF; + background-color: #f0f; position: relative; - &::before, &::after { + + &::before, + &::after { display: block; - content: ''; - background-color: #000000; + content: ""; + background-color: #000; position: absolute; height: 50%; width: 50%; } + &::after { top: 0; left: 0; } + &::before { bottom: 0; right: 0; @@ -64,5 +70,4 @@ .label { flex: 1 1 auto; } - } diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue index 374cb9ba..bbd6fd4a 100644 --- a/src/components/contrast_ratio/contrast_ratio.vue +++ b/src/components/contrast_ratio/contrast_ratio.vue @@ -87,7 +87,6 @@ export default { .contrast-ratio { display: flex; justify-content: flex-end; - margin-top: -4px; margin-bottom: 5px; diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 3b540cac..85e6d8ad 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,6 +1,10 @@ import { reduce, filter, findIndex, clone, get } from 'lodash' import Status from '../status/status.vue' import ThreadTree from '../thread_tree/thread_tree.vue' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import { mapGetters, mapState } from 'vuex' +import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' +import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -77,6 +81,9 @@ const conversation = { const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 return maxDepth >= 1 ? maxDepth : 1 }, + streamingEnabled () { + return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + }, displayStyle () { return this.$store.getters.mergedConfig.conversationDisplay }, @@ -339,11 +346,17 @@ const conversation = { }, maybeHighlight () { return this.isExpanded ? this.highlight : null - } + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mastoUserSocketStatus: state => state.api.mastoUserSocketStatus + }) }, components: { Status, - ThreadTree + ThreadTree, + QuickFilterSettings, + QuickViewSettings }, watch: { statusId (newVal, oldVal) { @@ -395,6 +408,11 @@ const conversation = { setHighlight (id) { if (!id) return this.highlight = id + + if (!this.streamingEnabled) { + this.$store.dispatch('fetchStatus', id) + } + this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchEmojiReactionsBy', id) }, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 1adbe250..7577129e 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -17,6 +17,16 @@ > {{ $t('timeline.collapse') }} </button> + <QuickFilterSettings + v-if="!collapsable" + :conversation="true" + class="rightside-button" + /> + <QuickViewSettings + v-if="!collapsable" + :conversation="true" + class="rightside-button" + /> </div> <div class="conversation-body panel-body"> <div @@ -50,7 +60,7 @@ v-if="shouldShowAncestors" class="thread-ancestors" > - <div + <article v-for="status in ancestorsOf(diveRoot)" :key="status.id" class="thread-ancestor" @@ -120,7 +130,7 @@ </i18n-t> </div> </div> - </div> + </article> </div> <thread-tree v-for="status in showingTopLevel" @@ -158,34 +168,36 @@ v-if="isLinearView" class="thread-body" > - <status - v-for="status in conversation" - :key="status.id" - ref="statusComponent" - :inline-expanded="collapsable && isExpanded" - :statusoid="status" - :expandable="!isExpanded" - :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" - :focused="focused(status.id)" - :in-conversation="isExpanded" - :highlight="getHighlight()" - :replies="getReplies(status.id)" - :in-profile="inProfile" - :profile-user-id="profileUserId" - class="conversation-status status-fadein panel-body" + <article> + <status + v-for="status in conversation" + :key="status.id" + ref="statusComponent" + :inline-expanded="collapsable && isExpanded" + :statusoid="status" + :expandable="!isExpanded" + :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" + :focused="focused(status.id)" + :in-conversation="isExpanded" + :highlight="getHighlight()" + :replies="getReplies(status.id)" + :in-profile="inProfile" + :profile-user-id="profileUserId" + class="conversation-status status-fadein panel-body" - :toggle-thread-display="toggleThreadDisplay" - :thread-display-status="threadDisplayStatus" - :show-thread-recursively="showThreadRecursively" - :total-reply-count="totalReplyCount" - :total-reply-depth="totalReplyDepth" - :status-content-properties="statusContentProperties" - :set-status-content-property="setStatusContentProperty" - :toggle-status-content-property="toggleStatusContentProperty" + :toggle-thread-display="toggleThreadDisplay" + :thread-display-status="threadDisplayStatus" + :show-thread-recursively="showThreadRecursively" + :total-reply-count="totalReplyCount" + :total-reply-depth="totalReplyDepth" + :status-content-properties="statusContentProperties" + :set-status-content-property="setStatusContentProperty" + :toggle-status-content-property="toggleStatusContentProperty" - @goto="setHighlight" - @toggleExpanded="toggleExpanded" - /> + @goto="setHighlight" + @toggleExpanded="toggleExpanded" + /> + </article> </div> </div> </div> @@ -198,17 +210,16 @@ <script src="./conversation.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .Conversation { z-index: 1; .conversation-dive-to-top-level-box { padding: var(--status-margin, $status-margin); - border-bottom-width: 1px; - border-bottom-style: solid; - border-bottom-color: var(--border, $fallback--border); + border-bottom: 1px solid var(--border, $fallback--border); border-radius: 0; + /* Make the button stretch along the whole row */ display: flex; align-items: stretch; @@ -223,52 +234,48 @@ .thread-ancestor.-faded .StatusContent { --link: var(--faintLink); --text: var(--faint); + color: var(--text); } .thread-ancestor-dive-box { padding-left: var(--status-margin, $status-margin); - border-bottom-width: 1px; - border-bottom-style: solid; - border-bottom-color: var(--border, $fallback--border); + border-bottom: 1px solid var(--border, $fallback--border); border-radius: 0; + /* Make the button stretch along the whole row */ - &, &-inner { + &, + &-inner { display: flex; align-items: stretch; flex-direction: column; } } + .thread-ancestor-dive-box-inner { padding: var(--status-margin, $status-margin); } .conversation-status { - border-bottom-width: 1px; - border-bottom-style: solid; - border-bottom-color: var(--border, $fallback--border); + border-bottom: 1px solid var(--border, $fallback--border); border-radius: 0; } .thread-ancestor-has-other-replies .conversation-status, + &:last-child .conversation-status, .thread-ancestor:last-child .conversation-status, .thread-ancestor:last-child .thread-ancestor-dive-box, - &:last-child .conversation-status, &.-expanded .thread-tree .conversation-status { border-bottom: none; } .thread-ancestors + .thread-tree > .conversation-status { - border-top-width: 1px; - border-top-style: solid; - border-top-color: var(--border, $fallback--border); + border-top: 1px solid var(--border, $fallback--border); } /* expanded conversation in timeline */ &.status-fadein.-expanded .thread-body { - border-left-width: 4px; - border-left-style: solid; - border-left-color: $fallback--cRed; + 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); diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss index 71202244..c7e02936 100644 --- a/src/components/desktop_nav/desktop_nav.scss +++ b/src/components/desktop_nav/desktop_nav.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .DesktopNav { width: 100%; @@ -23,13 +23,37 @@ max-width: 980px; } + &.-column-stretch .inner-nav { + --miniColumn: 25rem; + --maxiColumn: 45rem; + --columnGap: 1em; + + max-width: + calc( + var(--sidebarColumnWidth, var(--miniColumn)) + + var(--contentColumnWidth, var(--maxiColumn)) + + var(--columnGap) + ); + } + &.-logoLeft .inner-nav { grid-template-columns: auto 2fr 2fr; grid-template-areas: "logo sitename actions"; } + &.-column-stretch.-wide .inner-nav { + max-width: + calc( + var(--sidebarColumnWidth, var(--miniColumn)) + + var(--contentColumnWidth, var(--maxiColumn)) + + var(--notifsColumnWidth, var(--miniColumn)) + + var(--columnGap) + ); + } + .button-default { - &, svg { + &, + svg { color: $fallback--text; color: var(--btnTopBarText, $fallback--text); } @@ -50,7 +74,7 @@ color: $fallback--text; color: var(--btnToggledTopBarText, $fallback--text); background-color: $fallback--fg; - background-color: var(--btnToggledTopBar, $fallback--fg) + background-color: var(--btnToggledTopBar, $fallback--fg); } } @@ -62,6 +86,7 @@ transition-duration: 100ms; @media all and (min-width: 800px) { + /* stylelint-disable-next-line declaration-no-important */ opacity: 1 !important; } @@ -117,4 +142,8 @@ text-align: right; } } + + .spacer { + width: 1em; + } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index f352c78c..07bf8005 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -38,7 +38,7 @@ /> <button class="button-unstyled nav-icon" - @click="openSettingsModal" + @click.stop="openSettingsModal" > <FAIcon fixed-width @@ -61,10 +61,11 @@ :title="$t('nav.administration')" /> </a> + <span class="spacer" /> <button v-if="currentUser" class="button-unstyled nav-icon" - @click.prevent="logout" + @click.stop.prevent="logout" > <FAIcon fixed-width diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 06b270c3..24d65142 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -25,7 +25,7 @@ <script src="./dialog_modal.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; // TODO: unify with other modals. .dark-overlay { @@ -38,7 +38,7 @@ position: fixed; right: 0; top: 0; - background: rgba(27,31,35,.5); + background: rgb(27 31 35 / 50%); z-index: 99; } } @@ -65,7 +65,7 @@ .dialog-modal-content { margin: 0; - padding: 1rem 1rem; + padding: 1rem; background-color: $fallback--bg; background-color: var(--bg, $fallback--bg); white-space: normal; @@ -73,7 +73,7 @@ .dialog-modal-footer { margin: 0; - padding: .5em .5em; + padding: 0.5em; background-color: $fallback--bg; background-color: var(--bg, $fallback--bg); border-top: 1px solid $fallback--border; @@ -83,7 +83,7 @@ button { width: auto; - margin-left: .5rem; + margin-left: 0.5rem; } } } diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js new file mode 100644 index 00000000..75adfea7 --- /dev/null +++ b/src/components/edit_status_modal/edit_status_modal.js @@ -0,0 +1,75 @@ +import PostStatusForm from '../post_status_form/post_status_form.vue' +import Modal from '../modal/modal.vue' +import statusPosterService from '../../services/status_poster/status_poster.service.js' +import get from 'lodash/get' + +const EditStatusModal = { + components: { + PostStatusForm, + Modal + }, + data () { + return { + resettingForm: false + } + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + modalActivated () { + return this.$store.state.editStatus.modalActivated + }, + isFormVisible () { + return this.isLoggedIn && !this.resettingForm && this.modalActivated + }, + params () { + return this.$store.state.editStatus.params || {} + } + }, + watch: { + params (newVal, oldVal) { + if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) { + this.resettingForm = true + this.$nextTick(() => { + this.resettingForm = false + }) + } + }, + isFormVisible (val) { + if (val) { + this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus()) + } + } + }, + methods: { + doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) { + const params = { + store: this.$store, + statusId: this.$store.state.editStatus.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.$store.dispatch('closeEditStatusModal') + } + } +} + +export default EditStatusModal diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue new file mode 100644 index 00000000..db62972d --- /dev/null +++ b/src/components/edit_status_modal/edit_status_modal.vue @@ -0,0 +1,49 @@ +<template> + <Modal + v-if="isFormVisible" + class="edit-form-modal-view" + @backdropClicked="closeModal" + > + <div class="edit-form-modal-panel panel"> + <div class="panel-heading"> + {{ $t('post_status.edit_status') }} + </div> + <PostStatusForm + class="panel-body" + v-bind="params" + :post-handler="doEditStatus" + :disable-polls="true" + :disable-visibility-selector="true" + @posted="closeModal" + /> + </div> + </Modal> +</template> + +<script src="./edit_status_modal.js"></script> + +<style lang="scss"> +.modal-view.edit-form-modal-view { + align-items: flex-start; +} + +.edit-form-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } + + .form-bottom-left { + max-width: 6.5em; + + .emoji-icon { + justify-content: right; + } + } +} +</style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 5ba3907f..ba5f7552 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,8 +1,10 @@ import Completion from '../../services/completion/completion.js' import EmojiPicker from '../emoji_picker/emoji_picker.vue' +import Popover from 'src/components/popover/popover.vue' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { take } from 'lodash' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' - +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faSmileBeam @@ -108,46 +110,122 @@ const EmojiInput = { data () { return { input: undefined, + caretEl: undefined, highlighted: 0, caret: 0, focused: false, blurTimeout: null, - showPicker: false, temporarilyHideSuggestions: false, - keepOpen: false, disableClickOutside: false, - suggestions: [] + suggestions: [], + overlayStyle: {}, + pickerShown: false } }, components: { - EmojiPicker + Popover, + EmojiPicker, + UnicodeDomainIndicator }, computed: { padEmoji () { return this.$store.getters.mergedConfig.padEmoji }, + preText () { + return this.modelValue.slice(0, this.caret) + }, + postText () { + return this.modelValue.slice(this.caret) + }, showSuggestions () { return this.focused && this.suggestions && this.suggestions.length > 0 && - !this.showPicker && + !this.pickerShown && !this.temporarilyHideSuggestions }, textAtCaret () { - return (this.wordAtCaret || {}).word || '' + return this.wordAtCaret?.word }, wordAtCaret () { if (this.modelValue && this.caret) { const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} return word } + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiNamesAndKeywords () { + return emoji => { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + } + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } + }, + onInputScroll () { + this.$refs.hiddenOverlay.scrollTo({ + top: this.input.scrollTop, + left: this.input.scrollLeft + }) } }, mounted () { - const { root } = this.$refs + const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') if (!input) return this.input = input + this.caretEl = hiddenOverlayCaret + if (suggestorPopover.setAnchorEl) { + suggestorPopover.setAnchorEl(this.caretEl) // unit test compat + this.$refs.picker.setAnchorEl(this.caretEl) + } else { + console.warn('setAnchorEl not found, are we in a unit test?') + } + const style = getComputedStyle(this.input) + this.overlayStyle.padding = style.padding + this.overlayStyle.border = style.border + this.overlayStyle.margin = style.margin + this.overlayStyle.lineHeight = style.lineHeight + this.overlayStyle.fontFamily = style.fontFamily + this.overlayStyle.fontSize = style.fontSize + this.overlayStyle.wordWrap = style.wordWrap + this.overlayStyle.whiteSpace = style.whiteSpace this.resize() input.addEventListener('blur', this.onBlur) input.addEventListener('focus', this.onFocus) @@ -157,6 +235,7 @@ const EmojiInput = { input.addEventListener('click', this.onClickInput) input.addEventListener('transitionend', this.onTransition) input.addEventListener('input', this.onInput) + input.addEventListener('scroll', this.onInputScroll) }, unmounted () { const { input } = this @@ -169,46 +248,43 @@ const EmojiInput = { input.removeEventListener('click', this.onClickInput) input.removeEventListener('transitionend', this.onTransition) input.removeEventListener('input', this.onInput) + input.removeEventListener('scroll', this.onInputScroll) } }, watch: { - showSuggestions: function (newValue) { + showSuggestions: function (newValue, oldValue) { this.$emit('shown', newValue) + if (newValue) { + this.$refs.suggestorPopover.showPopover() + } else { + this.$refs.suggestorPopover.hidePopover() + } }, textAtCaret: async function (newWord) { + if (newWord === undefined) return const firstchar = newWord.charAt(0) - this.suggestions = [] - if (newWord === firstchar) return - const matchedSuggestions = await this.suggest(newWord) + if (newWord === firstchar) { + this.suggestions = [] + return + } + const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) // Async: cancel if textAtCaret has changed during wait - if (this.textAtCaret !== newWord) return - if (matchedSuggestions.length <= 0) return + if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) { + this.suggestions = [] + return + } this.suggestions = take(matchedSuggestions, 5) .map(({ imageUrl, ...rest }) => ({ ...rest, img: imageUrl || '' })) - }, - suggestions: { - handler (newValue) { - this.$nextTick(this.resize) - }, - deep: true } }, methods: { - focusPickerInput () { - const pickerEl = this.$refs.picker.$el - if (!pickerEl) return - const pickerInput = pickerEl.querySelector('input') - if (pickerInput) pickerInput.focus() - }, triggerShowPicker () { - this.showPicker = true - this.$refs.picker.startEmojiLoad() this.$nextTick(() => { + this.$refs.picker.showPicker() this.scrollIntoView() - this.focusPickerInput() }) // This temporarily disables "click outside" handler // since external trigger also means click originates @@ -220,11 +296,12 @@ const EmojiInput = { }, togglePicker () { this.input.focus() - this.showPicker = !this.showPicker - if (this.showPicker) { + if (!this.pickerShown) { this.scrollIntoView() + this.$refs.picker.showPicker() this.$refs.picker.startEmojiLoad() - this.$nextTick(this.focusPickerInput) + } else { + this.$refs.picker.hidePicker() } }, replace (replacement) { @@ -261,7 +338,6 @@ const EmojiInput = { spaceAfter, after ].join('') - this.keepOpen = keepOpen this.$emit('update:modelValue', newValue) const position = this.caret + (insertion + spaceAfter + spaceBefore).length if (!keepOpen) { @@ -361,8 +437,11 @@ const EmojiInput = { } }) }, - onTransition (e) { - this.resize() + onPickerShown () { + this.pickerShown = true + }, + onPickerClosed () { + this.pickerShown = false }, onBlur (e) { // Clicking on any suggestion removes focus from autocomplete, @@ -370,7 +449,6 @@ const EmojiInput = { this.blurTimeout = setTimeout(() => { this.focused = false this.setCaret(e) - this.resize() }, 200) }, onClick (e, suggestion) { @@ -382,18 +460,13 @@ const EmojiInput = { this.blurTimeout = null } - if (!this.keepOpen) { - this.showPicker = false - } this.focused = true this.setCaret(e) - this.resize() this.temporarilyHideSuggestions = false }, onKeyUp (e) { const { key } = e this.setCaret(e) - this.resize() // Setting hider in keyUp to prevent suggestions from blinking // when moving away from suggested spot @@ -405,7 +478,6 @@ const EmojiInput = { }, onPaste (e) { this.setCaret(e) - this.resize() }, onKeyDown (e) { const { ctrlKey, shiftKey, key } = e @@ -450,58 +522,24 @@ const EmojiInput = { this.input.focus() } } - - this.showPicker = false - this.resize() }, onInput (e) { - this.showPicker = false this.setCaret(e) - this.resize() this.$emit('update:modelValue', e.target.value) }, - onClickInput (e) { - this.showPicker = false - }, - onClickOutside (e) { - if (this.disableClickOutside) return - this.showPicker = false - }, onStickerUploaded (e) { - this.showPicker = false this.$emit('sticker-uploaded', e) }, onStickerUploadFailed (e) { - this.showPicker = false this.$emit('sticker-upload-Failed', e) }, setCaret ({ target: { selectionStart } }) { this.caret = selectionStart + this.$nextTick(() => { + this.$refs.suggestorPopover.updateStyles() + }) }, resize () { - const panel = this.$refs.panel - if (!panel) return - const picker = this.$refs.picker.$el - const panelBody = this.$refs['panel-body'] - const { offsetHeight, offsetTop } = this.input - const offsetBottom = offsetTop + offsetHeight - - this.setPlacement(panelBody, panel, offsetBottom) - this.setPlacement(picker, picker, offsetBottom) - }, - setPlacement (container, target, offsetBottom) { - if (!container || !target) return - - target.style.top = offsetBottom + 'px' - target.style.bottom = 'auto' - - if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { - target.style.top = 'auto' - target.style.bottom = this.input.offsetHeight + 'px' - } - }, - overflowsBottom (el) { - return el.getBoundingClientRect().bottom > window.innerHeight } } } diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 7d95ab7e..ccba0393 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,11 +1,23 @@ <template> <div ref="root" - v-click-outside="onClickOutside" class="emoji-input" :class="{ 'with-picker': !hideEmojiButton }" > <slot /> + <!-- TODO: make the 'x' disappear if at the end maybe? --> + <div + ref="hiddenOverlay" + class="hidden-overlay" + :style="overlayStyle" + > + <span>{{ preText }}</span> + <span + ref="hiddenOverlayCaret" + class="caret" + >x</span> + <span>{{ postText }}</span> + </div> <template v-if="enableEmojiPicker"> <button v-if="!hideEmojiButton" @@ -18,66 +30,79 @@ <EmojiPicker v-if="enableEmojiPicker" ref="picker" - :class="{ hide: !showPicker }" :enable-sticker-picker="enableStickerPicker" class="emoji-picker-panel" @emoji="insert" @sticker-uploaded="onStickerUploaded" @sticker-upload-failed="onStickerUploadFailed" + @show="onPickerShown" + @close="onPickerClosed" /> </template> - <div - ref="panel" + <Popover + ref="suggestorPopover" class="autocomplete-panel" - :class="{ hide: !showSuggestions }" + placement="bottom" > - <div - ref="panel-body" - class="autocomplete-panel-body" - > + <template #content> <div - v-for="(suggestion, index) in suggestions" - :key="index" - class="autocomplete-item" - :class="{ highlighted: index === highlighted }" - @click.stop.prevent="onClick($event, suggestion)" + ref="panel-body" + class="autocomplete-panel-body" > - <span class="image"> - <img - v-if="suggestion.img" - :src="suggestion.img" - > - <span v-else>{{ suggestion.replacement }}</span> - </span> - <div class="label"> - <span class="displayText">{{ suggestion.displayText }}</span> - <span class="detailText">{{ suggestion.detailText }}</span> + <div + v-for="(suggestion, index) in suggestions" + :key="index" + class="autocomplete-item" + :class="{ highlighted: index === highlighted }" + @click.stop.prevent="onClick($event, suggestion)" + > + <span class="image"> + <img + v-if="suggestion.img" + :src="suggestion.img" + > + <span v-else>{{ suggestion.replacement }}</span> + </span> + <div class="label"> + <span + v-if="suggestion.user" + class="displayText" + > + {{ suggestion.displayText }}<UnicodeDomainIndicator + :user="suggestion.user" + :at="false" + /> + </span> + <span + v-if="!suggestion.user" + class="displayText" + > + {{ maybeLocalizedEmojiName(suggestion) }} + </span> + <span class="detailText">{{ suggestion.detailText }}</span> + </div> </div> </div> - </div> - </div> + </template> + </Popover> </div> </template> <script src="./emoji_input.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .emoji-input { display: flex; flex-direction: column; position: relative; - &.with-picker input { - padding-right: 30px; - } - .emoji-picker-icon { position: absolute; top: 0; right: 0; - margin: .2em .25em; + margin: 0.2em 0.25em; font-size: 1.3em; cursor: pointer; line-height: 24px; @@ -87,99 +112,102 @@ color: var(--text, $fallback--text); } } + .emoji-picker-panel { position: absolute; z-index: 20; margin-top: 2px; &.hide { - display: none + display: none; } } - .autocomplete { - &-panel { - position: absolute; - z-index: 20; - margin-top: 2px; + input, + textarea { + flex: 1 0 auto; + } - &.hide { - display: none - } + &.with-picker input { + padding-right: 30px; + } - &-body { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - box-shadow: var(--popupShadow); - min-width: 75%; - 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); - --postLink: var(--popoverPostLink, $fallback--link); - --postFaintLink: var(--popoverPostFaintLink, $fallback--link); - --icon: var(--popoverIcon, $fallback--icon); - } + .hidden-overlay { + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow: hidden; + + /* DEBUG STUFF */ + color: red; + + /* set opacity to non-zero to see the overlay */ + + .caret { + width: 0; + margin-right: calc(-1ch - 1px); + border: 1px solid red; } + } +} - &-item { - display: flex; - cursor: pointer; - padding: 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); +.autocomplete { + &-panel { + position: absolute; + } + + &-item { + display: flex; + cursor: pointer; + padding: 0.2em 0.4em; + border-bottom: 1px solid rgb(0 0 0 / 40%); + height: 32px; + + .image { + width: 32px; height: 32px; + line-height: 32px; + text-align: center; + font-size: 32px; + margin-right: 4px; - .image { + img { width: 32px; height: 32px; - line-height: 32px; - text-align: center; - font-size: 32px; - - margin-right: 4px; - - img { - width: 32px; - height: 32px; - object-fit: contain; - } + object-fit: contain; } + } - .label { - display: flex; - flex-direction: column; - justify-content: center; - margin: 0 0.1em 0 0.2em; - - .displayText { - line-height: 1.5; - } + .label { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.1em 0 0.2em; - .detailText { - font-size: 9px; - line-height: 9px; - } + .displayText { + line-height: 1.5; } - &.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); + .detailText { + font-size: 9px; + line-height: 9px; } } - } - input, textarea { - flex: 1 0 auto; + &.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> diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js index e8efbd1e..adaa879e 100644 --- a/src/components/emoji_input/suggestor.js +++ b/src/components/emoji_input/suggestor.js @@ -2,7 +2,7 @@ * suggest - generates a suggestor function to be used by emoji-input * data: object providing source information for specific types of suggestions: * data.emoji - optional, an array of all emoji available i.e. - * (state.instance.emoji + state.instance.customEmoji) + * (getters.standardEmojiList + state.instance.customEmoji) * data.users - optional, an array of all known users * updateUsersList - optional, a function to search and append to users * @@ -13,10 +13,10 @@ export default data => { const emojiCurry = suggestEmoji(data.emoji) const usersCurry = data.store && suggestUsers(data.store) - return input => { + return (input, nameKeywordLocalizer) => { const firstChar = input[0] if (firstChar === ':' && data.emoji) { - return emojiCurry(input) + return emojiCurry(input, nameKeywordLocalizer) } if (firstChar === '@' && usersCurry) { return usersCurry(input) @@ -25,34 +25,34 @@ export default data => { } } -export const suggestEmoji = emojis => input => { +export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => { const noPrefix = input.toLowerCase().substr(1) return emojis - .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) - .sort((a, b) => { - let aScore = 0 - let bScore = 0 + .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) })) + .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length) + .map(k => { + let score = 0 // An exact match always wins - aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 - bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0 + score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0) // Prioritize custom emoji a lot - aScore += a.imageUrl ? 100 : 0 - bScore += b.imageUrl ? 100 : 0 + score += k.imageUrl ? 100 : 0 // Prioritize prefix matches somewhat - aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 - bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 + score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0) // Sort by length - aScore -= a.displayText.length - bScore -= b.displayText.length + score -= k.displayText.length + k.score = score + return k + }) + .sort((a, b) => { // Break ties alphabetically const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 - return bScore - aScore + alphabetically + return b.score - a.score + alphabetically }) } @@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => { return diff + nameAlphabetically + screenNameAlphabetically /* eslint-disable camelcase */ - }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ - displayText: screen_name_ui, - detailText: name, - imageUrl: profile_image_url_original, - replacement: '@' + screen_name + ' ' + }).map((user) => ({ + user, + displayText: user.screen_name_ui, + detailText: user.name, + imageUrl: user.profile_image_url_original, + replacement: '@' + user.screen_name + ' ' })) /* eslint-enable camelcase */ diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index f6920208..0d7ca812 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,33 +1,76 @@ import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' +import Popover from 'src/components/popover/popover.vue' +import StillImage from '../still-image/still-image.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag } from '@fortawesome/free-solid-svg-icons' -import { trim } from 'lodash' +import { debounce, trim, chunk } from 'lodash' library.add( faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag ) -// At widest, approximately 20 emoji are visible in a row, -// loading 3 rows, could be overkill for narrow picker -const LOAD_EMOJI_BY = 60 +const UNICODE_EMOJI_GROUP_ICON = { + 'smileys-and-emotion': 'smile', + 'people-and-body': 'user', + 'animals-and-nature': 'paw', + 'food-and-drink': 'ice-cream', + 'travel-and-places': 'bus', + activities: 'basketball-ball', + objects: 'lightbulb', + symbols: 'code', + flags: 'flag' +} -// When to start loading new batch emoji, in pixels -const LOAD_EMOJI_MARGIN = 64 +const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => { + const res = [emoji.displayText, nameLocalizer(emoji)] + if (emoji.annotations) { + languages.forEach(lang => { + const keywords = emoji.annotations[lang]?.keywords || [] + const name = emoji.annotations[lang]?.name + res.push(...(keywords.concat([name]).filter(k => k))) + }) + } + return res +} -const filterByKeyword = (list, keyword = '') => { +const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { if (keyword === '') return list const keywordLowercase = keyword.toLowerCase() const orderedEmojiList = [] for (const emoji of list) { - const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) + const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer) + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 + if (indexOfKeyword > -1) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { orderedEmojiList[indexOfKeyword] = [] @@ -38,6 +81,17 @@ const filterByKeyword = (list, keyword = '') => { return orderedEmojiList.flat() } +const getOffset = (elem) => { + const style = elem.style.transform + const res = /translateY\((\d+)px\)/.exec(style) + if (!res) { return 0 } + return res[1] +} + +const toHeaderId = id => { + return id.replace(/^row-\d+-/, '') +} + const EmojiPicker = { props: { enableStickerPicker: { @@ -53,16 +107,41 @@ const EmojiPicker = { showingStickers: false, groupsScrolledClass: 'scrolled-top', keepOpen: false, - customEmojiBufferSlice: LOAD_EMOJI_BY, customEmojiTimeout: null, - customEmojiLoadAllConfirmed: false + // Lazy-load only after the first time `showing` becomes true. + contentLoaded: false, + groupRefs: {}, + emojiRefs: {}, + filteredEmojiGroups: [], + width: 0 } }, components: { StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), - Checkbox + Checkbox, + StillImage, + Popover }, methods: { + showPicker () { + this.$refs.popover.showPopover() + this.onShowing() + }, + hidePicker () { + this.$refs.popover.hidePopover() + }, + setAnchorEl (el) { + this.$refs.popover.setAnchorEl(el) + }, + setGroupRef (name) { + return el => { this.groupRefs[name] = el } + }, + onPopoverShown () { + this.$emit('show') + }, + onPopoverClosed () { + this.$emit('close') + }, onStickerUploaded (e) { this.$emit('sticker-uploaded', e) }, @@ -71,23 +150,53 @@ const EmojiPicker = { }, onEmoji (emoji) { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement + if (!this.keepOpen) { + this.$refs.popover.hidePopover() + } this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) }, - onScroll (e) { - const target = (e && e.target) || this.$refs['emoji-groups'] - this.updateScrolledClass(target) - this.scrolledGroup(target) - this.triggerLoadMore(target) + onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) { + const target = this.$refs['emoji-groups'].$el + this.scrolledGroup(target, visibleStartIndex, visibleEndIndex) }, - highlight (key) { - const ref = this.$refs['group-' + key] - const top = ref.offsetTop - this.setShowStickers(false) - this.activeGroup = key + scrolledGroup (target, start, end) { + const top = target.scrollTop + 5 this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = top + 1 + this.emojiItems.slice(start, end + 1).forEach(group => { + const headerId = toHeaderId(group.id) + const ref = this.groupRefs['group-' + group.id] + if (!ref) { return } + const elem = ref.$el.parentElement + if (!elem) { return } + if (elem && getOffset(elem) <= top) { + this.activeGroup = headerId + } + }) + this.scrollHeader() }) }, + scrollHeader () { + // Scroll the active tab's header into view + const headerRef = this.groupRefs['group-header-' + this.activeGroup] + const left = headerRef.offsetLeft + const right = left + headerRef.offsetWidth + const headerCont = this.$refs.header + const currentScroll = headerCont.scrollLeft + const currentScrollRight = currentScroll + headerCont.clientWidth + const setScroll = s => { headerCont.scrollLeft = s } + + const margin = 7 // .emoji-tabs-item: padding + if (left - margin < currentScroll) { + setScroll(left - margin) + } else if (right + margin > currentScrollRight) { + setScroll(right + margin - headerCont.clientWidth) + } + }, + highlight (groupId) { + this.setShowStickers(false) + const indexInList = this.emojiItems.findIndex(k => k.id === groupId) + this.$refs['emoji-groups'].scrollToItem(indexInList) + }, updateScrolledClass (target) { if (target.scrollTop <= 5) { this.groupsScrolledClass = 'scrolled-top' @@ -97,74 +206,70 @@ const EmojiPicker = { this.groupsScrolledClass = 'scrolled-middle' } }, - triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'] - if (!ref) return - const bottom = ref.offsetTop + ref.offsetHeight - - const scrollerBottom = target.scrollTop + target.clientHeight - const scrollerTop = target.scrollTop - const scrollerMax = target.scrollHeight - - // Loads more emoji when they come into view - const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN - // Always load when at the very top in case there's no scroll space yet - const atTop = scrollerTop < 5 - // Don't load when looking at unicode category or at the very bottom - const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax - if (!bottomAboveViewport && (approachingBottom || atTop)) { - this.loadEmoji() - } + toggleStickers () { + this.showingStickers = !this.showingStickers }, - scrolledGroup (target) { - const top = target.scrollTop + 5 - this.$nextTick(() => { - this.emojisView.forEach(group => { - const ref = this.$refs['group-' + group.id] - if (ref.offsetTop <= top) { - this.activeGroup = group.id - } - }) - }) + setShowStickers (value) { + this.showingStickers = value }, - loadEmoji () { - const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length - - if (allLoaded) { - return - } - - this.customEmojiBufferSlice += LOAD_EMOJI_BY + filterByKeyword (list, keyword) { + return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName) }, - startEmojiLoad (forceUpdate = false) { - if (!forceUpdate) { - this.keyword = '' - } + onShowing () { + const oldContentLoaded = this.contentLoaded + this.recalculateItemPerRow() this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = 0 + this.$refs.search.focus() }) - const bufferSize = this.customEmojiBuffer.length - const bufferPrefilledAll = bufferSize === this.filteredEmoji.length - if (bufferPrefilledAll && !forceUpdate) { - return + this.contentLoaded = true + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + if (!oldContentLoaded) { + this.$nextTick(() => { + if (this.defaultGroup) { + this.highlight(this.defaultGroup) + } + }) } - this.customEmojiBufferSlice = LOAD_EMOJI_BY }, - toggleStickers () { - this.showingStickers = !this.showingStickers + getFilteredEmojiGroups () { + return this.allEmojiGroups + .map(group => ({ + ...group, + emojis: this.filterByKeyword(group.emojis, trim(this.keyword)) + })) + .filter(group => group.emojis.length > 0) }, - setShowStickers (value) { - this.showingStickers = value + recalculateItemPerRow () { + this.$nextTick(() => { + if (!this.$refs['emoji-groups']) { + return + } + this.width = this.$refs['emoji-groups'].$el.clientWidth + }) } }, watch: { keyword () { - this.customEmojiLoadAllConfirmed = false this.onScroll() - this.startEmojiLoad(true) + this.debouncedHandleKeywordChange() + }, + allCustomGroups () { + this.filteredEmojiGroups = this.getFilteredEmojiGroups() } }, computed: { + minItemSize () { + return this.emojiHeight + }, + emojiHeight () { + return 32 + 4 + }, + emojiWidth () { + return 32 + 4 + }, + itemPerRow () { + return this.width ? Math.floor(this.width / this.emojiWidth - 1) : 6 + }, activeGroupView () { return this.showingStickers ? '' : this.activeGroup }, @@ -174,39 +279,69 @@ const EmojiPicker = { } return 0 }, - filteredEmoji () { - return filterByKeyword( - this.$store.state.instance.customEmoji || [], - trim(this.keyword) - ) + allCustomGroups () { + const emojis = this.$store.getters.groupedCustomEmojis + if (emojis.unpacked) { + emojis.unpacked.text = this.$t('emoji.unpacked') + } + return emojis }, - customEmojiBuffer () { - return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) + defaultGroup () { + return Object.keys(this.allCustomGroups)[0] }, - emojis () { - const standardEmojis = this.$store.state.instance.emoji || [] - const customEmojis = this.customEmojiBuffer - - return [ - { - id: 'custom', - text: this.$t('emoji.custom'), - icon: 'smile-beam', - emojis: customEmojis - }, - { - id: 'standard', - text: this.$t('emoji.unicode'), - icon: 'box-open', - emojis: filterByKeyword(standardEmojis, trim(this.keyword)) - } - ] + unicodeEmojiGroups () { + return this.$store.getters.standardEmojiGroupList.map(group => ({ + id: `standard-${group.id}`, + text: this.$t(`emoji.unicode_groups.${group.id}`), + icon: UNICODE_EMOJI_GROUP_ICON[group.id], + emojis: group.emojis + })) }, - emojisView () { - return this.emojis.filter(value => value.emojis.length > 0) + allEmojiGroups () { + return Object.entries(this.allCustomGroups) + .map(([_, v]) => v) + .concat(this.unicodeEmojiGroups) }, stickerPickerEnabled () { return (this.$store.state.instance.stickers || []).length !== 0 + }, + debouncedHandleKeywordChange () { + return debounce(() => { + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + }, 500) + }, + emojiItems () { + return this.filteredEmojiGroups.map(group => + chunk(group.emojis, this.itemPerRow) + .map((items, index) => ({ + ...group, + id: index === 0 ? group.id : `row-${index}-${group.id}`, + emojis: items, + isFirstRow: index === 0 + }))) + .reduce((a, c) => a.concat(c), []) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } } } } diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index a2f17c51..5bcff33b 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -1,24 +1,43 @@ -@import '../../_variables.scss'; +@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 { + width: 25em; + max-width: calc(100vw - 20px); // popover gives 10px margin from window edge display: flex; flex-direction: column; - position: absolute; - right: 0; - left: 0; - margin: 0 !important; - // TODO: actually use popover in emoji picker - z-index: var(--ZI_popovers); background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--link; color: var(--popoverText, $fallback--link); - --lightText: var(--popoverLightText, $fallback--faint); + --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; + + .still-image { + max-width: 100%; + max-height: 100%; + height: 100%; + width: 100%; + object-fit: contain; + } + } + .keep-open, .too-many-emoji { padding: 7px; @@ -37,7 +56,6 @@ .heading { display: flex; - height: 32px; padding: 10px 7px 5px; } @@ -45,18 +63,18 @@ display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 0px; + min-height: 0; } .emoji-tabs { flex-grow: 1; - } - - .emoji-groups { - min-height: 200px; + display: flex; + flex-flow: row nowrap; + overflow-x: auto; } .additional-tabs { + display: flex; border-left: 1px solid; border-left-color: $fallback--icon; border-left-color: var(--icon, $fallback--icon); @@ -66,15 +84,20 @@ .additional-tabs, .emoji-tabs { - display: block; - min-width: 0; flex-basis: auto; - flex-shrink: 1; + display: flex; + align-content: center; &-item { padding: 0 7px; 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; + display: flex; + align-items: center; &.disabled { opacity: 0.5; @@ -93,7 +116,7 @@ } .sticker-picker { - flex: 1 1 auto + flex: 1 1 auto; } .stickers, @@ -123,22 +146,27 @@ } &-groups { + height: 100%; + min-height: 200px; flex: 1 1 1px; position: relative; overflow: auto; user-select: none; - mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, - linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, - linear-gradient(to top, white, white); + mask: + linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); transition: mask-size 150ms; mask-size: 100% 20px, 100% 20px, auto; // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; + &.scrolled { &-top { mask-size: 100% 20px, 100% 0, auto; } + &-bottom { mask-size: 100% 0, 100% 20px, auto; } @@ -164,24 +192,26 @@ } &-item { - width: 32px; - height: 32px; + width: $emoji-picker-emoji-size; + height: $emoji-picker-emoji-size; box-sizing: border-box; display: flex; - font-size: 32px; + line-height: $emoji-picker-emoji-size; align-items: center; justify-content: center; margin: 4px; - cursor: pointer; - img { + .emoji-picker-emoji.-custom { object-fit: contain; max-width: 100%; max-height: 100%; } - } + .emoji-picker-emoji.-unicode { + font-size: 24px; + overflow: hidden; + } + } } - } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index a7269120..ca90bf26 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -1,105 +1,148 @@ <template> - <div class="emoji-picker panel panel-default panel-body"> - <div class="heading"> - <span class="emoji-tabs"> + <Popover + ref="popover" + trigger="click" + popover-class="emoji-picker popover-default" + @show="onPopoverShown" + @close="onPopoverClosed" + > + <template #content> + <div class="heading"> <span - v-for="group in emojis" - :key="group.id" - class="emoji-tabs-item" - :class="{ - active: activeGroupView === group.id, - disabled: group.emojis.length === 0 - }" - :title="group.text" - @click.prevent="highlight(group.id)" + ref="header" + class="emoji-tabs" > - <FAIcon - :icon="group.icon" - fixed-width - /> + <span + v-for="group in filteredEmojiGroups" + :ref="setGroupRef('group-header-' + group.id)" + :key="group.id" + class="emoji-tabs-item" + :class="{ + active: activeGroupView === group.id + }" + :title="group.text" + @click.prevent="highlight(group.id)" + > + <span + v-if="group.image" + class="emoji-picker-header-image" + > + <still-image + :alt="group.text" + :src="group.image" + /> + </span> + <FAIcon + v-else + :icon="group.icon" + fixed-width + /> + </span> </span> - </span> - <span - v-if="stickerPickerEnabled" - class="additional-tabs" - > <span - class="stickers-tab-icon additional-tabs-item" - :class="{active: showingStickers}" - :title="$t('emoji.stickers')" - @click.prevent="toggleStickers" + v-if="stickerPickerEnabled" + class="additional-tabs" > - <FAIcon - icon="sticky-note" - fixed-width - /> + <span + class="stickers-tab-icon additional-tabs-item" + :class="{active: showingStickers}" + :title="$t('emoji.stickers')" + @click.prevent="toggleStickers" + > + <FAIcon + icon="sticky-note" + fixed-width + /> + </span> </span> - </span> - </div> - <div class="content"> + </div> <div - class="emoji-content" - :class="{hidden: showingStickers}" + v-if="contentLoaded" + class="content" > - <div class="emoji-search"> - <input - v-model="keyword" - type="text" - class="form-control" - :placeholder="$t('emoji.search_emoji')" - @input="$event.target.composing = false" - > - </div> <div - ref="emoji-groups" - class="emoji-groups" - :class="groupsScrolledClass" - @scroll="onScroll" + class="emoji-content" + :class="{hidden: showingStickers}" > - <div - v-for="group in emojisView" - :key="group.id" - class="emoji-group" - > - <h6 - :ref="'group-' + group.id" - class="emoji-group-title" - > - {{ group.text }} - </h6> - <span - v-for="emoji in group.emojis" - :key="group.id + emoji.displayText" - :title="emoji.displayText" - class="emoji-item" - @click.stop.prevent="onEmoji(emoji)" + <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" > - <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> - <img - v-else - :src="emoji.imageUrl" + </div> + <DynamicScroller + ref="emoji-groups" + class="emoji-groups" + :class="groupsScrolledClass" + :min-item-size="minItemSize" + :items="emojiItems" + :emit-update="true" + @update="onScroll" + @visible="recalculateItemPerRow" + @resize="recalculateItemPerRow" + > + <template #default="{ item: group, index, active }"> + <DynamicScrollerItem + :ref="setGroupRef('group-' + group.id)" + :item="group" + :active="active" + :data-index="index" + :size-dependencies="[group.emojis.length]" > - </span> - <span :ref="'group-end-' + group.id" /> + <div + class="emoji-group" + > + <h6 + v-if="group.isFirstRow" + class="emoji-group-title" + > + {{ group.text }} + </h6> + <span + v-for="emoji in group.emojis" + :key="group.id + emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" + class="emoji-item" + @click.stop.prevent="onEmoji(emoji)" + > + <span + v-if="!emoji.imageUrl" + class="emoji-picker-emoji -unicode" + >{{ emoji.replacement }}</span> + <still-image + v-else + class="emoji-picker-emoji -custom" + loading="lazy" + :src="emoji.imageUrl" + :data-emoji-name="group.id + emoji.displayText" + /> + </span> + </div> + </DynamicScrollerItem> + </template> + </DynamicScroller> + <div class="keep-open"> + <Checkbox v-model="keepOpen"> + {{ $t('emoji.keep_open') }} + </Checkbox> </div> </div> - <div class="keep-open"> - <Checkbox v-model="keepOpen"> - {{ $t('emoji.keep_open') }} - </Checkbox> + <div + v-if="showingStickers" + class="stickers-content" + > + <sticker-picker + @uploaded="onStickerUploaded" + @upload-failed="onStickerUploadFailed" + /> </div> </div> - <div - v-if="showingStickers" - class="stickers-content" - > - <sticker-picker - @uploaded="onStickerUploaded" - @upload-failed="onStickerUploadFailed" - /> - </div> - </div> - </div> + </template> + </Popover> </template> <script src="./emoji_picker.js"></script> diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index 4ea8b6a2..a63daa97 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,5 +1,5 @@ <template> - <div class="emoji-reactions"> + <div class="EmojiReactions"> <UserListPopover v-for="(reaction) in emojiReactions" :key="reaction.name" @@ -7,7 +7,7 @@ > <button class="emoji-reaction btn button-default" - :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" + :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" @click="emojiOnClick(reaction.name, $event)" @mouseenter="fetchEmojiReactionsByIfMissing()" > @@ -28,55 +28,58 @@ <script src="./emoji_reactions.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; -.emoji-reactions { +.EmojiReactions { display: flex; margin-top: 0.25em; flex-wrap: wrap; -} -.emoji-reaction { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - .reaction-emoji { - width: 1.25em; - margin-right: 0.25em; - } - &:focus { - outline: none; - } + .emoji-reaction { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; - &.not-clickable { - cursor: default; - &:hover { - box-shadow: $fallback--buttonShadow; - box-shadow: var(--buttonShadow); + .reaction-emoji { + width: 1.25em; + margin-right: 0.25em; } - } -} -.emoji-reaction-expand { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - &:hover { - text-decoration: underline; + &:focus { + outline: none; + } + + &.not-clickable { + cursor: default; + + &:hover { + box-shadow: $fallback--buttonShadow; + box-shadow: var(--buttonShadow); + } + } + + &.-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); + } } -} -.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); -} + .emoji-reaction-expand { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + &:hover { + text-decoration: underline; + } + } +} </style> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 22ffb65a..3dc968c9 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -6,7 +6,10 @@ import { faEyeSlash, faThumbtack, faShareAlt, - faExternalLinkAlt + faExternalLinkAlt, + faHistory, + faPlus, + faTimes } from '@fortawesome/free-solid-svg-icons' import { faBookmark as faBookmarkReg, @@ -21,13 +24,27 @@ library.add( faThumbtack, faShareAlt, faExternalLinkAlt, - faFlag + faFlag, + faHistory, + faPlus, + faTimes ) const ExtraButtons = { props: ['status'], components: { Popover }, + data () { + return { + expanded: false + } + }, methods: { + onShow () { + this.expanded = true + }, + onClose () { + this.expanded = false + }, deleteStatus () { const confirmed = window.confirm(this.$t('status.delete_confirm')) if (confirmed) { @@ -71,14 +88,32 @@ const ExtraButtons = { }, reportStatus () { this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) + }, + editStatus () { + this.$store.dispatch('fetchStatusSource', { id: this.status.id }) + .then(data => this.$store.dispatch('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]) + this.$store.dispatch('openStatusHistoryModal', originalStatus) } }, computed: { currentUser () { return this.$store.state.users.currentUser }, canDelete () { if (!this.currentUser) { return } - const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin - return superuser || this.status.user.id === this.currentUser.id + return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id }, ownStatus () { return this.status.user.id === this.currentUser.id @@ -94,7 +129,11 @@ const ExtraButtons = { }, 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 } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 2c893bf3..a84d47f6 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -6,6 +6,8 @@ :offset="{ y: 5 }" :bound-to="{ x: 'container' }" remove-padding + @show="onShow" + @close="onClose" > <template #content="{close}"> <div class="dropdown-menu"> @@ -76,6 +78,28 @@ </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" @@ -122,10 +146,24 @@ </template> <template #trigger> <span class="button-unstyled popover-trigger"> - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="ellipsis-h" - /> + <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> </template> </Popover> @@ -134,14 +172,10 @@ <script src="./extra_buttons.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; +@import "../../mixins"; .ExtraButtons { - /* override of popover internal stuff */ - .popover-trigger-button { - width: auto; - } - .popover-trigger { position: static; padding: 10px; @@ -152,5 +186,22 @@ 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> diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index 5cd05f73..cf3378c9 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -1,13 +1,21 @@ import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' -import { faStar } from '@fortawesome/free-solid-svg-icons' +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 + faStarRegular, + faPlus, + faMinus, + faCheck ) const FavoriteButton = { @@ -31,7 +39,10 @@ const FavoriteButton = { } }, computed: { - ...mapGetters(['mergedConfig']) + ...mapGetters(['mergedConfig']), + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) + } } } diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index d5c4c61e..58d14945 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -7,19 +7,45 @@ :title="$t('tool_tip.favorite')" @click.prevent="favorite()" > - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="[status.favorited ? 'fas' : 'far', 'star']" - :spin="animated" - /> + <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> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.favorite')" :icon="['far', 'star']" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0" class="action-counter" @@ -32,7 +58,8 @@ <script src="./favorite_button.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; +@import "../../mixins"; .FavoriteButton { display: flex; @@ -57,6 +84,26 @@ 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> diff --git a/src/components/flash/flash.vue b/src/components/flash/flash.vue index 95f71950..9f58d314 100644 --- a/src/components/flash/flash.vue +++ b/src/components/flash/flash.vue @@ -42,7 +42,8 @@ <script src="./flash.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; + .Flash { display: inline-block; width: 100%; @@ -78,7 +79,7 @@ .hidden { display: none; - visibility: 'hidden'; + visibility: "hidden"; } } </style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index 6dcb6d47..b26b27a7 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -1,6 +1,7 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' import FollowButton from '../follow_button/follow_button.vue' +import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue' const FollowCard = { props: [ @@ -10,7 +11,8 @@ const FollowCard = { components: { BasicUserCard, RemoteFollow, - FollowButton + FollowButton, + RemoveFollowerButton }, computed: { isMe () { diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 895a8fa3..eff69fb2 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -22,6 +22,11 @@ class="follow-card-follow-button" :user="user" /> + <RemoveFollowerButton + v-if="noFollowsYou && relationship.followed_by" + :relationship="relationship" + class="follow-card-button" + /> </template> </div> </basic-user-card> @@ -34,12 +39,17 @@ &-content-container { flex-shrink: 0; display: flex; - flex-direction: row; + flex-flow: row wrap; justify-content: space-between; - flex-wrap: wrap; line-height: 1.5em; } + &-button { + margin-top: 0.5em; + padding: 0 1.5em; + margin-left: 1em; + } + &-follow-button { margin-top: 0.5em; margin-left: auto; diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue index 1b12ba4b..eb222cc7 100644 --- a/src/components/follow_request_card/follow_request_card.vue +++ b/src/components/follow_request_card/follow_request_card.vue @@ -22,8 +22,8 @@ <style lang="scss"> .follow-request-card-content-container { display: flex; - flex-direction: row; - flex-wrap: wrap; + flex-flow: row wrap; + button { margin-top: 0.5em; margin-right: 0.5em; diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue index 83c1cef7..bb7e64bc 100644 --- a/src/components/font_control/font_control.vue +++ b/src/components/font_control/font_control.vue @@ -50,17 +50,20 @@ <script src="./font_control.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; + .font-control { input.custom-font { min-width: 10em; } + &.custom { /* TODO Should make proper joiners... */ .font-switcher { border-top-right-radius: 0; border-bottom-right-radius: 0; } + .custom-font { border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js index 4e1bda55..e86a3eea 100644 --- a/src/components/gallery/gallery.js +++ b/src/components/gallery/gallery.js @@ -4,6 +4,7 @@ import { sumBy, set } from 'lodash' const Gallery = { props: [ 'attachments', + 'compact', 'limitRows', 'descriptions', 'limit', diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index ccf6e3e2..96b554e3 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -20,6 +20,7 @@ v-for="(attachment, attachmentIndex) in row.items" :key="attachment.id" class="gallery-item" + :compact="compact" :nsfw="nsfw" :attachment="attachment" :size="size" @@ -86,7 +87,7 @@ <script src='./gallery.js'></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .Gallery { .gallery-rows { @@ -100,6 +101,53 @@ width: 100%; flex-grow: 1; + .gallery-row-inner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-flow: row wrap; + align-content: stretch; + + .gallery-item { + margin: 0 0.5em 0 0; + flex-grow: 1; + height: 100%; + box-sizing: border-box; + // to make failed images a bit more noticeable on chromium + min-width: 2em; + + &:last-child { + margin: 0; + } + } + + &.-grid { + width: 100%; + height: auto; + position: relative; + display: grid; + grid-gap: 0.5em; + grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); + + .gallery-item { + margin: 0; + height: 200px; + } + } + } + + &.-grid, + &.-minimal { + height: auto; + + .gallery-row-inner { + position: relative; + } + } + &:not(:first-child) { margin-top: 0.5em; } @@ -114,7 +162,7 @@ linear-gradient(to top, white, white); /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; } } @@ -138,54 +186,5 @@ padding: 0 2em; } } - - .gallery-row { - &.-grid, - &.-minimal { - height: auto; - .gallery-row-inner { - position: relative; - } - } - } - - .gallery-row-inner { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-content: stretch; - - &.-grid { - width: 100%; - height: auto; - position: relative; - display: grid; - grid-column-gap: 0.5em; - grid-row-gap: 0.5em; - grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); - - .gallery-item { - margin: 0; - height: 200px; - } - } - } - - .gallery-item { - margin: 0 0.5em 0 0; - flex-grow: 1; - height: 100%; - box-sizing: border-box; - // to make failed images a bit more noticeable on chromium - min-width: 2em; - &:last-child { - margin: 0; - } - } } </style> diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index 09904761..0e58476f 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -25,14 +25,14 @@ <script src="./global_notice_list.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .global-notice-list { position: fixed; - top: 50px; + top: calc(var(--navbar-height) + 0.5em); width: 100%; pointer-events: none; - z-index: var(--ZI_popovers); + z-index: var(--ZI_navbar_popovers); display: flex; flex-direction: column; align-items: center; @@ -73,6 +73,7 @@ .global-success { background-color: var(--alertPopupSuccess, $fallback--cGreen); color: var(--alertPopupSuccessText, $fallback--text); + .svg-inline--fa { color: var(--alertPopupSuccessText, $fallback--text); } @@ -81,6 +82,7 @@ .global-info { background-color: var(--alertPopupNeutral, $fallback--fg); color: var(--alertPopupNeutralText, $fallback--text); + .svg-inline--fa { color: var(--alertPopupNeutralText, $fallback--text); } @@ -88,6 +90,7 @@ .close-notice { padding-right: 0.2em; + .svg-inline--fa:hover { opacity: 0.6; } diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js index cc6a15e1..1ae1d01c 100644 --- a/src/components/interactions/interactions.js +++ b/src/components/interactions/interactions.js @@ -5,6 +5,8 @@ const tabModeDict = { mentions: ['mention'], 'likes+repeats': ['repeat', 'like'], follows: ['follow'], + reactions: ['pleroma:emoji_reaction'], + reports: ['pleroma:report'], moves: ['move'] } @@ -12,7 +14,8 @@ const Interactions = { data () { return { allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, - filterMode: tabModeDict.mentions + filterMode: tabModeDict.mentions, + canSeeReports: this.$store.state.users.currentUser.privileges.includes('reports_manage_reports') } }, methods: { diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue index 57d5d87c..b7291c02 100644 --- a/src/components/interactions/interactions.vue +++ b/src/components/interactions/interactions.vue @@ -22,6 +22,15 @@ :label="$t('interactions.follows')" /> <span + key="reactions" + :label="$t('interactions.emoji_reactions')" + /> + <span + v-if="canSeeReports" + key="reports" + :label="$t('interactions.reports')" + /> + <span v-if="!allowFollowingMove" key="moves" :label="$t('interactions.moves')" diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index 220527f2..09f341ac 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -33,7 +33,7 @@ <script src="./link-preview.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .link-preview-card { display: flex; @@ -46,6 +46,7 @@ flex-shrink: 0; width: 120px; max-width: 25%; + img { width: 100%; height: 100%; @@ -67,7 +68,7 @@ } .card-description { - margin: 0.5em 0 0 0; + margin: 0.5em 0 0; overflow: hidden; text-overflow: ellipsis; word-break: break-word; diff --git a/src/components/list/list.vue b/src/components/list/list.vue index a6223cce..f17766b4 100644 --- a/src/components/list/list.vue +++ b/src/components/list/list.vue @@ -35,7 +35,7 @@ export default { </script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .list { &-item:not(:last-child) { diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js new file mode 100644 index 00000000..56d68430 --- /dev/null +++ b/src/components/lists/lists.js @@ -0,0 +1,27 @@ +import ListsCard from '../lists_card/lists_card.vue' + +const Lists = { + data () { + return { + isNew: false + } + }, + components: { + ListsCard + }, + computed: { + lists () { + return this.$store.state.lists.allLists + } + }, + methods: { + cancelNewList () { + this.isNew = false + }, + newList () { + this.isNew = true + } + } +} + +export default Lists diff --git a/src/components/lists/lists.vue b/src/components/lists/lists.vue new file mode 100644 index 00000000..b8bab0a0 --- /dev/null +++ b/src/components/lists/lists.vue @@ -0,0 +1,33 @@ +<template> + <div class="Lists panel panel-default"> + <div class="panel-heading"> + <div class="title"> + {{ $t('lists.lists') }} + </div> + <router-link + :to="{ name: 'lists-new' }" + class="button-default btn new-list-button" + > + {{ $t("lists.new") }} + </router-link> + </div> + <div class="panel-body"> + <ListsCard + v-for="list in lists.slice().reverse()" + :key="list" + :list="list" + class="list-item" + /> + </div> + </div> +</template> + +<script src="./lists.js"></script> + +<style lang="scss"> +.Lists { + .new-list-button { + padding: 0 0.5em; + } +} +</style> diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js new file mode 100644 index 00000000..b503caec --- /dev/null +++ b/src/components/lists_card/lists_card.js @@ -0,0 +1,16 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisH +) + +const ListsCard = { + props: [ + 'list' + ] +} + +export default ListsCard diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue new file mode 100644 index 00000000..925da3a5 --- /dev/null +++ b/src/components/lists_card/lists_card.vue @@ -0,0 +1,52 @@ +<template> + <div class="list-card"> + <router-link + :to="{ name: 'lists-timeline', params: { id: list.id } }" + class="list-name" + > + {{ list.title }} + </router-link> + <router-link + :to="{ name: 'lists-edit', params: { id: list.id } }" + class="button-list-edit" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="ellipsis-h" + /> + </router-link> + </div> +</template> + +<script src="./lists_card.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.list-card { + display: flex; +} + +.list-name { + flex-grow: 1; +} + +.list-name, +.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); + } +} +</style> diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js new file mode 100644 index 00000000..c33659df --- /dev/null +++ b/src/components/lists_edit/lists_edit.js @@ -0,0 +1,145 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import ListsUserSearch from '../lists_user_search/lists_user_search.vue' +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSearch, + faChevronLeft +) + +const ListsNew = { + components: { + BasicUserCard, + UserAvatar, + ListsUserSearch, + TabSwitcher, + PanelLoading + }, + data () { + return { + title: '', + titleDraft: '', + membersUserIds: [], + removedUserIds: new Set([]), // users we added for members, to undo + searchUserIds: [], + addedUserIds: new Set([]), // users we added from search, to undo + searchLoading: false, + reallyDelete: false + } + }, + created () { + if (!this.id) return + this.$store.dispatch('fetchList', { listId: this.id }) + .then(() => { + this.title = this.findListTitle(this.id) + this.titleDraft = this.title + }) + this.$store.dispatch('fetchListAccounts', { listId: this.id }) + .then(() => { + this.membersUserIds = this.findListAccounts(this.id) + this.membersUserIds.forEach(userId => { + this.$store.dispatch('fetchUserIfMissing', userId) + }) + }) + }, + computed: { + id () { + return this.$route.params.id + }, + membersUsers () { + return [...this.membersUserIds, ...this.addedUserIds] + .map(userId => this.findUser(userId)).filter(user => user) + }, + searchUsers () { + return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user) + }, + ...mapState({ + currentUser: state => state.users.currentUser + }), + ...mapGetters(['findUser', 'findListTitle', 'findListAccounts']) + }, + methods: { + onInput () { + this.search(this.query) + }, + toggleRemoveMember (user) { + if (this.removedUserIds.has(user.id)) { + this.id && this.addUser(user) + this.removedUserIds.delete(user.id) + } else { + this.id && this.removeUser(user.id) + this.removedUserIds.add(user.id) + } + }, + toggleAddFromSearch (user) { + if (this.addedUserIds.has(user.id)) { + this.id && this.removeUser(user.id) + this.addedUserIds.delete(user.id) + } else { + this.id && this.addUser(user) + this.addedUserIds.add(user.id) + } + }, + isRemoved (user) { + return this.removedUserIds.has(user.id) + }, + isAdded (user) { + return this.addedUserIds.has(user.id) + }, + addUser (user) { + this.$store.dispatch('addListAccount', { accountId: user.id, listId: this.id }) + }, + removeUser (userId) { + this.$store.dispatch('removeListAccount', { accountId: userId, listId: this.id }) + }, + onSearchLoading (results) { + this.searchLoading = true + }, + onSearchLoadingDone (results) { + this.searchLoading = false + }, + onSearchResults (results) { + this.searchLoading = false + this.searchUserIds = results + }, + updateListTitle () { + this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft }) + .then(() => { + this.title = this.findListTitle(this.id) + }) + }, + createList () { + this.$store.dispatch('createList', { title: this.titleDraft }) + .then((list) => { + return this + .$store + .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] }) + .then(() => list.id) + }) + .then((listId) => { + this.$router.push({ name: 'lists-timeline', params: { id: listId } }) + }) + .catch((e) => { + this.$store.dispatch('pushGlobalNotice', { + messageKey: 'lists.error', + messageArgs: [e.message], + level: 'error' + }) + }) + }, + deleteList () { + this.$store.dispatch('deleteList', { listId: this.id }) + this.$router.push({ name: 'lists' }) + } + } +} + +export default ListsNew diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue new file mode 100644 index 00000000..eec0f978 --- /dev/null +++ b/src/components/lists_edit/lists_edit.vue @@ -0,0 +1,228 @@ +<template> + <div class="panel-default panel ListEdit"> + <div + ref="header" + class="panel-heading list-edit-heading" + > + <button + class="button-unstyled go-back-button" + @click="$router.back" + > + <FAIcon + size="lg" + icon="chevron-left" + /> + </button> + <div class="title"> + <i18n-t + v-if="id" + keypath="lists.editing_list" + > + <template #listTitle> + {{ title }} + </template> + </i18n-t> + <i18n-t + v-else + keypath="lists.creating_list" + /> + </div> + </div> + <div class="panel-body"> + <div class="input-wrap"> + <label for="list-edit-title">{{ $t('lists.title') }}</label> + {{ ' ' }} + <input + id="list-edit-title" + ref="title" + v-model="titleDraft" + > + <button + v-if="id" + class="btn button-default follow-button" + @click="updateListTitle" + > + {{ $t('lists.update_title') }} + </button> + </div> + <tab-switcher + class="list-member-management" + :scrollable-tabs="true" + > + <div + v-if="id || addedUserIds.size > 0" + :label="$t('lists.manage_members')" + class="members-list" + > + <div class="users-list"> + <div + v-for="user in membersUsers" + :key="user.id" + class="member" + > + <BasicUserCard + :user="user" + > + <button + class="btn button-default follow-button" + @click="toggleRemoveMember(user)" + > + {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }} + </button> + </BasicUserCard> + </div> + </div> + </div> + + <div + class="search-list" + :label="$t('lists.add_members')" + > + <ListsUserSearch + @results="onSearchResults" + @loading="onSearchLoading" + @loadingDone="onSearchLoadingDone" + /> + <div + v-if="searchLoading" + class="loading" + > + <PanelLoading /> + </div> + <div + v-else + class="users-list" + > + <div + v-for="user in searchUsers" + :key="user.id" + class="member" + > + <BasicUserCard + :user="user" + > + <span + v-if="membersUserIds.includes(user.id)" + > + {{ $t('lists.is_in_list') }} + </span> + <button + v-if="!membersUserIds.includes(user.id)" + class="btn button-default follow-button" + @click="toggleAddFromSearch(user)" + > + {{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }} + </button> + <button + v-else + class="btn button-default follow-button" + @click="toggleRemoveMember(user)" + > + {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }} + </button> + </BasicUserCard> + </div> + </div> + </div> + </tab-switcher> + </div> + <div class="panel-footer"> + <span class="spacer" /> + <button + v-if="!id" + class="btn button-default footer-button" + @click="createList" + > + {{ $t('lists.create') }} + </button> + <button + v-else-if="!reallyDelete" + class="btn button-default footer-button" + @click="reallyDelete = true" + > + {{ $t('lists.delete') }} + </button> + <template v-else> + {{ $t('lists.really_delete') }} + <button + class="btn button-default footer-button" + @click="deleteList" + > + {{ $t('general.yes') }} + </button> + <button + class="btn button-default footer-button" + @click="reallyDelete = false" + > + {{ $t('general.no') }} + </button> + </template> + </div> + </div> +</template> + +<script src="./lists_edit.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.ListEdit { + --panel-body-padding: 0.5em; + + height: calc(100vh - var(--navbar-height)); + overflow: hidden; + display: flex; + flex-direction: column; + + .list-edit-heading { + grid-template-columns: auto minmax(50%, 1fr); + } + + .panel-body { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + } + + .list-member-management { + flex: 1 0 auto; + } + + .search-icon { + margin-right: 0.3em; + } + + .users-list { + padding-bottom: 0.7rem; + overflow-y: auto; + } + + & .search-list, + & .members-list { + overflow: hidden; + flex-direction: column; + min-height: 0; + } + + .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> diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js new file mode 100644 index 00000000..97b32210 --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.js @@ -0,0 +1,22 @@ +import { mapState } from 'vuex' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import { getListEntries } from 'src/components/navigation/filter.js' + +export const ListsMenuContent = { + props: [ + 'showPin' + ], + components: { + NavigationEntry + }, + computed: { + ...mapState({ + lists: getListEntries, + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }) + } +} + +export default ListsMenuContent diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue new file mode 100644 index 00000000..f93e80c9 --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.vue @@ -0,0 +1,12 @@ +<template> + <ul> + <NavigationEntry + v-for="item in lists" + :key="item.name" + :show-pin="showPin" + :item="item" + /> + </ul> +</template> + +<script src="./lists_menu_content.js"></script> diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js new file mode 100644 index 00000000..c3f408bd --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.js @@ -0,0 +1,36 @@ +import Timeline from '../timeline/timeline.vue' +const ListsTimeline = { + data () { + return { + listId: null + } + }, + components: { + Timeline + }, + computed: { + timeline () { return this.$store.state.statuses.timelines.list } + }, + watch: { + $route: function (route) { + if (route.name === 'lists-timeline' && route.params.id !== this.listId) { + this.listId = route.params.id + this.$store.dispatch('stopFetchingTimeline', 'list') + this.$store.commit('clearTimeline', { timeline: 'list' }) + this.$store.dispatch('fetchList', { listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + } + } + }, + created () { + this.listId = this.$route.params.id + this.$store.dispatch('fetchList', { listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + }, + unmounted () { + this.$store.dispatch('stopFetchingTimeline', 'list') + this.$store.commit('clearTimeline', { timeline: 'list' }) + } +} + +export default ListsTimeline diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue new file mode 100644 index 00000000..18156b81 --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.vue @@ -0,0 +1,10 @@ +<template> + <Timeline + title="list.name" + :timeline="timeline" + :list-id="listId" + timeline-name="list" + /> +</template> + +<script src="./lists_timeline.js"></script> diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js new file mode 100644 index 00000000..c92ec0ee --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.js @@ -0,0 +1,51 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' +import { debounce } from 'lodash' +import Checkbox from '../checkbox/checkbox.vue' + +library.add( + faSearch, + faChevronLeft +) + +const ListsUserSearch = { + components: { + Checkbox + }, + emits: ['loading', 'loadingDone', 'results'], + data () { + return { + loading: false, + query: '', + followingOnly: true + } + }, + methods: { + onInput: debounce(function () { + this.search(this.query) + }, 2000), + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.$emit('loading') + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly }) + .then(data => { + this.$emit('results', data.accounts.map(a => a.id)) + }) + .finally(() => { + this.loading = false + this.$emit('loadingDone') + }) + } + } +} + +export default ListsUserSearch diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue new file mode 100644 index 00000000..6ca107e6 --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.vue @@ -0,0 +1,47 @@ +<template> + <div class="ListsUserSearch"> + <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" + :placeholder="$t('lists.search')" + @input="onInput" + > + </div> + <div class="input-wrap"> + <Checkbox + v-model="followingOnly" + @change="onInput" + > + {{ $t('lists.following_only') }} + </Checkbox> + </div> + </div> +</template> + +<script src="./lists_user_search.js"></script> +<style lang="scss"> +@import "../../variables"; + +.ListsUserSearch { + .input-wrap { + display: flex; + margin: 0.7em 0.5em; + + input { + width: 100%; + } + } + + .search-icon { + margin-right: 0.3em; + } +} + +</style> diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index 7a430c51..829e88ad 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -93,7 +93,7 @@ <script src="./login_form.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .login-form { display: flex; @@ -110,7 +110,7 @@ } .login-bottom { - margin-top: 1.0em; + margin-top: 1em; display: flex; flex-direction: row; align-items: center; @@ -121,7 +121,7 @@ display: flex; flex-direction: column; padding: 0.3em 0.5em 0.6em; - line-height:24px; + line-height: 24px; } .form-bottom { @@ -142,7 +142,6 @@ .error { text-align: center; - animation-name: shakeError; animation-duration: 0.4s; animation-timing-function: ease-in-out; diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index ff993664..05ef9fbe 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -63,6 +63,11 @@ const MediaModal = { }, type () { return this.currentMedia ? this.getType(this.currentMedia) : null + }, + swipeDisableClickThreshold () { + // If there is only one media, allow more mouse movements to close the modal + // because there is less chance that the user wants to switch to another image + return () => this.canNavigate ? 1 : 30 } }, methods: { diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index d59055b3..eb28f8be 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -10,6 +10,7 @@ class="modal-image-container" :direction="swipeDirection" :threshold="swipeThreshold" + :disable-click-threshold="swipeDisableClickThreshold" @preview-requested="handleSwipePreview" @swipe-finished="handleSwipeEnd" @swipeless-clicked="hide" @@ -120,32 +121,12 @@ $modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2) $modal-view-button-icon-width: 3em; $modal-view-button-icon-margin: 0.5em; -.modal-view.media-modal-view { - z-index: var(--ZI_media_modal); - flex-direction: column; - - .modal-view-button-arrow, - .modal-view-button-hide { - opacity: 0.75; - - &:focus, - &:hover { - outline: none; - box-shadow: none; - } - - &:hover { - opacity: 1; - } - } - overflow: hidden; -} - .media-modal-view { @keyframes media-fadein { from { opacity: 0; } + to { opacity: 1; } @@ -226,7 +207,7 @@ $modal-view-button-icon-margin: 0.5em; appearance: none; overflow: visible; cursor: pointer; - transition: opacity 333ms cubic-bezier(.4,0,.22,1); + transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1); height: $modal-view-button-icon-height; width: $modal-view-button-icon-width; @@ -236,9 +217,9 @@ $modal-view-button-icon-margin: 0.5em; width: $modal-view-button-icon-width; font-size: 1rem; line-height: $modal-view-button-icon-height; - color: #FFF; + color: #fff; text-align: center; - background-color: rgba(0,0,0,.3); + background-color: rgb(0 0 0 / 30%); } } @@ -254,13 +235,14 @@ $modal-view-button-icon-margin: 0.5em; position: absolute; top: 0; line-height: $modal-view-button-icon-height; - color: #FFF; + color: #fff; text-align: center; - background-color: rgba(0,0,0,.3); + background-color: rgb(0 0 0 / 30%); } &--prev { left: 0; + .arrow-icon { left: $modal-view-button-icon-margin; } @@ -268,6 +250,7 @@ $modal-view-button-icon-margin: 0.5em; &--next { right: 0; + .arrow-icon { right: $modal-view-button-icon-margin; } @@ -278,10 +261,33 @@ $modal-view-button-icon-margin: 0.5em; position: absolute; top: 0; right: 0; + .button-icon { top: $modal-view-button-icon-margin; right: $modal-view-button-icon-margin; } } } + +.modal-view.media-modal-view { + z-index: var(--ZI_media_modal); + flex-direction: column; + + .modal-view-button-arrow, + .modal-view-button-hide { + opacity: 0.75; + + &:focus, + &:hover { + outline: none; + box-shadow: none; + } + + &:hover { + opacity: 1; + } + } + + overflow: hidden; +} </style> diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index a538a5ed..2799495b 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -29,7 +29,7 @@ <script src="./media_upload.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .media-upload { cursor: pointer; // We use <label> for interactivity... i wonder if it's fine diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js index 4a74fbe2..6515bd11 100644 --- a/src/components/mention_link/mention_link.js +++ b/src/components/mention_link/mention_link.js @@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p import { mapGetters, mapState } from 'vuex' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import UserAvatar from '../user_avatar/user_avatar.vue' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { defineAsyncComponent } from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -16,6 +17,7 @@ const MentionLink = { name: 'MentionLink', components: { UserAvatar, + UnicodeDomainIndicator, UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) }, props: { diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss index 8b2af926..69e9fed1 100644 --- a/src/components/mention_link/mention_link.scss +++ b/src/components/mention_link/mention_link.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .MentionLink { position: relative; @@ -59,6 +59,7 @@ font-weight: 600; } } + &.-has-selection { color: var(--alertNeutralText, $fallback--text); background-color: var(--alertNeutral, $fallback--fg); @@ -100,10 +101,6 @@ } } - .full { - pointer-events: none; - } - .serverName.-faded { color: var(--faintLink, $fallback--link); } diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue index 3af502ef..869a3257 100644 --- a/src/components/mention_link/mention_link.vue +++ b/src/components/mention_link/mention_link.vue @@ -47,6 +47,9 @@ class="serverName" :class="{ '-faded': shouldFadeDomain }" v-html="'@' + serverName" + /><UnicodeDomainIndicator + v-if="shouldShowFullUserName" + :user="user" /> </span> <span diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss index 9a622e75..b7dfbfb8 100644 --- a/src/components/mentions_line/mentions_line.scss +++ b/src/components/mentions_line/mentions_line.scss @@ -2,7 +2,7 @@ word-break: break-all; .mention-link:not(:first-child)::before { - content: ' '; + content: " "; } .showMoreLess { diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index 877d52a9..cdbbb812 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -2,33 +2,40 @@ import SideDrawer from '../side_drawer/side_drawer.vue' import Notifications from '../notifications/notifications.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' +import NavigationPins from 'src/components/navigation/navigation_pins.vue' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { faTimes, faBell, - faBars + faBars, + faArrowUp, + faMinus } from '@fortawesome/free-solid-svg-icons' library.add( faTimes, faBell, - faBars + faBars, + faArrowUp, + faMinus ) const MobileNav = { components: { SideDrawer, - Notifications + Notifications, + NavigationPins }, data: () => ({ notificationsCloseGesture: undefined, - notificationsOpen: false + notificationsOpen: false, + notificationsAtTop: true }), created () { this.notificationsCloseGesture = GestureService.swipeGesture( GestureService.DIRECTION_RIGHT, - this.closeMobileNotifications, + () => this.closeMobileNotifications(true), 50 ) }, @@ -47,7 +54,10 @@ const MobileNav = { isChat () { return this.$route.name === 'chat' }, - ...mapGetters(['unreadChatCount']) + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']), + chatsPinned () { + return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats') + } }, methods: { toggleMobileSidebar () { @@ -56,12 +66,14 @@ const MobileNav = { openMobileNotifications () { this.notificationsOpen = true }, - closeMobileNotifications () { + closeMobileNotifications (markRead) { if (this.notificationsOpen) { // make sure to mark notifs seen only when the notifs were open and not // from close-calls. this.notificationsOpen = false - this.markNotificationsAsSeen() + if (markRead) { + this.markNotificationsAsSeen() + } } }, notificationsTouchStart (e) { @@ -73,6 +85,9 @@ const MobileNav = { scrollToTop () { window.scrollTo(0, 0) }, + scrollMobileNotificationsToTop () { + this.$refs.mobileNotifications.scrollTo(0, 0) + }, logout () { this.$router.replace('/main/public') this.$store.dispatch('logout') @@ -82,6 +97,7 @@ const MobileNav = { this.$store.dispatch('markNotificationsAsSeen') }, onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { + this.notificationsAtTop = scrollTop > 0 if (scrollTop + clientHeight >= scrollHeight) { this.$refs.notifications.fetchOlderNotifications() } diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 949cf17e..d6fe102c 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -10,6 +10,8 @@ <div class="item"> <button class="button-unstyled mobile-nav-button" + :title="$t('nav.mobile_sidebar')" + :aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed" @click.stop.prevent="toggleMobileSidebar()" > <FAIcon @@ -17,23 +19,16 @@ icon="bars" /> <div - v-if="unreadChatCount" + v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount" class="alert-dot" /> </button> - <router-link - v-if="!hideSitename" - class="site-name" - :to="{ name: 'root' }" - active-class="home" - > - {{ sitename }} - </router-link> - </div> - <div class="item right"> + <NavigationPins class="pins" /> + </div> <div class="item right"> <button v-if="currentUser" class="button-unstyled mobile-nav-button" + :title="unseenNotificationsCount ? $t('nav.mobile_notifications_unread_active') : $t('nav.mobile_notifications')" @click.stop.prevent="openMobileNotifications()" > <FAIcon @@ -47,7 +42,7 @@ </button> </div> </nav> - <div + <aside v-if="currentUser" class="mobile-notifications-drawer" :class="{ '-closed': !notificationsOpen }" @@ -56,22 +51,39 @@ > <div class="mobile-notifications-header"> <span class="title">{{ $t('notifications.notifications') }}</span> - <a - class="mobile-nav-button" - @click.stop.prevent="closeMobileNotifications()" + <span class="spacer" /> + <button + v-if="notificationsAtTop" + class="button-unstyled mobile-nav-button" + :title="$t('general.scroll_to_top')" + @click.stop.prevent="scrollMobileNotificationsToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> + <button + class="button-unstyled mobile-nav-button" + :title="$t('nav.mobile_notifications_close')" + @click.stop.prevent="closeMobileNotifications(true)" > <FAIcon class="fa-scale-110 fa-old-padding" icon="times" /> - </a> + </button> </div> <div id="mobile-notifications" + ref="mobileNotifications" class="mobile-notifications" @scroll="onScroll" /> - </div> + </aside> <SideDrawer ref="sideDrawer" :logout="logout" @@ -82,7 +94,7 @@ <script src="./mobile_nav.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .MobileNav { z-index: var(--ZI_navbar); @@ -94,6 +106,7 @@ grid-template-columns: 2fr auto; width: 100%; box-sizing: border-box; + a { color: var(--topBarLink, $fallback--link); } @@ -114,7 +127,7 @@ } .site-name { - padding: 0 .3em; + padding: 0 0.3em; display: inline-block; } @@ -143,7 +156,7 @@ position: fixed; top: 0; left: 0; - box-shadow: 1px 1px 4px rgba(0,0,0,.6); + box-shadow: 1px 1px 4px rgb(0 0 0 / 60%); box-shadow: var(--panelShadow); transition-property: transform; transition-duration: 0.25s; @@ -169,22 +182,33 @@ color: var(--topBarText); background-color: $fallback--fg; background-color: var(--topBar, $fallback--fg); - box-shadow: 0px 0px 4px rgba(0,0,0,.6); + box-shadow: 0 0 4px rgb(0 0 0 / 60%); box-shadow: var(--topBarShadow); + .spacer { + flex: 1; + } + .title { font-size: 1.3em; margin-left: 0.6em; } } + .pins { + flex: 1; + + .pinned-item { + flex-grow: 1; + } + } + .mobile-notifications { margin-top: 50px; width: 100vw; height: calc(100vh - var(--navbar-height)); overflow-x: hidden; overflow-y: scroll; - color: $fallback--text; color: var(--text, $fallback--text); background-color: $fallback--bg; @@ -194,14 +218,17 @@ padding: 0; border-radius: 0; box-shadow: none; + .panel { border-radius: 0; margin: 0; box-shadow: none; } - .panel:after { + + .panel::after { border-radius: 0; } + .panel .panel-heading { border-radius: 0; box-shadow: none; diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js index ecf79b64..f7f96cd6 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.js +++ b/src/components/mobile_post_status_button/mobile_post_status_button.js @@ -10,7 +10,8 @@ library.add( const HIDDEN_FOR_PAGES = new Set([ 'chats', - 'chat' + 'chat', + 'lists-edit' ]) const MobilePostStatusButton = { diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue index 9fcdf6f8..ef0f51fe 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.vue +++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue @@ -3,6 +3,7 @@ v-if="isLoggedIn" class="MobilePostButton button-default new-status-button" :class="{ 'hidden': isHidden, 'always-show': isPersistent }" + :title="$t('post_status.new_status')" @click="openPostForm" > <FAIcon icon="pen" /> @@ -12,7 +13,7 @@ <script src="./mobile_post_status_button.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .MobilePostButton { &.button-default { @@ -29,9 +30,8 @@ display: flex; justify-content: center; align-items: center; - box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3); + box-shadow: 0 2px 2px rgb(0 0 0 / 30%), 0 4px 6px rgb(0 0 0 / 30%); z-index: 10; - transition: 0.35s transform; transition-timing-function: cubic-bezier(0, 1, 0.5, 1); } diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue index 2187f392..9f767ede 100644 --- a/src/components/modal/modal.vue +++ b/src/components/modal/modal.vue @@ -59,7 +59,7 @@ export default { &.modal-background { pointer-events: initial; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgb(0 0 0 / 50%); } &.open { @@ -69,10 +69,11 @@ export default { @keyframes modal-background-fadein { from { - background-color: rgba(0, 0, 0, 0); + background-color: rgb(0 0 0 / 0%); } + to { - background-color: rgba(0, 0, 0, 0.5); + background-color: rgb(0 0 0 / 50%); } } </style> diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index 2469327a..a5ce8656 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -41,14 +41,26 @@ const ModerationTools = { tagsSet () { return new Set(this.user.tags) }, - hasTagPolicy () { - return this.$store.state.instance.tagPolicyAvailable + canGrantRole () { + return this.user.is_local && !this.user.deactivated && this.$store.state.users.currentUser.role === 'admin' + }, + canChangeActivationState () { + return this.privileged('users_manage_activation_state') + }, + canDeleteAccount () { + return this.privileged('users_delete') + }, + canUseTagPolicy () { + return this.$store.state.instance.tagPolicyAvailable && this.privileged('users_manage_tags') } }, methods: { hasTag (tagName) { return this.tagsSet.has(tagName) }, + privileged (privilege) { + return this.$store.state.users.currentUser.privileges.includes(privilege) + }, toggleTag (tag) { const store = this.$store if (this.tagsSet.has(tag)) { diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 34fe2e7c..b708cdc8 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -10,7 +10,7 @@ > <template #content> <div class="dropdown-menu"> - <span v-if="user.is_local"> + <span v-if="canGrantRole"> <button class="button-default dropdown-item" @click="toggleRight("admin")" @@ -24,28 +24,31 @@ {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} </button> <div + v-if="canChangeActivationState || canDeleteAccount" role="separator" class="dropdown-divider" /> </span> <button + v-if="canChangeActivationState" class="button-default dropdown-item" @click="toggleActivationStatus()" > {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} </button> <button + v-if="canDeleteAccount" class="button-default dropdown-item" @click="deleteUserDialog(true)" > {{ $t('user_card.admin_menu.delete_account') }} </button> <div - v-if="hasTagPolicy" + v-if="canUseTagPolicy" role="separator" class="dropdown-divider" /> - <span v-if="hasTagPolicy"> + <span v-if="canUseTagPolicy"> <button class="button-default dropdown-item" @click="toggleTag(tags.FORCE_NSFW)" @@ -163,18 +166,21 @@ <script src="./moderation_tools.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .moderation-tools-popover { height: 100%; + .trigger { + /* stylelint-disable-next-line declaration-no-important */ display: flex !important; height: 100%; } } .moderation-tools-button { - svg,i { + svg, + i { font-size: 0.8em; } } diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss index 80ea01d4..a262ed1c 100644 --- a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss @@ -2,19 +2,21 @@ margin: 1em; table { - width:100%; + width: 100%; text-align: left; - padding-left:10px; - padding-bottom:20px; + padding-left: 10px; + padding-bottom: 20px; - th, td { + th, + td { width: 180px; max-width: 360px; - overflow: hidden; + overflow: hidden; vertical-align: text-top; } - th+th, td+td { + th + th, + td + td { width: auto; } } diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue index 1787fa07..97af4787 100644 --- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue @@ -227,6 +227,6 @@ <script src="./mrf_transparency_panel.js"></script> <style lang="scss"> -@import '../../_variables.scss'; -@import './mrf_transparency_panel.scss'; +@import "../../variables"; +@import "./mrf_transparency_panel"; </style> diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue index ca33c6c5..97e91cbc 100644 --- a/src/components/mute_card/mute_card.vue +++ b/src/components/mute_card/mute_card.vue @@ -37,6 +37,7 @@ .mute-card-content-container { margin-top: 0.5em; text-align: right; + button { width: 10em; } diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 37bcb409..8c9c3b11 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,5 +1,10 @@ -import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' +import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue' import { mapState, mapGetters } from 'vuex' +import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js' +import { filterNavigation } from 'src/components/navigation/filter.js' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import NavigationPins from 'src/components/navigation/navigation_pins.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -12,7 +17,9 @@ import { faComments, faBell, faInfoCircle, - faStream + faStream, + faList, + faBullhorn } from '@fortawesome/free-solid-svg-icons' library.add( @@ -25,26 +32,53 @@ library.add( faComments, faBell, faInfoCircle, - faStream + faStream, + faList, + faBullhorn ) - const NavPanel = { + props: ['forceExpand', 'forceEditMode'], created () { - if (this.currentUser && this.currentUser.locked) { - this.$store.dispatch('startFetchingFollowRequests') - } }, components: { - TimelineMenuContent + ListsMenuContent, + NavigationEntry, + NavigationPins, + Checkbox }, data () { return { - showTimelines: false + editMode: false, + showTimelines: false, + showLists: false, + timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })), + rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k })) } }, methods: { toggleTimelines () { this.showTimelines = !this.showTimelines + }, + toggleLists () { + this.showLists = !this.showLists + }, + toggleEditMode () { + this.editMode = !this.editMode + }, + toggleCollapse () { + this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed }) + this.$store.dispatch('pushServerSideStorage') + }, + isPinned (item) { + return this.pinnedItems.has(item) + }, + togglePin (item) { + if (this.isPinned(item)) { + this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) + } else { + this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) + } + this.$store.dispatch('pushServerSideStorage') } }, computed: { @@ -53,9 +87,40 @@ const NavPanel = { followRequestCount: state => state.api.followRequests.length, privateMode: state => state.instance.private, federating: state => state.instance.federating, - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, + supportsAnnouncements: state => state.announcements.supportsAnnouncements, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems), + collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav }), - ...mapGetters(['unreadChatCount']) + timelinesItems () { + return filterNavigation( + Object + .entries({ ...TIMELINES }) + .map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + hasAnnouncements: this.supportsAnnouncements, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + }, + rootItems () { + return filterNavigation( + Object + .entries({ ...ROOT_ITEMS }) + .map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + hasAnnouncements: this.supportsAnnouncements, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + }, + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 3fd27d89..1a826cc4 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -1,90 +1,99 @@ <template> <div class="NavPanel"> <div class="panel panel-default"> - <ul> - <li v-if="currentUser || !privateMode"> - <button - class="button-unstyled menu-item" - @click="toggleTimelines" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="stream" - />{{ $t("nav.timelines") }} - <FAIcon - class="timelines-chevron" - fixed-width - :icon="showTimelines ? 'chevron-up' : 'chevron-down'" + <div + v-if="!forceExpand" + class="panel-heading nav-panel-heading" + > + <NavigationPins :limit="6" /> + <div class="spacer" /> + <button + class="button-unstyled" + @click="toggleCollapse" + > + <FAIcon + class="navigation-chevron" + fixed-width + :icon="collapsed ? 'chevron-down' : 'chevron-up'" + /> + </button> + </div> + <ul + v-if="!collapsed || forceExpand" + class="panel-body" + > + <NavigationEntry + v-if="currentUser || !privateMode" + :show-pin="false" + :item="{ icon: 'stream', label: 'nav.timelines' }" + :aria-expanded="showTimelines ? 'true' : 'false'" + @click="toggleTimelines" + > + <FAIcon + class="timelines-chevron" + fixed-width + :icon="showTimelines ? 'chevron-up' : 'chevron-down'" + /> + </NavigationEntry> + <div + v-show="showTimelines" + class="timelines-background" + > + <div class="timelines"> + <NavigationEntry + v-for="item in timelinesItems" + :key="item.name" + :show-pin="editMode || forceEditMode" + :item="item" /> - </button> - <div - v-show="showTimelines" - class="timelines-background" - > - <TimelineMenuContent class="timelines" /> </div> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'interactions', params: { username: currentUser.screen_name } }" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="bell" - />{{ $t("nav.interactions") }} - </router-link> - </li> - <li v-if="currentUser && pleromaChatMessagesAvailable"> + </div> + <NavigationEntry + v-if="currentUser" + :show-pin="false" + :item="{ icon: 'list', label: 'nav.lists' }" + :aria-expanded="showLists ? 'true' : 'false'" + @click="toggleLists" + > <router-link - class="menu-item" - :to="{ name: 'chats', params: { username: currentUser.screen_name } }" + :title="$t('lists.manage_lists')" + class="extra-button" + :to="{ name: 'lists' }" + @click.stop > - <div - v-if="unreadChatCount" - class="badge badge-notification" - > - {{ unreadChatCount }} - </div> <FAIcon + class="extra-button" fixed-width - class="fa-scale-110" - icon="comments" - />{{ $t("nav.chats") }} - </router-link> - </li> - <li v-if="currentUser && currentUser.locked"> - <router-link - class="menu-item" - :to="{ name: 'friend-requests' }" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="user-plus" - />{{ $t("nav.friend_requests") }} - <span - v-if="followRequestCount > 0" - class="badge badge-notification" - > - {{ followRequestCount }} - </span> - </router-link> - </li> - <li> - <router-link - class="menu-item" - :to="{ name: 'about' }" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="info-circle" - />{{ $t("nav.about") }} + icon="wrench" + /> </router-link> - </li> + <FAIcon + class="timelines-chevron" + fixed-width + :icon="showLists ? 'chevron-up' : 'chevron-down'" + /> + </NavigationEntry> + <div + v-show="showLists" + class="timelines-background" + > + <ListsMenuContent + :show-pin="editMode || forceEditMode" + class="timelines" + /> + </div> + <NavigationEntry + v-for="item in rootItems" + :key="item.name" + :show-pin="editMode || forceEditMode" + :item="item" + /> + <NavigationEntry + v-if="!forceEditMode && currentUser" + :show-pin="false" + :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }" + @click="toggleEditMode" + /> </ul> </div> </div> @@ -93,7 +102,7 @@ <script src="./nav_panel.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .NavPanel { .panel { @@ -112,7 +121,6 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); - padding: 0; } > li { @@ -135,42 +143,10 @@ border: none; } - .menu-item { - display: block; - box-sizing: border-box; - height: 3.5em; - line-height: 3.5em; - padding: 0 1em; - width: 100%; - 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); - --icon: var(--selectedMenuIcon, $fallback--icon); - } - - &.router-link-active { - font-weight: bolder; - background-color: $fallback--lightBg; - background-color: var(--selectedMenu, $fallback--lightBg); - color: $fallback--text; - color: var(--selectedMenuText, $fallback--text); - --faint: var(--selectedMenuFaintText, $fallback--faint); - --faintLink: var(--selectedMenuFaintLink, $fallback--faint); - --lightText: var(--selectedMenuLightText, $fallback--lightText); - --icon: var(--selectedMenuIcon, $fallback--icon); - - &:hover { - text-decoration: underline; - } - } + .navigation-chevron { + margin-left: 0.8em; + margin-right: 0.8em; + font-size: 1.1em; } .timelines-chevron { @@ -182,7 +158,7 @@ padding: 0 0 0 0.6em; background-color: $fallback--lightBg; background-color: var(--selectedMenu, $fallback--lightBg); - border-top: 1px solid; + border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); } @@ -192,14 +168,10 @@ background-color: var(--bg, $fallback--bg); } - .fa-scale-110 { - margin-right: 0.8em; - } - - .badge { - position: absolute; - right: 0.6rem; - top: 1.25em; + .nav-panel-heading { + // breaks without a unit + // stylelint-disable-next-line length-zero-no-unit + --panel-heading-height-padding: 0px; } } </style> diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js new file mode 100644 index 00000000..e8e77f8f --- /dev/null +++ b/src/components/navigation/filter.js @@ -0,0 +1,19 @@ +export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser }) => { + return list.filter(({ criteria, anon, anonRoute }) => { + const set = new Set(criteria || []) + if (!isFederating && set.has('federating')) return false + if (!currentUser && isPrivate && set.has('!private')) return false + if (!currentUser && !(anon || anonRoute)) return false + if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false + if (!hasChats && set.has('chats')) return false + if (!hasAnnouncements && set.has('announcements')) return false + return true + }) +} + +export const getListEntries = state => state.lists.allLists.map(list => ({ + name: 'list-' + list.id, + routeObject: { name: 'lists-timeline', params: { id: list.id } }, + labelRaw: list.title, + iconLetter: list.title[0] +})) diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js new file mode 100644 index 00000000..7f096316 --- /dev/null +++ b/src/components/navigation/navigation.js @@ -0,0 +1,82 @@ +export const USERNAME_ROUTES = new Set([ + 'bookmarks', + 'dms', + 'interactions', + 'notifications', + 'chat', + 'chats', + 'user-profile' +]) + +export const TIMELINES = { + home: { + route: 'friends', + icon: 'home', + label: 'nav.home_timeline', + criteria: ['!private'] + }, + public: { + route: 'public-timeline', + anon: true, + icon: 'users', + label: 'nav.public_tl', + criteria: ['!private'] + }, + twkn: { + route: 'public-external-timeline', + anon: true, + icon: 'globe', + label: 'nav.twkn', + criteria: ['!private', 'federating'] + }, + bookmarks: { + route: 'bookmarks', + icon: 'bookmark', + label: 'nav.bookmarks' + }, + favorites: { + routeObject: { name: 'user-profile', query: { tab: 'favorites' } }, + icon: 'star', + label: 'user_card.favorites' + }, + dms: { + route: 'dms', + icon: 'envelope', + label: 'nav.dms' + } +} + +export const ROOT_ITEMS = { + interactions: { + route: 'interactions', + icon: 'bell', + label: 'nav.interactions' + }, + chats: { + route: 'chats', + icon: 'comments', + label: 'nav.chats', + badgeGetter: 'unreadChatCount', + criteria: ['chats'] + }, + friendRequests: { + route: 'friend-requests', + icon: 'user-plus', + label: 'nav.friend_requests', + criteria: ['lockedUser'], + badgeGetter: 'followRequestCount' + }, + about: { + route: 'about', + anon: true, + icon: 'info-circle', + label: 'nav.about' + }, + announcements: { + route: 'announcements', + icon: 'bullhorn', + label: 'nav.announcements', + badgeGetter: 'unreadAnnouncementCount', + criteria: ['announcements'] + } +} diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js new file mode 100644 index 00000000..81cc936a --- /dev/null +++ b/src/components/navigation/navigation_entry.js @@ -0,0 +1,51 @@ +import { mapState } from 'vuex' +import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js' +import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faThumbtack } from '@fortawesome/free-solid-svg-icons' + +library.add(faThumbtack) + +const NavigationEntry = { + props: ['item', 'showPin'], + components: { + OptionalRouterLink + }, + methods: { + isPinned (value) { + return this.pinnedItems.has(value) + }, + togglePin (value) { + if (this.isPinned(value)) { + this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value }) + } else { + this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value }) + } + this.$store.dispatch('pushServerSideStorage') + } + }, + computed: { + routeTo () { + if (!this.item.route && !this.item.routeObject) return null + let route + if (this.item.routeObject) { + route = this.item.routeObject + } else { + route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute } + } + if (USERNAME_ROUTES.has(route.name)) { + route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name } + } + return route + }, + getters () { + return this.$store.getters + }, + ...mapState({ + currentUser: state => state.users.currentUser, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems) + }) + } +} + +export default NavigationEntry diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue new file mode 100644 index 00000000..411ca536 --- /dev/null +++ b/src/components/navigation/navigation_entry.vue @@ -0,0 +1,135 @@ +<template> + <OptionalRouterLink + v-slot="{ isActive, href, navigate } = {}" + ass="ass" + :to="routeTo" + > + <li + class="NavigationEntry menu-item" + :class="{ '-active': isActive }" + v-bind="$attrs" + > + <component + :is="routeTo ? 'a' : 'button'" + class="main-link button-unstyled" + :href="href" + @click="navigate" + > + <span> + <FAIcon + v-if="item.icon" + fixed-width + class="fa-scale-110 menu-icon" + :icon="item.icon" + /> + </span> + <span + v-if="item.iconLetter" + class="icon iconLetter fa-scale-110 menu-icon" + >{{ item.iconLetter }} + </span> + <span class="label"> + {{ item.labelRaw || $t(item.label) }} + </span> + </component> + <slot /> + <div + v-if="item.badgeGetter && getters[item.badgeGetter]" + class="badge badge-notification" + > + {{ getters[item.badgeGetter] }} + </div> + <button + v-if="showPin && currentUser" + type="button" + class="button-unstyled extra-button" + :title="$t(isPinned ? 'general.unpin' : 'general.pin' )" + :aria-pressed="!!isPinned" + @click.stop.prevent="togglePin(item.name)" + > + <FAIcon + v-if="showPin && currentUser" + fixed-width + class="fa-scale-110" + :class="{ 'veryfaint': !isPinned(item.name) }" + :transform="!isPinned(item.name) ? 'rotate-45' : ''" + icon="thumbtack" + /> + </button> + </li> + </OptionalRouterLink> +</template> + +<script src="./navigation_entry.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.NavigationEntry { + display: flex; + box-sizing: border-box; + align-items: baseline; + height: 3.5em; + line-height: 3.5em; + padding: 0 1em; + width: 100%; + color: $fallback--link; + color: var(--link, $fallback--link); + + .timelines-chevron { + margin-right: 0; + } + + .main-link { + flex: 1; + } + + .menu-icon { + margin-right: 0.8em; + } + + .extra-button { + width: 3em; + text-align: center; + + &:last-child { + margin-right: -0.8em; + } + } + + &: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); + + .menu-icon { + --icon: var(--text, $fallback--icon); + } + } + + &.-active { + font-weight: bolder; + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); + color: $fallback--text; + color: var(--selectedMenuText, $fallback--text); + + --faint: var(--selectedMenuFaintText, $fallback--faint); + --faintLink: var(--selectedMenuFaintLink, $fallback--faint); + --lightText: var(--selectedMenuLightText, $fallback--lightText); + + .menu-icon { + --icon: var(--text, $fallback--icon); + } + + &:hover { + text-decoration: underline; + } + } +} +</style> diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js new file mode 100644 index 00000000..9dd795aa --- /dev/null +++ b/src/components/navigation/navigation_pins.js @@ -0,0 +1,94 @@ +import { mapState } from 'vuex' +import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js' +import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faComments, + faBell, + faInfoCircle, + faStream, + faList +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faComments, + faBell, + faInfoCircle, + faStream, + faList +) + +const NavPanel = { + props: ['limit'], + methods: { + getRouteTo (item) { + if (item.routeObject) { + return item.routeObject + } + const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute } + if (USERNAME_ROUTES.has(route.name)) { + route.params = { username: this.currentUser.screen_name } + } + return route + } + }, + computed: { + getters () { + return this.$store.getters + }, + ...mapState({ + lists: getListEntries, + currentUser: state => state.users.currentUser, + followRequestCount: state => state.api.followRequests.length, + privateMode: state => state.instance.private, + federating: state => state.instance.federating, + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems) + }), + pinnedList () { + if (!this.currentUser) { + return filterNavigation([ + { ...TIMELINES.public, name: 'public' }, + { ...TIMELINES.twkn, name: 'twkn' }, + { ...ROOT_ITEMS.about, name: 'about' } + ], + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + }) + } + return filterNavigation( + [ + ...Object + .entries({ ...TIMELINES }) + .filter(([k]) => this.pinnedItems.has(k)) + .map(([k, v]) => ({ ...v, name: k })), + ...this.lists.filter((k) => this.pinnedItems.has(k.name)), + ...Object + .entries({ ...ROOT_ITEMS }) + .filter(([k]) => this.pinnedItems.has(k)) + .map(([k, v]) => ({ ...v, name: k })) + ], + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ).slice(0, this.limit) + } + } +} + +export default NavPanel diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue new file mode 100644 index 00000000..4fbb4f95 --- /dev/null +++ b/src/components/navigation/navigation_pins.vue @@ -0,0 +1,75 @@ +<template> + <span class="NavigationPins"> + <router-link + v-for="item in pinnedList" + :key="item.name" + class="pinned-item" + :to="getRouteTo(item)" + :title="item.labelRaw || $t(item.label)" + > + <FAIcon + v-if="item.icon" + fixed-width + :icon="item.icon" + /> + <span + v-if="item.iconLetter" + class="iconLetter fa-scale-110 fa-old-padding" + >{{ item.iconLetter }}</span> + <div + v-if="item.badgeGetter && getters[item.badgeGetter]" + class="alert-dot" + /> + </router-link> + </span> +</template> + +<script src="./navigation_pins.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.NavigationPins { + display: flex; + flex-wrap: wrap; + overflow: hidden; + height: 100%; + + .alert-dot { + border-radius: 100%; + height: 0.5em; + width: 0.5em; + position: absolute; + right: calc(50% - 0.75em); + top: calc(50% - 0.5em); + background-color: $fallback--cRed; + background-color: var(--badgeNotification, $fallback--cRed); + } + + .pinned-item { + position: relative; + flex: 1 0 3em; + min-width: 2em; + text-align: center; + overflow: visible; + box-sizing: border-box; + height: 100%; + + & .svg-inline--fa, + & .iconLetter { + margin: 0; + } + + &.router-link-active { + color: $fallback--text; + color: var(--panelText, $fallback--text); + border-bottom: 4px solid; + + & .svg-inline--fa, + & .iconLetter { + color: inherit; + } + } + } +} +</style> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 882b68f9..265aaee0 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -4,6 +4,8 @@ import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.vue' +import Report from '../report/report.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import UserPopover from '../user_popover/user_popover.vue' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' @@ -18,7 +20,9 @@ import { faUserPlus, faEyeSlash, faUser, - faSuitcaseRolling + faSuitcaseRolling, + faExpandAlt, + faCompressAlt } from '@fortawesome/free-solid-svg-icons' library.add( @@ -29,13 +33,15 @@ library.add( faUserPlus, faUser, faEyeSlash, - faSuitcaseRolling + faSuitcaseRolling, + faExpandAlt, + faCompressAlt ) const Notification = { data () { return { - userExpanded: false, + statusExpanded: false, betterShadow: this.$store.state.interface.browserSupport.cssFilter, unmuted: false } @@ -47,12 +53,14 @@ const Notification = { UserCard, Timeago, Status, + Report, RichContent, - UserPopover + UserPopover, + UserLink }, methods: { - toggleUserExpanded () { - this.userExpanded = !this.userExpanded + toggleStatusExpanded () { + this.statusExpanded = !this.statusExpanded }, generateUserProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index 38978137..654aca3c 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -1,13 +1,14 @@ -@import '../../_variables.scss'; +@import "../../variables"; // TODO Copypaste from Status, should unify it somehow .Notification { - border-bottom: 1px solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - word-wrap: break-word; - word-break: break-word; - --emoji-size: 14px; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + word-wrap: break-word; + word-break: break-word; + + --emoji-size: 14px; &:hover { --_still-image-img-visibility: visible; @@ -54,7 +55,7 @@ margin-left: 0.2em; &::before { - content: ' '; + content: " "; } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index d2b903f6..f1aa5420 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,19 +1,23 @@ <template> - <Status + <article v-if="notification.type === 'mention'" - class="Notification" - :compact="true" - :statusoid="notification.status" - /> - <div v-else> + > + <Status + class="Notification" + :compact="true" + :statusoid="notification.status" + /> + </article> + <article v-else> <div v-if="needMute && !unmuted" class="Notification container -muted" > <small> - <router-link :to="userProfileLink"> - {{ notification.from_profile.screen_name_ui }} - </router-link> + <user-link + :user="notification.from_profile" + :at="false" + /> </small> <button class="button-unstyled unmute" @@ -121,6 +125,9 @@ </i18n-t> </small> </span> + <span v-if="notification.type === 'pleroma:report'"> + <small>{{ $t('notifications.submitted_report') }}</small> + </span> <span v-if="notification.type === 'poll'"> <FAIcon class="type-icon" @@ -137,13 +144,25 @@ <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" - class="faint-link" + class="timeago-link faint-link" > <Timeago :time="notification.created_at" :auto-update="240" /> </router-link> + <button + class="button-unstyled expand-icon" + @click.prevent="toggleStatusExpanded" + :title="$t('tool_tip.toggle_expand')" + :aria-expanded="statusExpanded" + > + <FAIcon + class="fa-scale-110" + fixed-width + :icon="statusExpanded ? 'compress-alt' : 'expand-alt'" + /> + </button> </div> <div v-else @@ -159,6 +178,8 @@ <button v-if="needMute" class="button-unstyled" + :title="$t('tool_tip.toggle_mute')" + :aria-expanded="!unmuted" @click.prevent="toggleMute" > <FAIcon @@ -171,12 +192,10 @@ v-if="notification.type === 'follow' || notification.type === 'follow_request'" class="follow-text" > - <router-link - :to="userProfileLink" + <user-link class="follow-name" - > - @{{ notification.from_profile.screen_name_ui }} - </router-link> + :user="notification.from_profile" + /> <div v-if="notification.type === 'follow_request'" style="white-space: nowrap;" @@ -207,20 +226,24 @@ v-else-if="notification.type === 'move'" class="move-text" > - <router-link :to="targetUserProfileLink"> - @{{ notification.target.screen_name_ui }} - </router-link> + <user-link + :user="notification.target" + /> </div> + <Report + v-else-if="notification.type === 'pleroma:report'" + :report-id="notification.report.id" + /> <template v-else> <StatusContent - class="faint" - :compact="true" + :class="{ faint: !statusExpanded }" + :compact="!statusExpanded" :status="notification.action" /> </template> </div> </div> - </div> + </article> </template> <script src="./notification.js"></script> diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue index b0213167..1315b51a 100644 --- a/src/components/notifications/notification_filters.vue +++ b/src/components/notifications/notification_filters.vue @@ -109,22 +109,3 @@ export default { } } </script> - -<style lang="scss"> - -.NotificationFilters { - align-self: stretch; - - > button { - line-height: 100%; - height: 100%; - width: var(--__panel-heading-height-inner); - text-align: center; - - svg { - font-size: 1.2em; - } - } -} - -</style> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 0851f407..d499d3d6 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -10,10 +10,12 @@ import { } from '../../services/notification_utils/notification_utils.js' import FaviconService from '../../services/favicon_service/favicon_service.js' import { library } from '@fortawesome/fontawesome-svg-core' -import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' +import { faCircleNotch, faArrowUp, faMinus } from '@fortawesome/free-solid-svg-icons' library.add( - faCircleNotch + faCircleNotch, + faArrowUp, + faMinus ) const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 @@ -34,6 +36,7 @@ const Notifications = { }, data () { return { + showScrollTop: false, bottomedOut: false, // How many seen notifications to display in the list. The more there are, // the heavier the page becomes. This count is increased when loading @@ -66,7 +69,7 @@ const Notifications = { return this.unseenNotifications.length }, unseenCountTitle () { - return this.unseenCount + (this.unreadChatCount) + return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount }, loading () { return this.$store.state.statuses.notifications.loading @@ -90,7 +93,22 @@ const Notifications = { notificationsToDisplay () { return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) }, - ...mapGetters(['unreadChatCount']) + noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) + }, + mounted () { + this.scrollerRef = this.$refs.root.closest('.column.-scrollable') + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.mobile-notifications') + } + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.column.main') + } + this.scrollerRef.addEventListener('scroll', this.updateScrollPosition) + }, + unmounted () { + if (!this.scrollerRef) return + this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition) }, watch: { unseenCountTitle (count) { @@ -101,9 +119,29 @@ const Notifications = { FaviconService.clearFaviconBadge() this.$store.dispatch('setPageTitle', '') } + }, + teleportTarget () { + // handle scroller change + this.$nextTick(() => { + this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition) + this.scrollerRef = this.$refs.root.closest('.column.-scrollable') + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.mobile-notifications') + } + this.scrollerRef.addEventListener('scroll', this.updateScrollPosition) + this.updateScrollPosition() + }) } }, methods: { + scrollToTop () { + const scrollable = this.scrollerRef + scrollable.scrollTo({ top: this.$refs.root.offsetTop }) + // this.$refs.root.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + updateScrollPosition () { + this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop + }, markAsSeen () { this.$store.dispatch('markNotificationsAsSeen') this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 3d3408f7..41cfcef0 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .Notifications { &:not(.minimal) { @@ -25,12 +25,13 @@ &.unseen { .notification-overlay { - background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px) + background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px); } } } } +/* stylelint-disable-next-line no-descending-specificity */ .notification { box-sizing: border-box; @@ -38,6 +39,7 @@ canvas { display: none; } + img { visibility: visible; } @@ -59,8 +61,10 @@ height: 32px; } - --link: var(--faintLink); - --text: var(--faint); + .faint { + --link: var(--faintLink); + --text: var(--faint); + } } .follow-request-accept { @@ -77,7 +81,8 @@ } } - .follow-text, .move-text { + .follow-text, + .move-text { padding: 0.5em 0; overflow-wrap: break-word; display: flex; @@ -110,6 +115,16 @@ min-width: 3em; text-align: right; } + + .timeago-link { + margin-right: 0.2em; + } + + .expand-icon { + .svg-inline--fa { + margin-left: 0.25em; + } + } } .emoji-reaction-emoji { diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index e778e27b..633efca6 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -3,7 +3,9 @@ :disabled="minimalMode || disableTeleport" :to="teleportTarget" > - <div + <component + :is="noHeading ? 'div' : 'aside'" + ref="root" :class="{ minimal: minimalMode }" class="Notifications" > @@ -19,19 +21,43 @@ class="badge badge-notification unseen-count" >{{ unseenCount }}</span> </div> + <div + v-if="showScrollTop" + class="rightside-button" + > + <button + class="button-unstyled scroll-to-top-button" + type="button" + :title="$t('general.scroll_to_top')" + @click="scrollToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> + </div> <button v-if="unseenCount" class="button-default read-button" + type="button" @click.prevent="markAsSeen" > {{ $t('notifications.read') }} </button> - <NotificationFilters /> + <NotificationFilters class="rightside-button" /> </div> - <div class="panel-body"> + <div + class="panel-body" + role="feed" + > <div v-for="notification in notificationsToDisplay" :key="notification.id" + role="listitem" class="notification" :class="{unseen: !minimalMode && !notification.seen}" > @@ -67,7 +93,7 @@ </div> </div> </div> - </div> + </component> </teleport> </template> diff --git a/src/components/optional_router_link/optional_router_link.vue b/src/components/optional_router_link/optional_router_link.vue new file mode 100644 index 00000000..d56ad268 --- /dev/null +++ b/src/components/optional_router_link/optional_router_link.vue @@ -0,0 +1,23 @@ +<template> + <!-- eslint-disable vue/no-multiple-template-root --> + <router-link + v-if="to" + v-slot="props" + :to="to" + custom + > + <slot + v-bind="props" + /> + </router-link> + <slot + v-else + v-bind="{}" + /> +</template> + +<script> +export default { + props: ['to'] +} +</script> diff --git a/src/components/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue index d916d8a6..17458e49 100644 --- a/src/components/panel_loading/panel_loading.vue +++ b/src/components/panel_loading/panel_loading.vue @@ -23,7 +23,7 @@ export default {} </script> <style lang="scss"> -@import 'src/_variables.scss'; +@import "src/variables"; .panel-loading { display: flex; @@ -33,6 +33,7 @@ export default {} font-size: 2em; color: $fallback--text; color: var(--text, $fallback--text); + .loading-text svg { line-height: 0; vertical-align: middle; diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue index 90673f44..d6e10250 100644 --- a/src/components/password_reset/password_reset.vue +++ b/src/components/password_reset/password_reset.vue @@ -77,7 +77,7 @@ <script src="./password_reset.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .password-reset-form { display: flex; diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue index f6b12a54..cacc3298 100644 --- a/src/components/poll/poll.vue +++ b/src/components/poll/poll.vue @@ -90,7 +90,7 @@ <script src="./poll.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .poll { .votes { @@ -98,9 +98,11 @@ flex-direction: column; margin: 0 0 0.5em; } + .poll-option { margin: 0.75em 0.5em; } + .option-result { height: 100%; display: flex; @@ -109,6 +111,7 @@ color: $fallback--lightText; color: var(--lightText, $fallback--lightText); } + .option-result-label { display: flex; align-items: center; @@ -116,10 +119,12 @@ z-index: 1; word-break: break-word; } + .result-percentage { width: 3.5em; flex-shrink: 0; } + .result-fill { height: 100%; position: absolute; @@ -133,20 +138,25 @@ left: 0; transition: width 0.5s; } + .option-vote { display: flex; align-items: center; } + input { width: 3.5em; } + .footer { display: flex; align-items: center; } + &.loading * { cursor: progress; } + .poll-vote-button { padding: 0 0.5em; margin-right: 0.5em; diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue index 146754db..09d411ca 100644 --- a/src/components/poll/poll_form.vue +++ b/src/components/poll/poll_form.vue @@ -95,7 +95,7 @@ <script src="./poll_form.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .poll-form { display: flex; @@ -117,6 +117,7 @@ .input-container { width: 100%; + input { // Hack: dodge the floating X icon padding-right: 2.5em; diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index d2af59fe..d44b266b 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -4,7 +4,7 @@ const Popover = { // Action to trigger popover: either 'hover' or 'click' trigger: String, - // Either 'top' or 'bottom' + // 'top', 'bottom', 'left', 'right' placement: String, // Takes object with properties 'x' and 'y', values of these can be @@ -43,7 +43,12 @@ const Popover = { overlayCentersSelector: String, // Lets hover popover stay when clicking inside of it - stayOnClick: Boolean + stayOnClick: Boolean, + + triggerAttrs: { + type: Object, + default: {} + } }, inject: ['popoversZLayer'], // override popover z layer data () { @@ -51,6 +56,10 @@ const Popover = { // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance // with popovers refusing to be hidden when user wants to interact with something in below popover + anchorEl: null, + // There's an issue where having teleport enabled by default causes things just... + // not render at all, i.e. main post status form and its emoji inputs + teleport: false, lockReEntry: false, hidden: true, styles: {}, @@ -59,10 +68,15 @@ const Popover = { // used to avoid blinking if hovered onto popover graceTimeout: null, parentPopover: null, + disableClickOutside: false, childrenShown: new Set() } }, methods: { + setAnchorEl (el) { + this.anchorEl = el + this.updateStyles() + }, containerBoundingClientRect () { const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent return container.getBoundingClientRect() @@ -75,7 +89,7 @@ const Popover = { // Popover will be anchored around this element, trigger ref is the container, so // its children are what are inside the slot. Expect only one v-slot:trigger. - const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el + const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el // SVGs don't have offsetWidth/Height, use fallback const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth @@ -84,6 +98,8 @@ const Popover = { const anchorStyle = getComputedStyle(anchorEl) const topPadding = parseFloat(anchorStyle.paddingTop) const bottomPadding = parseFloat(anchorStyle.paddingBottom) + const rightPadding = parseFloat(anchorStyle.paddingRight) + const leftPadding = parseFloat(anchorStyle.paddingLeft) // Screen position of the origin point for popover = center of the anchor const origin = { @@ -170,7 +186,7 @@ const Popover = { if (overlayCenter) { translateX = origin.x + horizOffset translateY = origin.y + vertOffset - } else { + } else if (this.placement !== 'right' && this.placement !== 'left') { // Default to whatever user wished with placement prop let usingTop = this.placement !== 'bottom' @@ -189,6 +205,25 @@ const Popover = { const xOffset = (this.offset && this.offset.x) || 0 translateX = origin.x + horizOffset + xOffset + } else { + // Default to whatever user wished with placement prop + let usingRight = this.placement !== 'left' + + // Handle special cases, first force to displaying on top if there's not space on bottom, + // regardless of what placement value was. Then check if there's not space on top, and + // force to bottom, again regardless of what placement value was. + const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0) + const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0) + if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true + if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false + + const xOffset = (this.offset && this.offset.x) || 0 + translateX = usingRight + ? rightBoundary - xOffset - content.offsetWidth + : leftBoundary + xOffset + + const yOffset = (this.offset && this.offset.y) || 0 + translateY = origin.y + vertOffset + yOffset } this.styles = { @@ -205,6 +240,10 @@ const Popover = { }, showPopover () { if (this.disabled) return + this.disableClickOutside = true + setTimeout(() => { + this.disableClickOutside = false + }, 0) const wasHidden = this.hidden this.hidden = false this.parentPopover && this.parentPopover.onChildPopoverState(this, true) @@ -265,6 +304,7 @@ const Popover = { } }, onClickOutside (e) { + if (this.disableClickOutside) return if (this.hidden) return if (this.$refs.content && this.$refs.content.contains(e.target)) return if (this.$el.contains(e.target)) return @@ -298,6 +338,7 @@ const Popover = { } }, mounted () { + this.teleport = true let scrollable = this.$refs.trigger.closest('.column.-scrollable') || this.$refs.trigger.closest('.mobile-notifications') if (!scrollable) scrollable = window diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index bd59cade..fd0fd821 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -7,11 +7,15 @@ ref="trigger" class="button-unstyled popover-trigger-button" type="button" + v-bind="triggerAttrs" @click="onClick" > <slot name="trigger" /> </button> - <teleport to="#popovers"> + <teleport + :disabled="!teleport" + to="#popovers" + > <transition name="fade"> <div v-if="!hidden" @@ -37,7 +41,7 @@ <script src="./popover.js" /> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .popover-trigger-button { display: inline-block; @@ -48,31 +52,31 @@ position: fixed; min-width: 0; max-width: calc(100vw - 20px); - box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); + box-shadow: 2px 2px 3px rgb(0 0 0 / 50%); box-shadow: var(--popupShadow); } .popover-default { - &:after { - content: ''; + &::after { + content: ""; position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: 3; - box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: 1px 1px 4px rgb(0 0 0 / 60%); box-shadow: var(--panelShadow); pointer-events: none; } border-radius: $fallback--btnRadius; border-radius: var(--btnRadius, $fallback--btnRadius); - background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--text; color: var(--popoverText, $fallback--text); + --faint: var(--popoverFaintText, $fallback--faint); --faintLink: var(--popoverFaintLink, $fallback--faint); --lightText: var(--popoverLightText, $fallback--lightText); @@ -83,7 +87,7 @@ .dropdown-menu { display: block; - padding: .5rem 0; + padding: 0.5rem 0; font-size: 1em; text-align: left; list-style: none; @@ -93,7 +97,7 @@ .dropdown-divider { height: 0; - margin: .5rem 0; + margin: 0.5rem 0; overflow: hidden; border-top: 1px solid $fallback--border; border-top: 1px solid var(--border, $fallback--border); @@ -109,7 +113,7 @@ text-align: inherit; white-space: nowrap; border: none; - border-radius: 0px; + border-radius: 0; background-color: transparent; box-shadow: none; width: 100%; @@ -122,21 +126,32 @@ svg { width: 22px; margin-right: 0.75rem; - color: var(--menuPopoverIcon, $fallback--icon) + color: var(--menuPopoverIcon, $fallback--icon); + } + } + + &.-has-submenu { + .chevron-icon { + margin-right: 0.25rem; + margin-left: 2rem; } } - &:active, &:hover { + &:active, + &:hover { background-color: $fallback--lightBg; background-color: var(--selectedMenuPopover, $fallback--lightBg); box-shadow: none; + --btnText: var(--selectedMenuPopoverText, $fallback--link); --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); --icon: var(--selectedMenuPopoverIcon, $fallback--icon); + svg { color: var(--selectedMenuPopoverIcon, $fallback--icon); + --icon: var(--selectedMenuPopoverIcon, $fallback--icon); } } @@ -150,16 +165,16 @@ max-height: 22px; line-height: 22px; text-align: center; - border-radius: 0px; + border-radius: 0; background-color: $fallback--fg; background-color: var(--input, $fallback--fg); - box-shadow: 0px 0px 2px black inset; + box-shadow: 0 0 2px black inset; box-shadow: var(--inputShadow); margin-right: 0.75em; &.menu-checkbox-checked::after { font-size: 1.25em; - content: '✓'; + content: "✓"; } &.-radio { @@ -167,16 +182,15 @@ &.menu-checkbox-checked::after { font-size: 2em; - content: '•'; + content: "•"; } } } - } .button-default.dropdown-item { &, - i[class*=icon-] { + i[class*="icon-"] { color: $fallback--text; color: var(--btnText, $fallback--text); } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index c0d80b20..eb55cfcc 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -55,6 +55,14 @@ const pxStringToNumber = (str) => { const PostStatusForm = { props: [ + 'statusId', + 'statusText', + 'statusIsSensitive', + 'statusPoll', + 'statusFiles', + 'statusMediaDescriptions', + 'statusScope', + 'statusContentType', 'replyTo', 'repliedUser', 'attentions', @@ -62,6 +70,7 @@ const PostStatusForm = { 'subject', 'disableSubject', 'disableScopeSelector', + 'disableVisibilitySelector', 'disableNotice', 'disableLockWarning', 'disablePolls', @@ -125,22 +134,38 @@ const PostStatusForm = { const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig + let statusParams = { + spoilerText: this.subject || '', + status: statusText, + nsfw: !!sensitiveByDefault, + files: [], + poll: {}, + mediaDescriptions: {}, + visibility: scope, + contentType + } + + if (this.statusId) { + const statusContentType = this.statusContentType || contentType + statusParams = { + spoilerText: this.subject || '', + status: this.statusText || '', + nsfw: this.statusIsSensitive || !!sensitiveByDefault, + files: this.statusFiles || [], + poll: this.statusPoll || {}, + mediaDescriptions: this.statusMediaDescriptions || {}, + visibility: this.statusScope || scope, + contentType: statusContentType + } + } + return { dropFiles: [], uploadingFiles: false, error: null, posting: false, highlighted: 0, - newStatus: { - spoilerText: this.subject || '', - status: statusText, - nsfw: !!sensitiveByDefault, - files: [], - poll: {}, - mediaDescriptions: {}, - visibility: scope, - contentType - }, + newStatus: statusParams, caret: 0, pollFormVisible: false, showDropIcon: 'hide', @@ -164,7 +189,7 @@ const PostStatusForm = { emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store @@ -173,13 +198,13 @@ const PostStatusForm = { emojiSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ] }) }, emoji () { - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, customEmoji () { return this.$store.state.instance.customEmoji || [] @@ -236,6 +261,9 @@ const PostStatusForm = { uploadFileLimitReached () { return this.newStatus.files.length >= this.fileLimit }, + isEdit () { + return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' + }, ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout @@ -473,7 +501,6 @@ const PostStatusForm = { if (target.value === '') { target.style.height = null this.$emit('resize') - this.$refs['emoji-input'].resize() return } @@ -560,8 +587,6 @@ const PostStatusForm = { } else { scrollerRef.scrollTop = targetScroll } - - this.$refs['emoji-input'].resize() }, showEmojiPicker () { this.$refs.textarea.focus() diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 62613bd1..c51639db 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -67,6 +67,13 @@ <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> <div + v-if="isEdit" + class="visibility-notice edit-warning" + > + <p>{{ $t('post_status.edit_remote_warning') }}</p> + <p>{{ $t('post_status.edit_unsupported_warning') }}</p> + </div> + <div v-if="!disablePreview" class="preview-heading faint" > @@ -170,6 +177,7 @@ class="visibility-tray" > <scope-selector + v-if="!disableVisibilitySelector" :show-all="showAllScopes" :user-default="userDefaultScope" :original-scope="copyMessageScope" @@ -323,7 +331,7 @@ <script src="./post_status_form.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .post-status-form { position: relative; @@ -370,7 +378,9 @@ &:hover { text-decoration: underline; } - svg, i { + + svg, + i { margin-left: 0.2em; font-size: 0.8em; transform: rotate(90deg); @@ -410,7 +420,35 @@ align-items: baseline; } - .media-upload-icon, .poll-icon, .emoji-icon { + .visibility-notice.edit-warning { + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + + // Order is not necessary but a good indicator + .media-upload-icon { + order: 1; + justify-content: left; + } + + .emoji-icon { + order: 2; + justify-content: center; + } + + .poll-icon { + order: 3; + justify-content: right; + } + + .media-upload-icon, + .poll-icon, + .emoji-icon { font-size: 1.85em; line-height: 1.1; flex: 1; @@ -418,16 +456,20 @@ display: flex; align-items: center; - &.selected, &:hover { + &.selected, + &:hover { // needs to be specific to override icon default color - svg, i, label { + svg, + i, + label { color: $fallback--lightText; color: var(--lightText, $fallback--lightText); } } &.disabled { - svg, i { + svg, + i { cursor: not-allowed; color: $fallback--icon; color: var(--btnDisabledText, $fallback--icon); @@ -440,32 +482,17 @@ } } - // Order is not necessary but a good indicator - .media-upload-icon { - order: 1; - justify-content: left; - } - - .emoji-icon { - order: 2; - justify-content: center; - } - - .poll-icon { - order: 3; - justify-content: right; - } - .error { text-align: center; } .media-upload-wrapper { - margin-right: .2em; - margin-bottom: .5em; + margin-right: 0.2em; + margin-bottom: 0.5em; width: 18em; - img, video { + img, + video { object-fit: contain; max-height: 10em; } @@ -539,18 +566,14 @@ } } - .btn[disabled] { - cursor: not-allowed; - } - @keyframes fade-in { from { opacity: 0; } - to { opacity: 0.6; } + to { opacity: 0.6; } } @keyframes fade-out { from { opacity: 0.6; } - to { opacity: 0; } + to { opacity: 0; } } .drop-indicator { diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js index 92d5ac14..e67e3a4b 100644 --- a/src/components/timeline/timeline_quick_settings.js +++ b/src/components/quick_filter_settings/quick_filter_settings.js @@ -9,7 +9,10 @@ library.add( faWrench ) -const TimelineQuickSettings = { +const QuickFilterSettings = { + props: { + conversation: Boolean + }, components: { Popover }, @@ -64,4 +67,4 @@ const TimelineQuickSettings = { } } -export default TimelineQuickSettings +export default QuickFilterSettings diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue index 297bc72a..f2aa61ee 100644 --- a/src/components/timeline/timeline_quick_settings.vue +++ b/src/components/quick_filter_settings/quick_filter_settings.vue @@ -1,13 +1,15 @@ <template> <Popover trigger="click" - class="TimelineQuickSettings" + class="QuickFilterSettings" :bound-to="{ x: 'container' }" + :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }" > <template #content> <div class="dropdown-menu"> <div v-if="loggedIn"> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilityAll = true" > @@ -17,6 +19,7 @@ />{{ $t('settings.reply_visibility_all') }} </button> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilityFollowing = true" > @@ -26,6 +29,7 @@ />{{ $t('settings.reply_visibility_following_short') }} </button> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilitySelf = true" > @@ -35,6 +39,7 @@ />{{ $t('settings.reply_visibility_self_short') }} </button> <div + v-if="!conversation" role="separator" class="dropdown-divider" /> @@ -70,40 +75,14 @@ class="button-default dropdown-item dropdown-item-icon" @click="openTab('filtering')" > - <FAIcon icon="font" />{{ $t('settings.word_filter') }} - </button> - <button - class="button-default dropdown-item dropdown-item-icon" - @click="openTab('general')" - > - <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} + <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }} </button> </div> </template> <template #trigger> - <button class="button-unstyled"> - <FAIcon icon="filter" /> - </button> + <FAIcon icon="filter" /> </template> </Popover> </template> -<script src="./timeline_quick_settings.js"></script> - -<style lang="scss"> - -.TimelineQuickSettings { - - > button { - line-height: 100%; - height: 100%; - width: var(--__panel-heading-height-inner); - text-align: center; - - svg { - font-size: 1.2em; - } - } -} - -</style> +<script src="./quick_filter_settings.js"></script> diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js new file mode 100644 index 00000000..2798f37a --- /dev/null +++ b/src/components/quick_view_settings/quick_view_settings.js @@ -0,0 +1,69 @@ +import Popover from '../popover/popover.vue' +import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faList, + faFolderTree, + faBars, + faWrench +) + +const QuickViewSettings = { + props: { + conversation: Boolean + }, + components: { + Popover + }, + methods: { + setConversationDisplay (visibility) { + this.$store.dispatch('setOption', { name: 'conversationDisplay', value: visibility }) + }, + openTab (tab) { + this.$store.dispatch('openSettingsModalTab', tab) + } + }, + computed: { + ...mapGetters(['mergedConfig']), + loggedIn () { + return !!this.$store.state.users.currentUser + }, + conversationDisplay: { + get () { return this.mergedConfig.conversationDisplay }, + set (newVal) { this.setConversationDisplay(newVal) } + }, + autoUpdate: { + get () { return this.mergedConfig.streaming }, + set () { + const value = !this.autoUpdate + this.$store.dispatch('setOption', { name: 'streaming', value }) + } + }, + collapseWithSubjects: { + get () { return this.mergedConfig.collapseMessageWithSubject }, + set () { + const value = !this.collapseWithSubjects + this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) + } + }, + showUserAvatars: { + get () { return this.mergedConfig.mentionLinkShowAvatar }, + set () { + const value = !this.showUserAvatars + console.log(value) + this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value }) + } + }, + muteBotStatuses: { + get () { return this.mergedConfig.muteBotStatuses }, + set () { + const value = !this.muteBotStatuses + this.$store.dispatch('setOption', { name: 'muteBotStatuses', value }) + } + } + } +} + +export default QuickViewSettings diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue new file mode 100644 index 00000000..4bd81c5b --- /dev/null +++ b/src/components/quick_view_settings/quick_view_settings.vue @@ -0,0 +1,75 @@ +<template> + <Popover + trigger="click" + class="QuickViewSettings" + :bound-to="{ x: 'container' }" + :trigger-attrs="{ title: $t('timeline.quick_view_settings') }" + > + <template #content> + <div class="dropdown-menu"> + <button + class="button-default dropdown-item" + @click="conversationDisplay = 'tree'" + > + <span + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }" + /><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }} + </button> + <button + class="button-default dropdown-item" + @click="conversationDisplay = 'linear'" + > + <span + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }" + /><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }} + </button> + <div + role="separator" + class="dropdown-divider" + /> + <button + class="button-default dropdown-item" + @click="showUserAvatars = !showUserAvatars" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': showUserAvatars }" + />{{ $t('settings.mention_link_show_avatar_quick') }} + </button> + <button + v-if="!conversation" + class="button-default dropdown-item" + @click="autoUpdate = !autoUpdate" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': autoUpdate }" + />{{ $t('settings.auto_update') }} + </button> + <button + v-if="!conversation" + class="button-default dropdown-item" + @click="collapseWithSubjects = !collapseWithSubjects" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': collapseWithSubjects }" + />{{ $t('settings.collapse_subject') }} + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click="openTab('general')" + > + <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} + </button> + </div> + </template> + <template #trigger> + <FAIcon icon="bars" /> + </template> + </Popover> +</template> + +<script src="./quick_view_settings.js"></script> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index 37d6e7d0..47a48623 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -1,15 +1,22 @@ import Popover from '../popover/popover.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' +import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' import { trim } from 'lodash' -library.add(faSmileBeam) +library.add( + faPlus, + faTimes, + faSmileBeam +) const ReactButton = { props: ['status'], data () { return { - filterWord: '' + filterWord: '', + expanded: false } }, components: { @@ -25,41 +32,90 @@ const ReactButton = { } close() }, + onShow () { + this.expanded = true + this.focusInput() + }, + onClose () { + this.expanded = false + }, focusInput () { this.$nextTick(() => { - const input = this.$el.querySelector('input') + const input = document.querySelector('.reaction-picker-filter > input') if (input) input.focus() }) + }, + // Vaguely adjusted copypaste from emoji_input and emoji_picker! + maybeLocalizedEmojiNamesAndKeywords (emoji) { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + }, + maybeLocalizedEmojiName (emoji) { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText } }, computed: { commonEmojis () { - return [ - { displayText: 'thumbsup', replacement: '👍' }, - { displayText: 'angry', replacement: '😠' }, - { displayText: 'eyes', replacement: '👀' }, - { displayText: 'joy', replacement: '😂' }, - { displayText: 'fire', replacement: '🔥' } - ] + const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥']) + return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement)) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) }, emojis () { if (this.filterWord !== '') { - const filterWordLowercase = trim(this.filterWord.toLowerCase()) + const keywordLowercase = trim(this.filterWord.toLowerCase()) + const orderedEmojiList = [] - for (const emoji of this.$store.state.instance.emoji) { - if (emoji.replacement === this.filterWord) return [emoji] + for (const emoji of this.$store.getters.standardEmojiList) { + const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji) + .keywords + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 - const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) - if (indexOfFilterWord > -1) { - if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) { - orderedEmojiList[indexOfFilterWord] = [] + if (indexOfKeyword > -1) { + if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { + orderedEmojiList[indexOfKeyword] = [] } - orderedEmojiList[indexOfFilterWord].push(emoji) + orderedEmojiList[indexOfKeyword].push(emoji) } } return orderedEmojiList.flat() } - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, mergedConfig () { return this.$store.getters.mergedConfig diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index 5a809847..a813b6fd 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -7,7 +7,8 @@ :bound-to="{ x: 'container' }" remove-padding popover-class="ReactButton popover-default" - @show="focusInput" + @show="onShow" + @close="onClose" > <template #content="{close}"> <div class="reaction-picker-filter"> @@ -23,7 +24,7 @@ v-for="emoji in commonEmojis" :key="emoji.replacement" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} @@ -33,7 +34,7 @@ v-for="(emoji, key) in emojis" :key="key" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} @@ -46,10 +47,24 @@ class="button-unstyled popover-trigger" :title="$t('tool_tip.add_reaction')" > - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="['far', 'smile-beam']" - /> + <FALayers> + <FAIcon + class="fa-scale-110 fa-old-padding" + :icon="['far', 'smile-beam']" + /> + <FAIcon + v-show="!expanded" + class="focus-marker" + transform="shrink-6 up-9 right-17" + icon="plus" + /> + <FAIcon + v-show="expanded" + class="focus-marker" + transform="shrink-6 up-9 right-17" + icon="times" + /> + </FALayers> </span> </template> </Popover> @@ -58,7 +73,8 @@ <script src="./react_button.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; +@import "../../mixins"; .ReactButton { .reaction-picker-filter { @@ -88,20 +104,19 @@ text-align: center; align-content: flex-start; user-select: none; - - mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, - linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, - linear-gradient(to top, white, white); + mask: + linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); transition: mask-size 150ms; mask-size: 100% 20px, 100% 20px, auto; /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; .emoji-button { cursor: pointer; - flex-basis: 20%; line-height: 1.5; align-content: center; @@ -112,11 +127,6 @@ } } - /* override of popover internal stuff */ - .popover-trigger-button { - width: auto; - } - .popover-trigger { padding: 10px; margin: -10px; @@ -126,6 +136,23 @@ 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> diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index d78d8da9..a26162f0 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -158,10 +158,10 @@ class="form-error" > <ul> - <li v-if="!v$.user.confirm.required"> + <li v-if="v$.user.confirm.required.$invalid"> <span>{{ $t('registration.validations.password_confirmation_required') }}</span> </li> - <li v-if="!v$.user.confirm.sameAsPassword"> + <li v-if="v$.user.confirm.sameAs.$invalid"> <span>{{ $t('registration.validations.password_confirmation_match') }}</span> </li> </ul> @@ -277,7 +277,7 @@ <script src="./registration.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; $validations-cRed: #f04124; .registration-form { @@ -321,7 +321,7 @@ $validations-cRed: #f04124; .form-group--error { animation-name: shakeError; - animation-duration: .6s; + animation-duration: 0.6s; animation-timing-function: ease-in-out; } @@ -350,7 +350,7 @@ $validations-cRed: #f04124; } form textarea { - line-height:16px; + line-height: 16px; resize: vertical; } diff --git a/src/components/remote_user_resolver/remote_user_resolver.vue b/src/components/remote_user_resolver/remote_user_resolver.vue index f8945225..07aac169 100644 --- a/src/components/remote_user_resolver/remote_user_resolver.vue +++ b/src/components/remote_user_resolver/remote_user_resolver.vue @@ -15,6 +15,3 @@ </template> <script src="./remote_user_resolver.js"></script> - -<style lang="scss"> -</style> diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js new file mode 100644 index 00000000..e1a7531b --- /dev/null +++ b/src/components/remove_follower_button/remove_follower_button.js @@ -0,0 +1,25 @@ +export default { + props: ['relationship'], + data () { + return { + inProgress: false + } + }, + computed: { + label () { + if (this.inProgress) { + return this.$t('user_card.follow_progress') + } else { + return this.$t('user_card.remove_follower') + } + } + }, + methods: { + onClick () { + this.inProgress = true + this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => { + this.inProgress = false + }) + } + } +} diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue new file mode 100644 index 00000000..a3a4c242 --- /dev/null +++ b/src/components/remove_follower_button/remove_follower_button.vue @@ -0,0 +1,13 @@ +<template> + <button + class="btn button-default follow-button" + :class="{ toggled: inProgress }" + :disabled="inProgress" + :title="$t('user_card.remove_follower')" + @click="onClick" + > + {{ label }} + </button> +</template> + +<script src="./remove_follower_button.js"></script> diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js index c7bd2a2b..543d25ac 100644 --- a/src/components/reply_button/reply_button.js +++ b/src/components/reply_button/reply_button.js @@ -1,7 +1,15 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { faReply } from '@fortawesome/free-solid-svg-icons' +import { + faReply, + faPlus, + faTimes +} from '@fortawesome/free-solid-svg-icons' -library.add(faReply) +library.add( + faReply, + faPlus, + faTimes +) const ReplyButton = { name: 'ReplyButton', @@ -9,6 +17,9 @@ const ReplyButton = { computed: { loggedIn () { return !!this.$store.state.users.currentUser + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue index c17041da..6e3964b7 100644 --- a/src/components/reply_button/reply_button.vue +++ b/src/components/reply_button/reply_button.vue @@ -7,18 +7,38 @@ :title="$t('tool_tip.reply')" @click.prevent="$emit('toggle')" > - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="reply" - /> + <FALayers class="fa-old-padding-layer"> + <FAIcon + class="fa-scale-110" + icon="reply" + /> + <FAIcon + v-if="!replying" + class="focus-marker" + transform="shrink-6 up-8 right-11" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-8 right-11" + icon="times" + /> + </FALayers> </button> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon icon="reply" class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.reply')" /> - </span> + </a> <span v-if="status.replies_count > 0" class="action-counter" @@ -31,7 +51,8 @@ <script src="./reply_button.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; +@import "../../mixins"; .ReplyButton { display: flex; @@ -52,7 +73,18 @@ color: $fallback--cBlue; color: var(--cBlue, $fallback--cBlue); } - } + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + } + } } </style> diff --git a/src/components/report/report.js b/src/components/report/report.js new file mode 100644 index 00000000..76055764 --- /dev/null +++ b/src/components/report/report.js @@ -0,0 +1,34 @@ +import Select from '../select/select.vue' +import StatusContent from '../status_content/status_content.vue' +import Timeago from '../timeago/timeago.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const Report = { + props: [ + 'reportId' + ], + components: { + Select, + StatusContent, + Timeago + }, + computed: { + report () { + return this.$store.state.reports.reports[this.reportId] || {} + }, + state: { + get: function () { return this.report.state }, + set: function (val) { this.setReportState(val) } + } + }, + methods: { + generateUserProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + setReportState (state) { + return this.$store.dispatch('setReportState', { id: this.report.id, state }) + } + } +} + +export default Report diff --git a/src/components/report/report.scss b/src/components/report/report.scss new file mode 100644 index 00000000..9762400b --- /dev/null +++ b/src/components/report/report.scss @@ -0,0 +1,43 @@ +@import "../../variables"; + +.Report { + .report-content { + margin: 0.5em 0 1em; + } + + .report-state { + margin: 0.5em 0 1em; + } + + .reported-status { + border: 1px solid $fallback--faint; + border-color: var(--faint, $fallback--faint); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); + color: $fallback--text; + color: var(--text, $fallback--text); + display: block; + padding: 0.5em; + margin: 0.5em 0; + + .status-content { + pointer-events: none; + } + + .reported-status-heading { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 0.2em; + } + + .reported-status-name { + font-weight: bold; + } + } + + .note { + width: 100%; + margin-bottom: 0.5em; + } +} diff --git a/src/components/report/report.vue b/src/components/report/report.vue new file mode 100644 index 00000000..1f19cc25 --- /dev/null +++ b/src/components/report/report.vue @@ -0,0 +1,74 @@ +<template> + <div class="Report"> + <div class="reported-user"> + <span>{{ $t('report.reported_user') }}</span> + <router-link :to="generateUserProfileLink(report.acct)"> + @{{ report.acct.screen_name }} + </router-link> + </div> + <div class="reporter"> + <span>{{ $t('report.reporter') }}</span> + <router-link :to="generateUserProfileLink(report.actor)"> + @{{ report.actor.screen_name }} + </router-link> + </div> + <div class="report-state"> + <span>{{ $t('report.state') }}</span> + <Select + :id="report-state" + v-model="state" + class="form-control" + > + <option + v-for="state in ['open', 'closed', 'resolved']" + :key="state" + :value="state" + > + {{ $t('report.state_' + state) }} + </option> + </Select> + </div> + <RichContent + class="report-content" + :html="report.content" + :emoji="[]" + /> + <div v-if="report.statuses.length"> + <small>{{ $t('report.reported_statuses') }}</small> + <router-link + v-for="status in report.statuses" + :key="status.id" + :to="{ name: 'conversation', params: { id: status.id } }" + class="reported-status" + > + <div class="reported-status-heading"> + <span class="reported-status-name">{{ status.user.name }}</span> + <Timeago + :time="status.created_at" + :auto-update="240" + class="faint" + /> + </div> + <status-content :status="status" /> + </router-link> + </div> + <div v-if="report.notes.length"> + <small>{{ $t('report.notes') }}</small> + <div + v-for="note in report.notes" + :key="note.id" + class="note" + > + <span>{{ note.content }}</span> + <Timeago + :time="note.created_at" + :auto-update="240" + class="faint" + /> + </div> + </div> + </div> +</template> + +<script src="./report.js"></script> +<style src="./report.scss" lang="scss"></style> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index 2103fd0b..4d92b5fa 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -1,7 +1,17 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { faRetweet } from '@fortawesome/free-solid-svg-icons' +import { + faRetweet, + faPlus, + faMinus, + faCheck +} from '@fortawesome/free-solid-svg-icons' -library.add(faRetweet) +library.add( + faRetweet, + faPlus, + faMinus, + faCheck +) const RetweetButton = { props: ['status', 'loggedIn', 'visibility'], @@ -26,6 +36,9 @@ const RetweetButton = { computed: { mergedConfig () { return this.$store.getters.mergedConfig + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 5a15d387..7700ee0d 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -7,11 +7,31 @@ :title="$t('tool_tip.repeat')" @click.prevent="retweet()" > - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="retweet" - :spin="animated" - /> + <FALayers class="fa-old-padding-layer"> + <FAIcon + class="fa-scale-110" + icon="retweet" + :spin="animated" + /> + <FAIcon + v-if="status.repeated" + class="active-marker" + transform="shrink-6 up-9 right-12" + icon="check" + /> + <FAIcon + v-if="!status.repeated" + 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> <span v-else-if="loggedIn"> <FAIcon @@ -20,13 +40,19 @@ :title="$t('timeline.no_retweet_hint')" /> </span> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" icon="retweet" :title="$t('tool_tip.repeat')" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.repeat_num > 0" class="no-event" @@ -39,7 +65,8 @@ <script src="./retweet_button.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; +@import "../../mixins"; .RetweetButton { display: flex; @@ -64,6 +91,26 @@ color: $fallback--cGreen; color: var(--cGreen, $fallback--cGreen); } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } + } } } </style> diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index ca075270..7881e365 100644 --- a/src/components/rich_content/rich_content.jsx +++ b/src/components/rich_content/rich_content.jsx @@ -150,6 +150,7 @@ export default { if (Array.isArray(item)) { const [opener, children, closer] = item const Tag = getTagName(opener) + const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null /* During grouping of mentions we trim all the empty text elements @@ -171,7 +172,7 @@ export default { return ['', [mentionsLinePadding, renderImage(opener)], ''] case 'a': // replace mentions with MentionLink if (!this.handleLinks) break - if (attrs['class'] && attrs['class'].includes('mention')) { + if (fullAttrs.class && fullAttrs.class.includes('mention')) { // Handling mentions here return renderMention(attrs, children) } else { @@ -179,7 +180,7 @@ export default { break } case 'span': - if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) { + if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { return ['', children.map(processItem), ''] } } @@ -213,13 +214,14 @@ export default { const [opener, children] = item const Tag = opener === '' ? '' : getTagName(opener) switch (Tag) { - case 'a': // replace mentions with MentionLink + case 'a': { // replace mentions with MentionLink if (!this.handleLinks) break - const attrs = getAttrs(opener) + const fullAttrs = getAttrs(opener, () => true) + const attrs = getAttrs(opener, () => true) // should only be this if ( - (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style - (attrs['rel'] === 'tag') // Mastodon style + (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style + (fullAttrs.rel === 'tag') // Mastodon style ) { return renderHashtag(attrs, children, encounteredTextReverse) } else { @@ -230,6 +232,7 @@ export default { { newChildren } </a> } + } case '': return [...children].reverse().map(processItemReverse).reverse() } diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss index db08ef1e..e5d353ac 100644 --- a/src/components/rich_content/rich_content.scss +++ b/src/components/rich_content/rich_content.scss @@ -1,7 +1,11 @@ +@import "../../variables"; + .RichContent { blockquote { - margin: 0.2em 0 0.2em 2em; + margin: 0.2em 0 0.2em 0.2em; font-style: italic; + border-left: 0.2em solid var(--faint, $fallback--faint); + padding-left: 1em; } pre { @@ -17,11 +21,11 @@ } p { - margin: 0 0 1em 0; + margin: 0 0 1em; } p:last-child { - margin: 0 0 0 0; + margin: 0; } h1 { diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue index f3bee183..d6e7265b 100644 --- a/src/components/scope_selector/scope_selector.vue +++ b/src/components/scope_selector/scope_selector.vue @@ -64,10 +64,9 @@ <script src="./scope_selector.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .ScopeSelector { - .scope { display: inline-block; cursor: pointer; diff --git a/src/components/search/search.js b/src/components/search/search.js index 76ac30ef..877d6f30 100644 --- a/src/components/search/search.js +++ b/src/components/search/search.js @@ -8,6 +8,7 @@ import { faCircleNotch, faSearch } from '@fortawesome/free-solid-svg-icons' +import { uniqBy } from 'lodash' library.add( faCircleNotch, @@ -32,7 +33,11 @@ const Search = { userIds: [], statuses: [], hashtags: [], - currenResultTab: 'statuses' + currenResultTab: 'statuses', + + statusesOffset: 0, + lastStatusFetchCount: 0, + lastQuery: '' } }, computed: { @@ -61,26 +66,42 @@ const Search = { this.$router.push({ name: 'search', query: { query } }) this.$refs.searchInput.focus() }, - search (query) { + search (query, searchType = null) { if (!query) { this.loading = false return } this.loading = true - this.userIds = [] - this.statuses = [] - this.hashtags = [] this.$refs.searchInput.blur() + if (this.lastQuery !== query) { + this.userIds = [] + this.hashtags = [] + this.statuses = [] + + this.statusesOffset = 0 + this.lastStatusFetchCount = 0 + } - this.$store.dispatch('search', { q: query, resolve: true }) + this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType }) .then(data => { this.loading = false - this.userIds = map(data.accounts, 'id') - this.statuses = data.statuses - this.hashtags = data.hashtags + + const oldLength = this.statuses.length + + // Always append to old results. If new results are empty, this doesn't change anything + this.userIds = this.userIds.concat(map(data.accounts, 'id')) + this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id') + this.hashtags = this.hashtags.concat(data.hashtags) + this.currenResultTab = this.getActiveTab() this.loaded = true + + // Offset from whatever we already have + this.statusesOffset = this.statuses.length + // Because the amount of new statuses can actually be zero, compare to old lenght instead + this.lastStatusFetchCount = this.statuses.length - oldLength + this.lastQuery = query }) }, resultCount (tabName) { diff --git a/src/components/search/search.vue b/src/components/search/search.vue index b7bfc1f3..19b9c577 100644 --- a/src/components/search/search.vue +++ b/src/components/search/search.vue @@ -22,7 +22,7 @@ </button> </div> <div - v-if="loading" + v-if="loading && statusesOffset == 0" class="text-center loading-icon" > <FAIcon @@ -55,12 +55,6 @@ </div> <div class="panel-body"> <div v-if="currenResultTab === 'statuses'"> - <div - v-if="visibleStatuses.length === 0 && !loading && loaded" - class="search-result-heading" - > - <h4>{{ $t('search.no_results') }}</h4> - </div> <Status v-for="status in visibleStatuses" :key="status.id" @@ -71,6 +65,33 @@ :statusoid="status" :no-heading="false" /> + <button + v-if="!loading && loaded && lastStatusFetchCount > 0" + class="more-statuses-button button-unstyled -link -fullwidth" + @click.prevent="search(searchTerm, 'statuses')" + > + <div class="new-status-notification text-center"> + {{ $t('search.load_more') }} + </div> + </button> + <div + v-else-if="loading && statusesOffset > 0" + class="text-center loading-icon" + > + <FAIcon + icon="circle-notch" + spin + size="lg" + /> + </div> + <div + v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded" + class="search-result-heading" + > + <h4> + {{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }} + </h4> + </div> </div> <div v-else-if="currenResultTab === 'people'"> <div @@ -127,7 +148,7 @@ <script src="./search.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .search-result-heading { color: $fallback--faint; @@ -155,7 +176,7 @@ } .search-result-footer { - border-width: 1px 0 0 0; + border-width: 1px 0 0; border-style: solid; border-color: var(--border, $fallback--border); padding: 10px; @@ -210,4 +231,9 @@ } } +.more-statuses-button { + height: 3.5em; + line-height: 3.5em; +} + </style> diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue index 222f57ba..3969d8de 100644 --- a/src/components/search_bar/search_bar.vue +++ b/src/components/search_bar/search_bar.vue @@ -47,6 +47,8 @@ class="cancel-icon fa-scale-110 fa-old-padding" /> </button> + <span class="spacer" /> + <span class="spacer" /> </template> </div> </template> @@ -54,7 +56,7 @@ <script src="./search_bar.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .SearchBar { display: inline-flex; diff --git a/src/components/select/select.vue b/src/components/select/select.vue index 92493b0b..0ce4ea82 100644 --- a/src/components/select/select.vue +++ b/src/components/select/select.vue @@ -21,22 +21,20 @@ <script src="./select.js"> </script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; /* TODO fix order of styles */ label.Select { padding: 0; select { - -webkit-appearance: none; - -moz-appearance: none; appearance: none; background: transparent; border: none; color: $fallback--text; color: var(--inputText, --text, $fallback--text); margin: 0; - padding: 0 2em 0 .2em; + padding: 0 2em 0 0.2em; font-family: sans-serif; font-family: var(--inputFont, sans-serif); font-size: 1em; @@ -59,6 +57,5 @@ label.Select { z-index: 0; pointer-events: none; } - } </style> diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue index 1f7683ab..14910fc5 100644 --- a/src/components/selectable_list/selectable_list.vue +++ b/src/components/selectable_list/selectable_list.vue @@ -51,7 +51,7 @@ <script src="./selectable_list.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .selectable-list { &-item-inner { @@ -67,6 +67,7 @@ background-color: $fallback--lightBg; background-color: var(--selectedMenu, $fallback--lightBg); color: var(--selectedMenuText, $fallback--text); + --faint: var(--selectedMenuFaintText, $fallback--faint); --faintLink: var(--selectedMenuFaintLink, $fallback--faint); --lightText: var(--selectedMenuLightText, $fallback--lightText); diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js index 353e551c..2e6992cb 100644 --- a/src/components/settings_modal/helpers/boolean_setting.js +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -41,7 +41,16 @@ export default { }, methods: { update (e) { + const [firstSegment, ...rest] = this.path.split('.') set(this.$parent, this.path, e) + // Updating nested properties does not trigger update on its parent. + // probably still not as reliable, but works for depth=1 at least + if (rest.length > 0) { + set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) }) + } + }, + reset () { + set(this.$parent, this.path, this.defaultState) } } } diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index 69584808..41142966 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -15,7 +15,12 @@ <slot /> </span> {{ ' ' }} - <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ServerSideIndicator :server-side="isServerSide" /> + </Checkbox> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index 4677d4c1..3da559fe 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -43,6 +43,9 @@ export default { methods: { update (e) { set(this.$parent, this.path, e) + }, + reset () { + set(this.$parent, this.path, this.defaultState) } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 258c7422..8fdbb5d3 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -19,14 +19,12 @@ {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }} </option> </Select> - <ModifiedIndicator :changed="isChanged" /> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> <ServerSideIndicator :server-side="isServerSide" /> </label> </template> <script src="./choice_setting.js"></script> - -<style lang="scss"> -.ChoiceSetting { -} -</style> diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js index 17dc0e7b..e64d0cee 100644 --- a/src/components/settings_modal/helpers/integer_setting.js +++ b/src/components/settings_modal/helpers/integer_setting.js @@ -36,6 +36,9 @@ export default { methods: { update (e) { set(this.$parent, this.path, parseInt(e.target.value)) + }, + reset () { + set(this.$parent, this.path, this.defaultState) } } } diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue index e661a025..695e2673 100644 --- a/src/components/settings_modal/helpers/integer_setting.vue +++ b/src/components/settings_modal/helpers/integer_setting.vue @@ -17,7 +17,10 @@ @change="update" > {{ ' ' }} - <ModifiedIndicator :changed="isChanged" /> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> </span> </template> diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js new file mode 100644 index 00000000..58697412 --- /dev/null +++ b/src/components/settings_modal/helpers/size_setting.js @@ -0,0 +1,67 @@ +import { get, set } from 'lodash' +import ModifiedIndicator from './modified_indicator.vue' +import Select from 'src/components/select/select.vue' + +export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'] +export const defaultHorizontalUnits = ['px', 'rem', 'vw'] +export const defaultVerticalUnits = ['px', 'rem', 'vh'] + +export default { + components: { + ModifiedIndicator, + Select + }, + props: { + path: String, + disabled: Boolean, + min: Number, + units: { + type: [String], + default: () => allCssUnits + }, + expert: [Number, String] + }, + computed: { + pathDefault () { + const [firstSegment, ...rest] = this.path.split('.') + return [firstSegment + 'DefaultValue', ...rest].join('.') + }, + stateUnit () { + return (this.state || '').replace(/\d+/, '') + }, + stateValue () { + return (this.state || '').replace(/\D+/, '') + }, + state () { + const value = get(this.$parent, this.path) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + defaultState () { + return get(this.$parent, this.pathDefault) + }, + isChanged () { + return this.state !== this.defaultState + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$parent.expertLevel + } + }, + methods: { + update (e) { + set(this.$parent, this.path, e) + }, + reset () { + set(this.$parent, this.path, this.defaultState) + }, + updateValue (e) { + set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit) + }, + updateUnit (e) { + set(this.$parent, this.path, this.stateValue + e.target.value) + } + } +} diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue new file mode 100644 index 00000000..5a78f100 --- /dev/null +++ b/src/components/settings_modal/helpers/size_setting.vue @@ -0,0 +1,55 @@ +<template> + <span + v-if="matchesExpertLevel" + class="SizeSetting" + > + <label + :for="path" + class="size-label" + > + <slot /> + </label> + <input + :id="path" + class="number-input" + type="number" + step="1" + :disabled="disabled" + :min="min || 0" + :value="stateValue" + @change="updateValue" + > + <Select + :id="path" + :model-value="stateUnit" + :disabled="disabled" + class="css-unit-input" + @change="updateUnit" + > + <option + v-for="option in units" + :key="option" + :value="option" + > + {{ option }} + </option> + </Select> + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + </span> +</template> + +<script src="./size_setting.js"></script> + +<style lang="scss"> +.css-unit-input, +.css-unit-input select { + margin-left: 0.5em; + width: 4em; + max-width: 4em; + min-width: 4em; +} +</style> diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index 13cb0e65..f5861229 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -1,4 +1,5 @@ -@import 'src/_variables.scss'; +@import "src/variables"; + .settings-modal { overflow: hidden; @@ -6,32 +7,13 @@ .option-list { list-style-type: none; padding-left: 2em; + li { margin-bottom: 0.5em; } - .suboptions { - margin-top: 0.3em - } - } - - &.peek { - .settings-modal-panel { - /* Explanation: - * Modal is positioned vertically centered. - * 100vh - 100% = Distance between modal's top+bottom boundaries and screen - * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen - * + 100% - we move modal completely off-screen, it's top boundary touches - * bottom of the screen - * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible - */ - transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); - @media all and (max-width: 800px) { - /* For mobile, the modal takes 100% of the available screen. - This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible. - */ - transform: translateY(calc(100% - 50px)); - } + .suboptions { + margin-top: 0.3em; } } @@ -63,6 +45,7 @@ .settings-footer { display: flex; + >* { margin-right: 0.5em; } @@ -72,4 +55,26 @@ flex-grow: 1; } } + + &.peek { + .settings-modal-panel { + /* Explanation: + * Modal is positioned vertically centered. + * 100vh - 100% = Distance between modal's top+bottom boundaries and screen + * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen + * + 100% - we move modal completely off-screen, it's top boundary touches + * bottom of the screen + * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible + */ + transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); + + @media all and (max-width: 800px) { + /* For mobile, the modal takes 100% of the available screen. + This ensures the minimized modal is always 50px above the browser bottom + bar regardless of whether or not it is visible. + */ + transform: translateY(calc(100% - 50px)); + } + } + } } diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss index 81ab434b..87df7982 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_content.scss @@ -1,4 +1,5 @@ -@import 'src/_variables.scss'; +@import "src/variables"; + .settings_tab-switcher { height: 100%; @@ -10,7 +11,8 @@ > div, > label { display: block; - margin-bottom: .5em; + margin-bottom: 0.5em; + &:last-child { margin-bottom: 0; } @@ -21,7 +23,7 @@ .option-list { margin: 0; - padding-left: .5em; + padding-left: 0.5em; } } diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue index e3b7f407..48356c9b 100644 --- a/src/components/settings_modal/tabs/data_import_export_tab.vue +++ b/src/components/settings_modal/tabs/data_import_export_tab.vue @@ -78,6 +78,16 @@ {{ $t('settings.download_backup') }} </a> <span + v-else-if="backup.state === 'running'" + > + {{ $tc('settings.backup_running', backup.processed_number, { number: backup.processed_number }) }} + </span> + <span + v-else-if="backup.state === 'failed'" + > + {{ $t('settings.backup_failed') }} + </span> + <span v-else > {{ $t('settings.backup_not_ready') }} diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 73413b48..7c37f0bc 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -1,4 +1,4 @@ -import { filter, trim } from 'lodash' +import { filter, trim, debounce } from 'lodash' import BooleanSetting from '../helpers/boolean_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue' @@ -29,24 +29,20 @@ const FilteringTab = { }, set (value) { this.muteWordsStringLocal = value + this.debouncedSetMuteWords(value) + } + }, + debouncedSetMuteWords () { + return debounce((value) => { this.$store.dispatch('setOption', { name: 'muteWords', value: filter(value.split('\n'), (word) => trim(word).length > 0) }) - } + }, 1000) } }, // Updating nested properties watch: { - notificationVisibility: { - handler (value) { - this.$store.dispatch('setOption', { - name: 'notificationVisibility', - value: this.$store.getters.mergedConfig.notificationVisibility - }) - }, - deep: true - }, replyVisibility () { this.$store.dispatch('queueFlushAll') } diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index 1e11b9e0..ea24d6ad 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -2,6 +2,7 @@ import BooleanSetting from '../helpers/boolean_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue' import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' import IntegerSetting from '../helpers/integer_setting.vue' +import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' @@ -43,6 +44,11 @@ const GeneralTab = { value: mode, label: this.$t(`settings.third_column_mode_${mode}`) })), + userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.user_popover_avatar_action_${mode}`) + })), loopSilentAvailable: // Firefox Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || @@ -56,11 +62,15 @@ const GeneralTab = { BooleanSetting, ChoiceSetting, IntegerSetting, + SizeSetting, InterfaceLanguageSwitcher, ScopeSelector, ServerSideIndicator }, computed: { + horizontalUnits () { + return defaultHorizontalUnits + }, postFormats () { return this.$store.state.instance.postFormats || [] }, @@ -71,6 +81,17 @@ const GeneralTab = { label: this.$t(`post_status.content_type["${format}"]`) })) }, + columns () { + const mode = this.$store.getters.mergedConfig.thirdColumnMode + + const notif = mode === 'none' ? [] : ['notifs'] + + if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') { + return [...notif, 'content', 'sidebar'] + } else { + return ['sidebar', 'content', ...notif] + } + }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceWallpaperUsed () { return this.$store.state.instance.background && diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index a2609200..582cb288 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -15,11 +15,6 @@ {{ $t('settings.hide_isp') }} </BooleanSetting> </li> - <li> - <BooleanSetting path="sidebarRight"> - {{ $t('settings.right_sidebar') }} - </BooleanSetting> - </li> <li v-if="instanceWallpaperUsed"> <BooleanSetting path="hideInstanceWallpaper"> {{ $t('settings.hide_wallpaper') }} @@ -65,22 +60,14 @@ </BooleanSetting> </li> <li> - <BooleanSetting path="disableStickyHeaders"> - {{ $t('settings.disable_sticky_headers') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="showScrollbars"> - {{ $t('settings.show_scrollbars') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting - path="userPopoverZoom" + <ChoiceSetting + id="userPopoverAvatarAction" + path="userPopoverAvatarAction" + :options="userPopoverAvatarActionOptions" expert="1" > - {{ $t('settings.user_popover_avatar_zoom') }} - </BooleanSetting> + {{ $t('settings.user_popover_avatar_action') }} + </ChoiceSetting> </li> <li> <BooleanSetting @@ -91,16 +78,6 @@ </BooleanSetting> </li> <li> - <ChoiceSetting - v-if="user" - id="thirdColumnMode" - path="thirdColumnMode" - :options="thirdColumnModeOptions" - > - {{ $t('settings.third_column_mode') }} - </ChoiceSetting> - </li> - <li> <BooleanSetting path="alwaysShowNewPostButton" expert="1" @@ -124,6 +101,53 @@ {{ $t('settings.hide_shoutbox') }} </BooleanSetting> </li> + <li> + <h3>{{ $t('settings.columns') }}</h3> + </li> + <li> + <BooleanSetting path="disableStickyHeaders"> + {{ $t('settings.disable_sticky_headers') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="showScrollbars"> + {{ $t('settings.show_scrollbars') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="sidebarRight"> + {{ $t('settings.right_sidebar') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="navbarColumnStretch"> + {{ $t('settings.navbar_column_stretch') }} + </BooleanSetting> + </li> + <li> + <ChoiceSetting + v-if="user" + id="thirdColumnMode" + path="thirdColumnMode" + :options="thirdColumnModeOptions" + > + {{ $t('settings.third_column_mode') }} + </ChoiceSetting> + </li> + <li v-if="expertLevel > 0"> + {{ $t('settings.column_sizes') }} + <div class="column-settings"> + <SizeSetting + v-for="column in columns" + :key="column" + :path="column + 'ColumnWidth'" + :units="horizontalUnits" + expert="1" + > + {{ $t('settings.column_sizes_' + column) }} + </SizeSetting> + </div> + </li> </ul> </div> <div class="setting-item"> @@ -433,3 +457,17 @@ </template> <script src="./general_tab.js"></script> + +<style lang="scss"> +.column-settings { + display: flex; + justify-content: space-evenly; + flex-wrap: wrap; +} + +.column-settings .size-label { + display: block; + margin-bottom: 0.5em; + margin-top: 0.5em; +} +</style> diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss index 2adff847..5fa3a27b 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss @@ -1,29 +1,29 @@ .mutes-and-blocks-tab { - height: 100%; + height: 100%; - .usersearch-wrapper { - padding: 1em; - } + .usersearch-wrapper { + padding: 1em; + } - .bulk-actions { - text-align: right; - padding: 0 1em; - min-height: 2em; - } + .bulk-actions { + text-align: right; + padding: 0 1em; + min-height: 2em; + } - .bulk-action-button { - width: 10em - } + .bulk-action-button { + width: 10em; + } - .domain-mute-form { - padding: 1em; - display: flex; - flex-direction: column - } + .domain-mute-form { + padding: 1em; + display: flex; + flex-direction: column; + } - .domain-mute-button { - align-self: flex-end; - margin-top: 1em; - width: 10em - } + .domain-mute-button { + align-self: flex-end; + margin-top: 1em; + width: 10em; + } } diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue index c515d542..ed4b15a4 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue @@ -56,7 +56,7 @@ <div :label="$t('settings.mutes_tab')"> <tab-switcher> - <div label="Users"> + <div :label="$t('settings.user_mutes')"> <div class="usersearch-wrapper"> <Autosuggest :filter="filterUnMutedUsers" diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index af7206a7..df100162 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -66,7 +66,7 @@ const ProfileTab = { emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store @@ -75,7 +75,7 @@ const ProfileTab = { emojiSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ] }) @@ -157,7 +157,7 @@ const ProfileTab = { return false }, deleteField (index, event) { - this.$delete(this.newFields, index) + this.newFields.splice(index, 1) }, uploadFile (slot, e) { const file = e.target.files[0] diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index 2303f5e8..ee253ffe 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -1,4 +1,5 @@ -@import '../../../_variables.scss'; +@import "../../../variables"; + .profile-tab { .bio { margin: 0; @@ -8,7 +9,7 @@ padding-top: 5px; } - input[type=file] { + input[type="file"] { padding: 5px; height: auto; } @@ -52,7 +53,7 @@ right: 0.2em; border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - background-color: rgba(0, 0, 0, 0.6); + background-color: rgb(0 0 0 / 60%); opacity: 0.7; width: 1.5em; height: 1.5em; diff --git a/src/components/settings_modal/tabs/security_tab/mfa.vue b/src/components/settings_modal/tabs/security_tab/mfa.vue index 455d17b6..ee5b6b13 100644 --- a/src/components/settings_modal/tabs/security_tab/mfa.vue +++ b/src/components/settings_modal/tabs/security_tab/mfa.vue @@ -137,9 +137,11 @@ <script src="./mfa.js"></script> <style lang="scss"> -@import '../../../../_variables.scss'; +@import "../../../../variables"; + .mfa-settings { - .mfa-heading, .method-item { + .mfa-heading, + .method-item { display: flex; flex-wrap: wrap; justify-content: space-between; @@ -155,18 +157,19 @@ display: flex; justify-content: center; flex-wrap: wrap; + .qr-code { flex: 1; padding-right: 10px; } .verify { flex: 1; } - .error { margin: 4px 0 0 0; } + .error { margin: 4px 0 0; } + .confirm-otp-actions { button { width: 15em; margin-top: 5px; } - } } } diff --git a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue index d7e98b3c..923161b2 100644 --- a/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue +++ b/src/components/settings_modal/tabs/security_tab/mfa_backup_codes.vue @@ -21,13 +21,14 @@ </template> <script src="./mfa_backup_codes.js"></script> <style lang="scss"> -@import '../../../../_variables.scss'; +@import "../../../../variables"; .mfa-backup-codes { .warning { color: $fallback--cOrange; color: var(--cOrange, $fallback--cOrange); } + .backup-codes { font-family: var(--postCodeFont, monospace); } diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index c74a0c67..6e03bef4 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -241,7 +241,7 @@ class="btn button-default" @click="confirmDelete" > - {{ $t('settings.save') }} + {{ $t('settings.delete_account') }} </button> </div> </div> diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue index ba6bd529..d755279a 100644 --- a/src/components/settings_modal/tabs/theme_tab/preview.vue +++ b/src/components/settings_modal/tabs/theme_tab/preview.vue @@ -33,10 +33,10 @@ scope="global" keypath="settings.style.preview.text" > - <code style="font-family: var(--postCodeFont)"> + <code style="font-family: var(--postCodeFont);"> {{ $t('settings.style.preview.mono') }} </code> - <a style="color: var(--link)"> + <a style="color: var(--link);"> {{ $t('settings.style.preview.link') }} </a> </i18n-t> @@ -44,25 +44,25 @@ <div class="icons"> <FAIcon fixed-width - style="color: var(--cBlue)" + style="color: var(--cBlue);" class="fa-scale-110 fa-old-padding" icon="reply" /> <FAIcon fixed-width - style="color: var(--cGreen)" + style="color: var(--cGreen);" class="fa-scale-110 fa-old-padding" icon="retweet" /> <FAIcon fixed-width - style="color: var(--cOrange)" + style="color: var(--cOrange);" class="fa-scale-110 fa-old-padding" icon="star" /> <FAIcon fixed-width - style="color: var(--cRed)" + style="color: var(--cRed);" class="fa-scale-110 fa-old-padding" icon="times" /> @@ -81,7 +81,7 @@ class="faint" scope="global" > - <a style="color: var(--faintLink)"> + <a style="color: var(--faintLink);"> {{ $t('settings.style.preview.faint_link') }} </a> </i18n-t> @@ -138,6 +138,7 @@ export default {} .preview-container { position: relative; } + .underlay-preview { position: absolute; top: 0; diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 282cb384..4a739f73 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -279,6 +279,9 @@ export default { opacity ) + // Temporary patch for null-y value errors + if (layers.flat().some(v => v == null)) return acc + return { ...acc, ...textColors.reduce((acc, textColorKey) => { @@ -300,6 +303,7 @@ export default { return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {}) } catch (e) { console.warn('Failure computing contrasts', e) + return {} } }, previewRules () { diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss index bad6f51b..9935c2e7 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss @@ -1,20 +1,17 @@ -@import 'src/_variables.scss'; +@import "src/variables"; + .theme-tab { padding-bottom: 2em; - .theme-warning { - display: flex; - align-items: baseline; - margin-bottom: .5em; - .buttons { - .btn { - margin-bottom: .5em; - } - } - } + .preset-switcher { margin-right: 1em; } + .btn { + margin-left: 0.25em; + margin-right: 0.25em; + } + .style-control { display: flex; align-items: baseline; @@ -24,35 +21,37 @@ flex: 1; } - &.disabled { - input, select { - opacity: .5 - } - } - .opt { - margin: .5em; + margin: 0.5em; } .color-input { flex: 0 0 0; } - input, select { + input, + select { min-width: 3em; margin: 0; flex: 0; - &[type=number] { + &[type="number"] { min-width: 5em; } - &[type=range] { + &[type="range"] { flex: 1; min-width: 3em; align-self: flex-start; } } + + &.disabled { + input, + select { + opacity: 0.5; + } + } } .reset-container { @@ -63,8 +62,7 @@ .reset-container, .apply-container, .radius-container, - .color-container, - { + .color-container, { display: flex; } @@ -73,10 +71,11 @@ flex-direction: column; } - .color-container{ + .color-container { > h4 { width: 99%; } + flex-wrap: wrap; justify-content: space-between; } @@ -100,7 +99,7 @@ p { flex: 1; margin: 0; - margin-right: .5em; + margin-right: 0.5em; } } @@ -112,15 +111,16 @@ min-width: 1px; flex: 0 auto; padding: 0 1em; - margin-bottom: .5em; + margin-bottom: 0.5em; } } .shadow-selector { .override { flex: 1; - margin-left: .5em; + margin-left: 0.5em; } + .select-container { margin-top: -4px; margin-bottom: -3px; @@ -136,7 +136,7 @@ .presets, .import-export { - margin-bottom: .5em; + margin-bottom: 0.5em; } .import-export { @@ -144,16 +144,17 @@ } .override { - margin-left: .5em; + margin-left: 0.5em; } } .save-load-options { flex-wrap: wrap; - margin-top: .5em; + margin-top: 0.5em; justify-content: center; + .keep-option { - margin: 0 .5em .5em; + margin: 0 0.5em 0.5em; min-width: 25%; } } @@ -179,11 +180,11 @@ flex: 1; h4 { - margin-bottom: .25em; + margin-bottom: 0.25em; } .icons { - margin-top: .5em; + margin-top: 0.5em; display: flex; i { @@ -199,8 +200,20 @@ align-items: center; } - .avatar, .avatar-alt{ - background: linear-gradient(135deg, #b8e1fc 0%,#a9d2f3 10%,#90bae4 25%,#90bcea 37%,#90bff0 50%,#6ba8e5 51%,#a2daf5 83%,#bdf3fd 100%); + .avatar, + .avatar-alt { + background: + linear-gradient( + 135deg, + #b8e1fc 0%, + #a9d2f3 10%, + #90bae4 25%, + #90bcea 37%, + #90bff0 50%, + #6ba8e5 51%, + #a2daf5 83%, + #bdf3fd 100% + ); color: black; font-family: sans-serif; text-align: center; @@ -251,33 +264,33 @@ } } + .radius-item { + flex-basis: auto; + } + .radius-item, .color-item { min-width: 20em; margin: 5px 6px 0 0; - display:flex; + display: flex; flex-direction: column; flex: 1 1 0; &.wide { - min-width: 60% + min-width: 60%; } &:not(.wide):nth-child(2n+1) { margin-right: 7px; - } - .color, .opacity { - display:flex; + .color, + .opacity { + display: flex; align-items: baseline; } } - .radius-item { - flex-basis: auto; - } - .theme-radius-rn, .theme-color-cl { border: 0; @@ -295,14 +308,11 @@ .theme-radius-in { min-width: 1em; - } - - .theme-radius-in { max-width: 7em; flex: 1; } - .theme-radius-lb{ + .theme-radius-lb { max-width: 50em; } @@ -310,9 +320,16 @@ padding: 20px; } - .btn { - margin-left: .25em; - margin-right: .25em; + .theme-warning { + display: flex; + align-items: baseline; + margin-bottom: 0.5em; + + .buttons { + .btn { + margin-bottom: 0.5em; + } + } } } @@ -323,6 +340,7 @@ justify-content: space-around; flex-grow: 1; + /* stylelint-disable-next-line no-descending-specificity */ .btn { flex-grow: 1; min-height: 2em; diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 669cac71..7546535d 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -218,7 +218,8 @@ <script src="./shadow_control.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; + .shadow-control { display: flex; flex-wrap: wrap; @@ -229,6 +230,7 @@ .shadow-tweak { margin: 5px 6px 0 0; } + .shadow-preview-container { flex: 0; display: flex; @@ -236,19 +238,19 @@ $side: 15em; - input[type=number] { + input[type="number"] { width: 5em; min-width: 2em; } + .x-shift-control, .y-shift-control { display: flex; flex: 0; - &[disabled=disabled] *{ - opacity: .5 + &[disabled="disabled"] * { + opacity: 0.5; } - } .x-shift-control { @@ -256,37 +258,40 @@ } .x-shift-control .wrap, - input[type=range] { + input[type="range"] { margin: 0; width: $side; height: 2em; } + .y-shift-control { flex-direction: column; align-items: flex-end; + .wrap { width: 2em; height: $side; } - input[type=range] { + + input[type="range"] { transform-origin: 1em 1em; transform: rotate(90deg); } } + .preview-window { flex: 1; - background-color: #999999; + background-color: #999; display: flex; align-items: center; justify-content: center; background-image: - linear-gradient(45deg, #666666 25%, transparent 25%), - linear-gradient(-45deg, #666666 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, #666666 75%), - linear-gradient(-45deg, transparent 75%, #666666 75%); + linear-gradient(45deg, #666 25%, transparent 25%), + linear-gradient(-45deg, #666 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #666 75%), + linear-gradient(-45deg, transparent 75%, #666 75%); background-size: 20px 20px; - background-position:0 0, 0 10px, 10px -10px, -10px 0; - + background-position: 0 0, 0 10px, 10px -10px, -10px 0; border-radius: $fallback--inputRadius; border-radius: var(--inputRadius, $fallback--inputRadius); @@ -312,14 +317,15 @@ flex: 1; } - .shadow-switcher, .btn { + .shadow-switcher, + .btn { min-width: 1px; margin-right: 5px; } .btn { - padding: 0 .4em; - margin: 0 .1em; + padding: 0 0.4em; + margin: 0 0.1em; } } } diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue index 688c2d61..a7013469 100644 --- a/src/components/shout_panel/shout_panel.vue +++ b/src/components/shout_panel/shout_panel.vue @@ -75,7 +75,7 @@ <script src="./shout_panel.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .floating-shout { position: fixed; diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index f45f8def..27019577 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -2,6 +2,7 @@ import { mapState, mapGetters } from 'vuex' import UserCard from '../user_card/user_card.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' +import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faSignInAlt, @@ -14,7 +15,9 @@ import { faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faCompass, + faList } from '@fortawesome/free-solid-svg-icons' library.add( @@ -28,7 +31,9 @@ library.add( faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faCompass, + faList ) const SideDrawer = { @@ -78,15 +83,22 @@ const SideDrawer = { return this.$store.state.instance.federating }, timelinesRoute () { + let name if (this.$store.state.interface.lastTimeline) { - return this.$store.state.interface.lastTimeline + name = this.$store.state.interface.lastTimeline + } + name = this.currentUser ? 'friends' : 'public-timeline' + if (USERNAME_ROUTES.has(name)) { + return { name, params: { username: this.currentUser.screen_name } } + } else { + return { name } } - return this.currentUser ? 'friends' : 'public-timeline' }, ...mapState({ - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, + supportsAnnouncements: state => state.announcements.supportsAnnouncements }), - ...mapGetters(['unreadChatCount']) + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) }, methods: { toggleDrawer () { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 7547fb08..994ac953 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -47,7 +47,7 @@ v-if="currentUser || !privateMode" @click="toggleDrawer" > - <router-link :to="{ name: timelinesRoute }"> + <router-link :to="timelinesRoute"> <FAIcon fixed-width class="fa-scale-110 fa-old-padding" @@ -56,12 +56,24 @@ </router-link> </li> <li + v-if="currentUser" + @click="toggleDrawer" + > + <router-link :to="{ name: 'lists' }"> + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="list" + /> {{ $t("nav.lists") }} + </router-link> + </li> + <li v-if="currentUser && pleromaChatMessagesAvailable" @click="toggleDrawer" > <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }" - style="position: relative" + style="position: relative;" > <FAIcon fixed-width @@ -180,6 +192,38 @@ </a> </li> <li + v-if="currentUser && supportsAnnouncements" + @click="toggleDrawer" + > + <router-link + :to="{ name: 'announcements' }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="bullhorn" + /> {{ $t("nav.announcements") }} + <span + v-if="unreadAnnouncementCount" + class="badge badge-notification" + > + {{ unreadAnnouncementCount }} + </span> + </router-link> + </li> + <li + v-if="currentUser" + @click="toggleDrawer" + > + <router-link :to="{ name: 'edit-navigation' }"> + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="compass" + /> {{ $t("nav.edit_nav_mobile") }} + </router-link> + </li> + <li v-if="currentUser" @click="toggleDrawer" > @@ -207,7 +251,7 @@ <script src="./side_drawer.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .side-drawer-container { position: fixed; @@ -240,11 +284,11 @@ z-index: -1; transition: 0.35s; transition-property: background-color; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgb(0 0 0 / 50%); } .side-drawer-darken-closed { - background-color: rgba(0, 0, 0, 0); + background-color: rgb(0 0 0 / 0%); } .side-drawer-click-outside { @@ -253,20 +297,21 @@ .side-drawer { overflow-x: hidden; - transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition: 0.35s; + transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition-property: transform; margin: 0 0 0 -100px; padding: 0 0 1em 100px; width: 80%; max-width: 20em; flex: 0 0 80%; - box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: 1px 1px 4px rgb(0 0 0 / 60%); box-shadow: var(--panelShadow); 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); @@ -316,7 +361,6 @@ list-style: none; margin: 0; padding: 0; - border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); @@ -329,7 +373,8 @@ .side-drawer li { padding: 0; - a, button { + a, + button { box-sizing: border-box; display: block; height: 3em; @@ -341,6 +386,7 @@ background-color: var(--selectedMenuPopover, $fallback--lightBg); color: $fallback--text; color: var(--selectedMenuPopoverText, $fallback--text); + --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index a7fbc718..46a92ac7 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -13,7 +13,7 @@ const StaffPanel = { }, computed: { groupedStaffAccounts () { - const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _) + const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _) const groupedStaffAccounts = groupBy(staffAccounts, 'role') return [ @@ -22,7 +22,7 @@ const StaffPanel = { ].filter(group => group.users) }, ...mapGetters([ - 'findUser' + 'findUserByName' ]), ...mapState({ staffAccounts: state => state.instance.staffAccounts diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue index 6b9e61f2..77155e8b 100644 --- a/src/components/staff_panel/staff_panel.vue +++ b/src/components/staff_panel/staff_panel.vue @@ -27,7 +27,6 @@ <script src="./staff_panel.js"></script> <style lang="scss"> - .staff-group { padding-left: 1em; padding-top: 1em; diff --git a/src/components/status/status.js b/src/components/status/status.js index 384063a7..9a9bca7a 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -13,6 +13,7 @@ import StatusPopover from '../status_popover/status_popover.vue' import UserPopover from '../user_popover/user_popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' +import UserLink from '../user_link/user_link.vue' import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import MentionLink from 'src/components/mention_link/mention_link.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -115,7 +116,8 @@ const Status = { RichContent, MentionLink, MentionsLine, - UserPopover + UserPopover, + UserLink }, props: [ 'statusoid', @@ -393,6 +395,12 @@ const Status = { }, visibilityLocalized () { return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) + }, + isEdited () { + return this.status.edited_at !== null + }, + editingAvailable () { + return this.$store.state.instance.editingAvailable } }, methods: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index b3ad3818..44812867 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .Status { min-width: 0; @@ -156,7 +156,8 @@ margin-right: 0.2em; } - & .heading-reply-row { + & .heading-reply-row, + & .heading-edited-row { position: relative; align-content: baseline; font-size: 0.85em; @@ -180,7 +181,7 @@ .reply-to-popover { .reply-to:hover::before { - content: ''; + content: ""; display: block; position: absolute; bottom: 0; @@ -196,7 +197,7 @@ &.-strikethrough { .reply-to::after { - content: ''; + content: ""; display: block; position: absolute; top: 50%; @@ -335,7 +336,7 @@ margin-left: 0.2em; &::before { - content: ' '; + content: " "; } } @@ -373,7 +374,7 @@ align-items: center; &::before { - content: ''; + content: ""; position: absolute; height: 100%; width: 1px; diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 967a966c..35b15362 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -25,9 +25,10 @@ class="fa-scale-110 fa-old-padding repeat-icon" icon="retweet" /> - <router-link :to="userProfileLink"> - {{ status.user.screen_name_ui }} - </router-link> + <user-link + :user="status.user" + :at="false" + /> </small> <small v-if="showReasonMutedThread" @@ -83,7 +84,7 @@ :user="statusoid.user" /> <div class="right-side faint"> - <span + <bdi class="status-username repeater-name" :title="retweeter" > @@ -100,7 +101,7 @@ v-else :to="retweeterProfileLink" >{{ retweeter }}</router-link> - </span> + </bdi> {{ ' ' }} <FAIcon icon="retweet" @@ -164,13 +165,12 @@ > {{ status.user.name }} </h4> - <router-link + <user-link class="account-name" :title="status.user.screen_name_ui" - :to="userProfileLink" - > - {{ status.user.screen_name_ui }} - </router-link> + :user="status.user" + :at="false" + /> <img v-if="!!(status.user && status.user.favicon)" class="status-favicon" @@ -261,7 +261,7 @@ v-if="!isPreview" :status-id="status.parent_visible && status.in_reply_to_status_id" class="reply-to-popover" - style="min-width: 0" + style="min-width: 0;" :class="{ '-strikethrough': !status.parent_visible }" > <button @@ -327,6 +327,24 @@ class="mentions-line" /> </div> + <div + v-if="isEdited && editingAvailable && !isPreview" + class="heading-edited-row" + > + <i18n-t + keypath="status.edited_at" + tag="span" + > + <template #time> + <Timeago + template-key="time.in_past" + :time="status.edited_at" + :auto-update="60" + :long-format="true" + /> + </template> + </i18n-t> + </div> </div> <StatusContent diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss index 039d4c7f..8fd60afc 100644 --- a/src/components/status_body/status_body.scss +++ b/src/components/status_body/status_body.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .StatusBody { display: flex; @@ -40,7 +40,7 @@ .summary-wrapper { margin-bottom: 0.5em; border-style: solid; - border-width: 0 0 1px 0; + border-width: 0 0 1px; border-color: var(--border, $fallback--border); flex-grow: 0; @@ -58,8 +58,7 @@ .text-wrapper { display: flex; - flex-direction: column; - flex-wrap: nowrap; + flex-flow: column nowrap; &.-tall-status { position: relative; @@ -75,7 +74,7 @@ linear-gradient(to top, white, white); /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; } } @@ -144,7 +143,7 @@ mask-image: linear-gradient(to bottom, white 2em, transparent 3em); /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; } @@ -158,7 +157,7 @@ .summary-wrapper { .summary::after { - content: ': '; + content: ": "; } line-height: inherit; diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index e2120f7a..c0e5c0b9 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -33,6 +33,7 @@ <gallery v-if="status.attachments.length !== 0" class="attachments media-body" + :compact="compact" :nsfw="nsfwClickthrough" :attachments="status.attachments" :limit="compact ? 1 : 0" diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js new file mode 100644 index 00000000..3941a56f --- /dev/null +++ b/src/components/status_history_modal/status_history_modal.js @@ -0,0 +1,60 @@ +import { get } from 'lodash' +import Modal from '../modal/modal.vue' +import Status from '../status/status.vue' + +const StatusHistoryModal = { + components: { + Modal, + Status + }, + data () { + return { + statuses: [] + } + }, + computed: { + modalActivated () { + return this.$store.state.statusHistory.modalActivated + }, + params () { + return this.$store.state.statusHistory.params + }, + statusId () { + return this.params.id + }, + historyCount () { + return this.statuses.length + }, + history () { + return this.statuses + } + }, + watch: { + params (newVal, oldVal) { + const newStatusId = get(newVal, 'id') !== get(oldVal, 'id') + if (newStatusId) { + this.resetHistory() + } + + if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) { + this.fetchStatusHistory() + } + } + }, + methods: { + resetHistory () { + this.statuses = [] + }, + fetchStatusHistory () { + this.$store.dispatch('fetchStatusHistory', this.params) + .then(data => { + this.statuses = data + }) + }, + closeModal () { + this.$store.dispatch('closeStatusHistoryModal') + } + } +} + +export default StatusHistoryModal diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue new file mode 100644 index 00000000..79642e9c --- /dev/null +++ b/src/components/status_history_modal/status_history_modal.vue @@ -0,0 +1,47 @@ +<template> + <Modal + v-if="modalActivated" + class="status-history-modal-view" + @backdropClicked="closeModal" + > + <div class="status-history-modal-panel panel"> + <div class="panel-heading"> + {{ $t('status.status_history') }} ({{ historyCount }}) + </div> + <div class="panel-body"> + <div + v-if="historyCount > 0" + class="history-body" + > + <status + v-for="status in history" + :key="status.id" + :statusoid="status" + :is-preview="true" + class="conversation-status status-fadein panel-body" + /> + </div> + </div> + </div> + </Modal> +</template> + +<script src="./status_history_modal.js"></script> + +<style lang="scss"> +.modal-view.status-history-modal-view { + align-items: flex-start; +} + +.status-history-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } +} +</style> diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index f4ab357b..311ca099 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -40,14 +40,13 @@ <script src="./status_popover.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; /* popover styles load on-demand, so we need to override */ .status-popover.popover { font-size: 1rem; min-width: 15em; max-width: 95%; - border-color: $fallback--border; border-color: var(--border, $fallback--border); border-style: solid; diff --git a/src/components/sticker_picker/sticker_picker.vue b/src/components/sticker_picker/sticker_picker.vue index dc449ccb..904853c0 100644 --- a/src/components/sticker_picker/sticker_picker.vue +++ b/src/components/sticker_picker/sticker_picker.vue @@ -32,24 +32,29 @@ <script src="./sticker_picker.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .sticker-picker { width: 100%; + .contents { min-height: 250px; + .sticker-picker-content { display: flex; flex-wrap: wrap; padding: 0 4px; + .sticker { display: flex; flex: 1 1 auto; margin: 4px; width: 56px; height: 56px; + img { height: 100%; + &:hover { filter: drop-shadow(0 0 5px var(--accent, $fallback--link)); } diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index d7abbcb5..56fd2fd9 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -7,16 +7,24 @@ const StillImage = { 'imageLoadHandler', 'alt', 'height', - 'width' + 'width', + 'dataSrc', + 'loading' ], data () { return { + // for lazy loading, see loadLazy() + realSrc: this.src, stopGifs: this.$store.getters.mergedConfig.stopGifs } }, computed: { animated () { - return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) + if (!this.realSrc) { + return false + } + + return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif')) }, style () { const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str @@ -27,7 +35,15 @@ const StillImage = { } }, methods: { + loadLazy () { + if (this.dataSrc) { + this.realSrc = this.dataSrc + } + }, onLoad () { + if (!this.realSrc) { + return + } const image = this.$refs.src if (!image) return this.imageLoadHandler && this.imageLoadHandler(image) @@ -42,6 +58,14 @@ const StillImage = { onError () { this.imageLoadError && this.imageLoadError() } + }, + watch: { + src () { + this.realSrc = this.src + }, + dataSrc () { + this.$el.removeAttribute('data-loaded') + } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index ab3080c8..fc46fbe6 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -11,11 +11,13 @@ <!-- NOTE: key is required to force to re-render img tag when src is changed --> <img ref="src" - :key="src" + :key="realSrc" :alt="alt" :title="alt" - :src="src" + :data-src="dataSrc" + :src="realSrc" :referrerpolicy="referrerpolicy" + :loading="loading" @load="onLoad" @error="onError" > @@ -26,7 +28,7 @@ <script src="./still-image.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .still-image { position: relative; @@ -56,13 +58,13 @@ &.animated { &::before { zoom: var(--_still_image-label-scale, 1); - content: 'gif'; + content: "gif"; position: absolute; line-height: 1; font-size: 0.7em; top: 0.5em; left: 0.5em; - background: rgba(127, 127, 127, 0.5); + background: rgb(127 127 127 / 50%); color: #fff; display: block; padding: 2px 4px; diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js index 238e6df8..2a423901 100644 --- a/src/components/swipe_click/swipe_click.js +++ b/src/components/swipe_click/swipe_click.js @@ -5,6 +5,8 @@ import GestureService from '../../services/gesture_service/gesture_service' * direction: a vector that indicates the direction of the intended swipe * threshold: the minimum distance in pixels the swipe has moved on `direction' * for swipe-finished() to have a non-zero sign + * disableClickThreshold: the minimum distance in pixels for the swipe to + * not trigger a click * perpendicularTolerance: see gesture_service * * Events: @@ -34,6 +36,10 @@ const SwipeClick = { type: Function, default: () => 30 }, + disableClickThreshold: { + type: Function, + default: () => 1 + }, perpendicularTolerance: { type: Number, default: 1.0 @@ -72,6 +78,7 @@ const SwipeClick = { this.$gesture = new GestureService.SwipeAndClickGesture({ direction: this.direction, threshold: this.threshold, + disableClickThreshold: this.disableClickThreshold, perpendicularTolerance: this.perpendicularTolerance, swipePreviewCallback: this.preview, swipeEndCallback: this.end, diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index 7a086b26..705925c8 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -1,5 +1,6 @@ -@import '../../_variables.scss'; +@import "../../variables"; +/* stylelint-disable no-descending-specificity */ .tab-switcher { display: flex; @@ -17,9 +18,11 @@ overflow-x: auto; padding-top: 5px; flex-direction: row; + flex: 0 0 auto; - &::after, &::before { - content: ''; + &::after, + &::before { + content: ""; flex: 1 1 auto; border-bottom: 1px solid; border-bottom-color: $fallback--border; @@ -38,6 +41,7 @@ border-bottom-color: var(--border, $fallback--border); } } + .tab { width: 100%; min-width: 1px; @@ -47,6 +51,7 @@ margin-bottom: 6px - 99px; } } + .contents.scrollable-tabs { flex-basis: 0; } @@ -69,10 +74,11 @@ overflow-x: hidden; flex-direction: column; - &::after, &::before { + &::after, + &::before { flex-shrink: 0; - flex-basis: .5em; - content: ''; + flex-basis: 0.5em; + content: ""; border-right: 1px solid; border-right-color: $fallback--border; border-right-color: var(--border, $fallback--border); @@ -106,7 +112,7 @@ &::before { flex: 0 0 6px; - content: ''; + content: ""; border-right: 1px solid; border-right-color: $fallback--border; border-right-color: var(--border, $fallback--border); @@ -130,12 +136,13 @@ margin-left: 1em; @media all and (max-width: 800px) { - padding-left: .25em; - padding-right: calc(.25em + 200px); - margin-right: calc(.25em - 200px); - margin-left: .25em; + padding-left: 0.25em; + padding-right: calc(0.25em + 200px); + margin-right: calc(0.25em - 200px); + margin-left: 0.25em; + .text { - display: none + display: none; } } } @@ -144,15 +151,17 @@ .contents { flex: 1 0 auto; - min-height: 0px; + min-height: 0; .hidden { display: none; } + .full-height:not(.hidden) { height: 100%; display: flex; flex-direction: column; + > *:not(.mobile-label) { flex: 1; } @@ -195,7 +204,8 @@ position: relative; box-sizing: border-box; - &::after, &::before { + &::after, + &::before { display: block; flex: 1 1 auto; } @@ -208,7 +218,7 @@ &:not(.active) { &::after { - content: ''; + content: ""; position: absolute; z-index: 7; } @@ -216,11 +226,11 @@ } .mobile-label { - padding-left: .3em; - padding-bottom: .25em; - margin-top: .5em; - margin-left: .2em; - margin-bottom: .25em; + padding-left: 0.3em; + padding-bottom: 0.25em; + margin-top: 0.5em; + margin-left: 0.2em; + margin-bottom: 0.25em; border-bottom: 1px solid var(--border, $fallback--border); @media all and (min-width: 800px) { @@ -228,3 +238,4 @@ } } } +/* stylelint-enable no-descending-specificity */ diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.vue b/src/components/terms_of_service_panel/terms_of_service_panel.vue index 1df41d70..bff0ae74 100644 --- a/src/components/terms_of_service_panel/terms_of_service_panel.vue +++ b/src/components/terms_of_service_panel/terms_of_service_panel.vue @@ -17,6 +17,6 @@ <style lang="scss"> .tos-content { - margin: 1em + margin: 1em; } </style> diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index 4eaf597d..04727278 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -1,5 +1,5 @@ <template> - <div class="thread-tree"> + <article class="thread-tree"> <status :key="status.id" ref="statusComponent" @@ -113,13 +113,14 @@ </template> </i18n-t> </div> - </div> + </article> </template> <script src="./thread_tree.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; + .thread-tree-replies { margin-left: var(--status-margin, $status-margin); border-left: 2px solid var(--border, $fallback--border); @@ -127,6 +128,7 @@ .thread-tree-replies-hidden { padding: var(--status-margin, $status-margin); + /* Make the button stretch along the whole row */ display: flex; align-items: stretch; diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue index 2b487dfd..b5f49515 100644 --- a/src/components/timeago/timeago.vue +++ b/src/components/timeago/timeago.vue @@ -3,7 +3,7 @@ :datetime="time" :title="localeDateString" > - {{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }} + {{ relativeTimeString }} </time> </template> @@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js' export default { name: 'Timeago', - props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], + props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'], data () { return { relativeTime: { key: 'time.now', num: 0 }, @@ -26,6 +26,23 @@ export default { return typeof this.time === 'string' ? new Date(Date.parse(this.time)).toLocaleString(browserLocale) : this.time.toLocaleString(browserLocale) + }, + relativeTimeString () { + const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num]) + + if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') { + return this.$i18n.t(this.templateKey, [timeString]) + } + + return timeString + } + }, + watch: { + time (newVal, oldVal) { + if (oldVal !== newVal) { + clearTimeout(this.interval) + this.refreshRelativeTimeObject() + } } }, created () { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index c575e876..b7414610 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,15 +1,21 @@ import Status from '../status/status.vue' +import { mapState } from 'vuex' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import Conversation from '../conversation/conversation.vue' import TimelineMenu from '../timeline_menu/timeline_menu.vue' -import TimelineQuickSettings from './timeline_quick_settings.vue' +import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' +import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' import { debounce, throttle, keyBy } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' -import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons' +import { faCircleNotch, faCirclePlus, faCog, faMinus, faArrowUp, faCheck } from '@fortawesome/free-solid-svg-icons' library.add( faCircleNotch, - faCog + faCog, + faMinus, + faArrowUp, + faCirclePlus, + faCheck ) const Timeline = { @@ -18,6 +24,7 @@ const Timeline = { 'timelineName', 'title', 'userId', + 'listId', 'tag', 'embedded', 'count', @@ -27,6 +34,7 @@ const Timeline = { ], data () { return { + showScrollTop: false, paused: false, unfocused: false, bottomedOut: false, @@ -38,7 +46,8 @@ const Timeline = { Status, Conversation, TimelineMenu, - TimelineQuickSettings + QuickFilterSettings, + QuickViewSettings }, computed: { filteredVisibleStatuses () { @@ -60,6 +69,13 @@ const Timeline = { return `${this.$t('timeline.show_new')} (${this.newStatusCount})` } }, + mobileLoadButtonString () { + if (this.timeline.flushMarker !== 0) { + return '+' + } else { + return this.newStatusCount > 99 ? '∞' : this.newStatusCount + } + }, classes () { let rootClasses = !this.embedded ? ['panel', 'panel-default'] : ['-nonpanel'] if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention']) @@ -84,7 +100,10 @@ const Timeline = { }, virtualScrollingEnabled () { return this.$store.getters.mergedConfig.virtualScrolling - } + }, + ...mapState({ + mobileLayout: state => state.interface.layoutType === 'mobile' + }) }, created () { const store = this.$store @@ -101,6 +120,7 @@ const Timeline = { timeline: this.timelineName, showImmediately, userId: this.userId, + listId: this.listId, tag: this.tag }) }, @@ -119,6 +139,9 @@ const Timeline = { this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { + scrollToTop () { + window.scrollTo({ top: this.$el.offsetTop }) + }, stopBlockingClicks: debounce(function () { this.blockingClicks = false }, 1000), @@ -156,6 +179,7 @@ const Timeline = { older: true, showImmediately: true, userId: this.userId, + listId: this.listId, tag: this.tag }).then(({ statuses }) => { if (statuses && statuses.length === 0) { @@ -217,6 +241,7 @@ const Timeline = { } }, handleScroll: throttle(function (e) { + this.showScrollTop = this.$el.offsetTop < window.scrollY this.determineVisibleStatuses() this.scrollLoad(e) }, 200), diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss index 9e009fd3..4371947d 100644 --- a/src/components/timeline/timeline.scss +++ b/src/components/timeline/timeline.scss @@ -1,8 +1,35 @@ -@import '../../_variables.scss'; +@import "../../variables"; .Timeline { - .loadmore-text { - opacity: 1; + .alert-dot { + border-radius: 100%; + height: 8px; + width: 8px; + position: absolute; + left: calc(50% - 4px); + top: calc(50% - 4px); + margin-left: 6px; + margin-top: -6px; + background-color: var(--badgeNeutral); + } + + .alert-badge { + font-size: 0.75em; + line-height: 1; + text-align: right; + border-radius: var(--tooltipRadius); + position: absolute; + left: calc(50% - 0.5em); + top: calc(50% - 0.4em); + padding: 0.2em; + margin-left: 0.7em; + margin-top: -1em; + background-color: var(--badgeNeutral); + color: var(--badgeNeutralText); + } + + .loadmore-button { + position: relative; } &.-blocked { @@ -19,10 +46,9 @@ text-align: center; line-height: 2.75em; padding: 0 0.5em; - } - .timeline-heading { - .button-default, .alert { + .button-default, + .alert { line-height: 2em; width: 100%; } diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 266c1d9a..2279f21a 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -1,31 +1,96 @@ <template> <div :class="['Timeline', classes.root]"> <div :class="classes.header"> - <TimelineMenu v-if="!embedded" /> - <button - v-if="showLoadButton" - class="button-default loadmore-button" - @click.prevent="showNewStatuses" - > - {{ loadButtonString }} - </button> + <TimelineMenu + v-if="!embedded" + :timeline-name="timelineName" + /> <div - v-else-if="!embedded" - class="loadmore-text faint" - @click.prevent + v-if="showScrollTop && !embedded" + class="rightside-button" > - {{ $t('timeline.up_to_date') }} + <button + class="button-unstyled scroll-to-top-button" + type="button" + :title="$t('general.scroll_to_top')" + @click="scrollToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> </div> - <TimelineQuickSettings v-if="!embedded" /> + <template v-if="mobileLayout && !embedded"> + <div + v-if="showLoadButton" + class="rightside-button" + > + <button + class="button-unstyled loadmore-button" + :title="loadButtonString" + @click.prevent="showNewStatuses" + > + <FAIcon + fixed-width + icon="circle-plus" + /> + <div class="alert-badge"> + {{ mobileLoadButtonString }} + </div> + </button> + </div> + <div + v-else-if="!embedded" + class="loadmore-text faint veryfaint rightside-icon" + :title="$t('timeline.up_to_date')" + :aria-disabled="true" + @click.prevent + > + <FAIcon + fixed-width + icon="check" + /> + </div> + </template> + <template v-else> + <button + v-if="showLoadButton" + class="button-default loadmore-button" + @click.prevent="showNewStatuses" + > + {{ loadButtonString }} + </button> + <div + v-else-if="!embedded" + class="loadmore-text faint" + @click.prevent + > + {{ $t('timeline.up_to_date') }} + </div> + </template> + <QuickFilterSettings + v-if="!embedded" + class="rightside-button" + /> + <QuickViewSettings + v-if="!embedded" + class="rightside-button" + /> </div> <div :class="classes.body"> <div ref="timeline" class="timeline" + role="feed" > <conversation v-for="statusId in filteredPinnedStatusIds" :key="statusId + '-pinned'" + role="listitem" class="status-fadein" :status-id="statusId" :collapsable="true" @@ -36,6 +101,7 @@ <conversation v-for="status in filteredVisibleStatuses" :key="status.id" + role="listitem" class="status-fadein" :status-id="status.id" :collapsable="true" diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index a11e7b7e..5a2a86c2 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -1,6 +1,10 @@ import Popover from '../popover/popover.vue' -import TimelineMenuContent from './timeline_menu_content.vue' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import { mapState } from 'vuex' +import { ListsMenuContent } from '../lists_menu/lists_menu_content.vue' import { library } from '@fortawesome/fontawesome-svg-core' +import { TIMELINES } from 'src/components/navigation/navigation.js' +import { filterNavigation } from 'src/components/navigation/filter.js' import { faChevronDown } from '@fortawesome/free-solid-svg-icons' @@ -22,7 +26,8 @@ export const timelineNames = () => { const TimelineMenu = { components: { Popover, - TimelineMenuContent + NavigationEntry, + ListsMenuContent }, data () { return { @@ -34,6 +39,28 @@ const TimelineMenu = { this.$store.dispatch('setLastTimeline', this.$route.name) } }, + computed: { + useListsMenu () { + const route = this.$route.name + return route === 'lists-timeline' + }, + ...mapState({ + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }), + timelinesList () { + return filterNavigation( + Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + } + }, methods: { openMenu () { // $nextTick is too fast, animation won't play back but @@ -58,6 +85,9 @@ const TimelineMenu = { if (route === 'tag-timeline') { return '#' + this.$route.params.tag } + if (route === 'lists-timeline') { + return this.$store.getters.findListTitle(this.$route.params.id) + } const i18nkey = timelineNames()[this.$route.name] return i18nkey ? this.$t(i18nkey) : route } diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index c24b9d72..5f1da1f7 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -10,7 +10,19 @@ @close="() => isOpen = false" > <template #content> - <TimelineMenuContent /> + <ListsMenuContent + v-if="useListsMenu" + :show-pin="false" + class="timelines" + /> + <ul v-else> + <NavigationEntry + v-for="item in timelinesList" + :key="item.name" + :show-pin="false" + :item="item" + /> + </ul> </template> <template #trigger> <span class="button-unstyled title timeline-menu-title"> @@ -33,56 +45,7 @@ <script src="./timeline_menu.js"></script> <style lang="scss"> -@import '../../_variables.scss'; - -.TimelineMenu { - margin-right: auto; - min-width: 0; - - .popover-trigger-button { - vertical-align: bottom; - } - - .panel::after { - border-top-right-radius: 0; - border-top-left-radius: 0; - } - - .timeline-menu-title { - margin: 0; - cursor: pointer; - user-select: none; - width: 100%; - display: flex; - - .timeline-menu-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - svg { - margin-left: 0.6em; - transition: transform 100ms; - } - - .click-blocker { - cursor: default; - flex-grow: 1; - } - } - - &.open .timeline-menu-title svg { - color: $fallback--text; - color: var(--panelText, $fallback--text); - transform: rotate(180deg); - } - - .panel { - box-shadow: var(--popoverShadow); - } - -} +@import "../../variables"; .timeline-menu-popover { min-width: 24rem; @@ -98,24 +61,6 @@ padding: 0; } - li { - border-bottom: 1px solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - padding: 0; - - &:last-child a { - border-bottom-right-radius: $fallback--panelRadius; - border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius); - border-bottom-left-radius: $fallback--panelRadius; - border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius); - } - - &:last-child { - border: none; - } - } - a { display: block; padding: 0 0.65em; @@ -127,6 +72,7 @@ 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); @@ -139,6 +85,7 @@ background-color: var(--selectedMenu, $fallback--lightBg); color: $fallback--text; color: var(--selectedMenuText, $fallback--text); + --faint: var(--selectedMenuFaintText, $fallback--faint); --faintLink: var(--selectedMenuFaintLink, $fallback--faint); --lightText: var(--selectedMenuLightText, $fallback--lightText); @@ -154,6 +101,71 @@ margin-left: -0.2em; } } + + li { + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + padding: 0; + + &:last-child a { + border-bottom-right-radius: $fallback--panelRadius; + border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius); + border-bottom-left-radius: $fallback--panelRadius; + border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius); + } + + &:last-child { + border: none; + } + } } +.TimelineMenu { + margin-right: auto; + min-width: 0; + + .popover-trigger-button { + vertical-align: bottom; + } + + .panel::after { + border-top-right-radius: 0; + border-top-left-radius: 0; + } + + .timeline-menu-title { + margin: 0; + cursor: pointer; + user-select: none; + width: 100%; + display: flex; + + .timeline-menu-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + svg { + margin-left: 0.6em; + transition: transform 100ms; + } + + .click-blocker { + cursor: default; + flex-grow: 1; + } + } + + &.open .timeline-menu-title svg { + color: $fallback--text; + color: var(--panelText, $fallback--text); + transform: rotate(180deg); + } + + .panel { + box-shadow: var(--popoverShadow); + } +} </style> diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js deleted file mode 100644 index 671570dd..00000000 --- a/src/components/timeline_menu/timeline_menu_content.js +++ /dev/null @@ -1,29 +0,0 @@ -import { mapState } from 'vuex' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome -) - -const TimelineMenuContent = { - computed: { - ...mapState({ - currentUser: state => state.users.currentUser, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) - } -} - -export default TimelineMenuContent diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue deleted file mode 100644 index 59e9e43c..00000000 --- a/src/components/timeline_menu/timeline_menu_content.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> - <ul> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'friends' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="home" - />{{ $t("nav.home_timeline") }} - </router-link> - </li> - <li v-if="currentUser || !privateMode"> - <router-link - class="menu-item" - :to="{ name: 'public-timeline' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="users" - />{{ $t("nav.public_tl") }} - </router-link> - </li> - <li v-if="federating && (currentUser || !privateMode)"> - <router-link - class="menu-item" - :to="{ name: 'public-external-timeline' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="globe" - />{{ $t("nav.twkn") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'bookmarks'}" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="bookmark" - />{{ $t("nav.bookmarks") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'dms', params: { username: currentUser.screen_name } }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="envelope" - />{{ $t("nav.dms") }} - </router-link> - </li> - </ul> -</template> - -<script src="./timeline_menu_content.js"></script> diff --git a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue new file mode 100644 index 00000000..8f35245f --- /dev/null +++ b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue @@ -0,0 +1,26 @@ +<template> + <FAIcon + v-if="user && user.screen_name_ui_contains_non_ascii" + icon="code" + :title="$t('unicode_domain_indicator.tooltip')" + /> +</template> + +<script> +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCode +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCode +) + +const UnicodeDomainIndicator = { + props: { + user: Object + } +} + +export default UnicodeDomainIndicator +</script> diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js new file mode 100644 index 00000000..ddf379f5 --- /dev/null +++ b/src/components/update_notification/update_notification.js @@ -0,0 +1,69 @@ +import Modal from 'src/components/modal/modal.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import pleromaTan from 'src/assets/pleromatan_apology.png' +import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png' +import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png' +import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png' + +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' +library.add( + faTimes +) + +export const CURRENT_UPDATE_COUNTER = 1 + +const UpdateNotification = { + data () { + return { + showingImage: false, + pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox, + showingMore: false + } + }, + components: { + Modal + }, + computed: { + pleromaTanStyles () { + const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask + return { + 'shape-outside': 'url(' + mask + ')' + } + }, + shouldShow () { + return !this.$store.state.instance.disableUpdateNotification && + this.$store.state.users.currentUser && + this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER && + !this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs + } + }, + methods: { + toggleShow () { + this.showingMore = !this.showingMore + }, + neverShowAgain () { + this.toggleShow() + this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER }) + this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true }) + this.$store.dispatch('pushServerSideStorage') + }, + dismiss () { + this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER }) + this.$store.dispatch('pushServerSideStorage') + } + }, + mounted () { + this.contentHeightNoImage = this.$refs.animatedText.scrollHeight + + // Workaround to get the text height only after mask loaded. A bit hacky. + const newImg = new Image() + newImg.onload = () => { + setTimeout(() => { this.showingImage = true }, 100) + } + newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask + } +} + +export default UpdateNotification diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss new file mode 100644 index 00000000..4337acc4 --- /dev/null +++ b/src/components/update_notification/update_notification.scss @@ -0,0 +1,115 @@ +@import "src/variables"; + +.UpdateNotification { + overflow: hidden; +} + +.UpdateNotificationModal { + --__top-fringe: 15em; // how much pleroma-tan should stick her head above + --__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant + --__right-fringe: 8em; + + font-size: 15px; + position: relative; + transition: transform; + transition-timing-function: ease-in-out; + transition-duration: 500ms; + + .text { + max-width: 40em; + padding-left: 1em; + } + + @media all and (max-width: 800px) { + /* For mobile, the modal takes 100% of the available screen. + This ensures the minimized modal is always 50px above the browser + bottom bar regardless of whether or not it is visible. + */ + width: 100vw; + } + + @media all and (max-height: 600px) { + display: none; + } + + .content { + overflow: hidden; + margin-top: calc(-1 * var(--__top-fringe)); + margin-bottom: calc(-1 * var(--__bottom-fringe)); + margin-right: calc(-1 * var(--__right-fringe)); + + &.-noImage { + .text { + padding-right: var(--__right-fringe); + } + } + } + + .panel-body { + border-width: 0 0 1px; + border-style: solid; + border-color: var(--border, $fallback--border); + } + + .panel-footer { + z-index: 22; + position: relative; + border-width: 0; + grid-template-columns: auto; + } + + .pleroma-tan { + object-fit: cover; + object-position: top; + transition: position, left, right, top, bottom, max-width, max-height; + transition-timing-function: ease-in-out; + transition-duration: 500ms; + width: 25em; + float: right; + z-index: 20; + position: relative; + shape-margin: 0.5em; + filter: drop-shadow(5px 5px 10px rgb(0 0 0 / 50%)); + pointer-events: none; + } + + .spacer-top { + min-height: var(--__top-fringe); + } + + .spacer-bottom { + min-height: var(--__bottom-fringe); + } + + .extra-info-group { + transition: max-height, padding, height; + transition-timing-function: ease-in; + transition-duration: 700ms; + max-height: 70vh; + mask: + linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat, + linear-gradient(to top, white, white); + } + + .art-credit { + text-align: right; + } + + &.-peek { + /* Explanation: + * 100vh - 100% = Distance between modal's top+bottom boundaries and screen + * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen + */ + transform: translateY(calc(((100vh - 100%) / 2))); + + .pleroma-tan { + float: right; + z-index: 10; + shape-image-threshold: 70%; + } + + .extra-info-group { + max-height: 0; + } + } +} diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue new file mode 100644 index 00000000..78e70a74 --- /dev/null +++ b/src/components/update_notification/update_notification.vue @@ -0,0 +1,103 @@ +<template> + <Modal + :is-open="!!shouldShow" + class="UpdateNotification" + :no-background="true" + > + <div + class="UpdateNotificationModal panel" + :class="{ '-peek': !showingMore }" + > + <div class="panel-heading"> + <span class="title"> + {{ $t('update.big_update_title') }} + </span> + </div> + <div class="panel-body"> + <div + class="content" + :class="{ '-noImage': !showingImage }" + > + <img + v-if="showingImage" + class="pleroma-tan" + :src="pleromaTanVariant" + :style="pleromaTanStyles" + > + <div class="spacer-top" /> + <div class="text"> + <p> + {{ $t('update.big_update_content') }} + </p> + <div + ref="animatedText" + class="extra-info-group" + > + <i18n-t + keypath="update.update_bugs" + tag="p" + > + <template #pleromaGitlab> + <a + target="_blank" + href="https://git.pleroma.social/" + >{{ $t('update.update_bugs_gitlab') }}</a> + </template> + </i18n-t> + <i18n-t + keypath="update.update_changelog" + tag="p" + > + <template #theFullChangelog> + <a + target="_blank" + href="https://pleroma.social/announcements/" + >{{ $t('update.update_changelog_here') }}</a> + </template> + </i18n-t> + <p class="art-credit"> + <i18n-t + keypath="update.art_by" + tag="small" + > + <template #linkToArtist> + <a + target="_blank" + href="https://post.ebin.club/users/pipivovott" + >pipivovott</a> + </template> + </i18n-t> + </p> + </div> + </div> + <div class="spacer-bottom" /> + </div> + </div> + <div class="panel-footer"> + <button + class="button-default" + @click.prevent="neverShowAgain" + > + {{ $t("general.never_show_again") }} + </button> + <button + v-if="!showingMore" + class="button-default" + @click.prevent="toggleShow" + > + {{ $t("general.show_more") }} + </button> + <button + class="button-default" + @click.prevent="dismiss" + > + {{ $t("general.dismiss") }} + </button> + </div> + </div> + </Modal> +</template> + +<script src="./update_notification.js"></script> + +<style src="./update_notification.scss" lang="scss"></style> diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index f4d294df..91c17611 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -27,7 +27,7 @@ <script src="./user_avatar.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .Avatar { --_avatarShadowBox: var(--avatarStatusShadow); @@ -85,10 +85,9 @@ right: 0; margin: -0.2em; padding: 0.2em; - background: rgba(127, 127, 127, 0.5); + background: rgb(127 127 127 / 50%); color: #fff; border-radius: var(--tooltipRadius); } - } </style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 4c81e2c7..67879307 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -4,7 +4,9 @@ import ProgressButton from '../progress_button/progress_button.vue' import FollowButton from '../follow_button/follow_button.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue' import AccountActions from '../account_actions/account_actions.vue' +import UserNote from '../user_note/user_note.vue' import Select from '../select/select.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' @@ -38,7 +40,8 @@ export default { 'rounded', 'bordered', 'avatarAction', // default - open profile, 'zoom' - zoom, function - call function - 'onClose' + 'onClose', + 'hasNoteEditor' ], data () { return { @@ -124,6 +127,16 @@ export default { hideFollowersCount () { return this.isOtherUser && this.user.hide_followers_count }, + showModerationMenu () { + const privileges = this.loggedIn.privileges + return this.loggedIn.role === 'admin' || privileges.includes('users_manage_activation_state') || privileges.includes('users_delete') || privileges.includes('users_manage_tags') + }, + hasNote () { + return this.relationship.note + }, + supportsNote () { + return 'note' in this.relationship + }, ...mapGetters(['mergedConfig']) }, components: { @@ -134,7 +147,9 @@ export default { ProgressButton, FollowButton, Select, - RichContent + RichContent, + UserLink, + UserNote }, methods: { muteUser () { diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss index a0bbc6a6..d56b6672 100644 --- a/src/components/user_card/user_card.scss +++ b/src/components/user_card/user_card.scss @@ -1,4 +1,4 @@ -@import '../../_variables.scss'; +@import "../../variables"; .user-card { position: relative; @@ -11,7 +11,7 @@ } .panel-heading { - padding: .5em 0; + padding: 0.5em 0; text-align: center; box-shadow: none; background: transparent; @@ -35,10 +35,11 @@ left: 0; right: 0; bottom: 0; - mask: linear-gradient(to top, white, transparent) bottom no-repeat, - linear-gradient(to top, white, white); + mask: + linear-gradient(to top, white, transparent) bottom no-repeat, + linear-gradient(to top, white, white); // Autoprefixer seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; + mask-composite: xor; mask-composite: exclude; background-size: cover; mask-size: 100% 60%; @@ -159,17 +160,17 @@ top: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.3); + background-color: rgb(0 0 0 / 30%); display: flex; justify-content: center; align-items: center; border-radius: $fallback--avatarRadius; border-radius: var(--avatarRadius, $fallback--avatarRadius); opacity: 0; - transition: opacity .2s ease; + transition: opacity 0.2s ease; svg { - color: #FFF; + color: #fff; } } @@ -178,7 +179,8 @@ } } - .external-link-button, .edit-profile-button { + .external-link-button, + .edit-profile-button { cursor: pointer; width: 2.5em; text-align: center; @@ -191,34 +193,6 @@ } } - .user-summary { - display: block; - margin-left: 0.6em; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 1 0; - // This is so that text doesn't get overlapped by avatar's shadow if it has - // big one - z-index: 1; - line-height: 2em; - - --emoji-size: 1.7em; - - .top-line, - .bottom-line { - display: flex; - } - } - - .user-name { - text-overflow: ellipsis; - overflow: hidden; - flex: 1 1 auto; - margin-right: 1em; - font-size: 1.1em; - } - .bottom-line { font-weight: light; font-size: 1.1em; @@ -253,8 +227,36 @@ } } + .user-summary { + display: block; + margin-left: 0.6em; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 0; + // This is so that text doesn't get overlapped by avatar's shadow if it has + // big one + z-index: 1; + line-height: 2em; + + --emoji-size: 1.7em; + + .top-line, + .bottom-line { + display: flex; + } + } + + .user-name { + text-overflow: ellipsis; + overflow: hidden; + flex: 1 1 auto; + margin-right: 1em; + font-size: 1.1em; + } + .user-meta { - margin-bottom: .15em; + margin-bottom: 0.15em; display: flex; align-items: baseline; line-height: 22px; @@ -263,7 +265,7 @@ .following { flex: 1 0 auto; margin: 0; - margin-bottom: .25em; + margin-bottom: 0.25em; text-align: left; } @@ -271,7 +273,7 @@ flex: 0 1 auto; display: flex; flex-wrap: wrap; - margin-right: -.5em; + margin-right: -0.5em; align-self: start; .userHighlightCl { @@ -294,19 +296,20 @@ .userHighlightText, .userHighlightSel { vertical-align: top; - margin-right: .5em; - margin-bottom: .25em; + margin-right: 0.5em; + margin-bottom: 0.25em; } } } + .user-interactions { position: relative; display: flex; flex-flow: row wrap; - margin-right: -.75em; + margin-right: -0.75em; > * { - margin: 0 .75em .6em 0; + margin: 0 0.75em 0.6em 0; white-space: nowrap; min-width: 95px; } @@ -315,6 +318,10 @@ margin: 0; } } + + .user-note { + margin: 0 0.75em 0.6em 0; + } } .sidebar .edit-profile-button { @@ -323,8 +330,8 @@ .user-counts { display: flex; - line-height:16px; - padding: .5em 1.5em 0em 1.5em; + line-height: 16px; + padding: 0.5em 1.5em 0; text-align: center; justify-content: space-between; color: $fallback--lightText; @@ -334,14 +341,16 @@ .user-count { flex: 1 0 auto; - padding: .5em 0 .5em 0; - margin: 0 .5em; + padding: 0.5em 0; + margin: 0 0.5em; h5 { - font-size:1em; + font-size: 1em; font-weight: bolder; margin: 0 0 0.25em; } + + /* stylelint-disable-next-line no-descending-specificity */ a { text-decoration: none; } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index ace89c51..349c7cb1 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -106,13 +106,10 @@ </button> </div> <div class="bottom-line"> - <router-link + <user-link class="user-screen-name" - :title="user.screen_name_ui" - :to="userProfileLink(user)" - > - @{{ user.screen_name_ui }} - </router-link> + :user="user" + /> <template v-if="!hideBio"> <span v-if="user.deactivated" @@ -261,7 +258,7 @@ </button> </div> <ModerationTools - v-if="loggedIn.role === "admin"" + v-if="showModerationMenu" :user="user" /> </div> @@ -271,6 +268,12 @@ > <RemoteFollow :user="user" /> </div> + <UserNote + v-if="loggedIn && isOtherUser && (hasNote || (hasNoteEditor && supportsNote))" + :user="user" + :relationship="relationship" + :editable="hasNoteEditor" + /> </div> </div> <div diff --git a/src/components/user_link/user_link.vue b/src/components/user_link/user_link.vue new file mode 100644 index 00000000..efd96e12 --- /dev/null +++ b/src/components/user_link/user_link.vue @@ -0,0 +1,38 @@ +<template> + <router-link + :title="user.screen_name_ui" + :to="userProfileLink(user)" + > + {{ at ? '@' : '' }}{{ user.screen_name_ui }}<UnicodeDomainIndicator + :user="user" + /> + </router-link> +</template> + +<script> +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const UserLink = { + props: { + user: Object, + at: { + type: Boolean, + default: true + } + }, + components: { + UnicodeDomainIndicator + }, + methods: { + userProfileLink (user) { + return generateProfileLink( + user.id, user.screen_name, + this.$store.state.instance.restrictedNicknames + ) + } + } +} + +export default UserLink +</script> diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js new file mode 100644 index 00000000..21996031 --- /dev/null +++ b/src/components/user_list_menu/user_list_menu.js @@ -0,0 +1,93 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronRight } from '@fortawesome/free-solid-svg-icons' +import { mapState } from 'vuex' + +import DialogModal from '../dialog_modal/dialog_modal.vue' +import Popover from '../popover/popover.vue' + +library.add(faChevronRight) + +const UserListMenu = { + props: [ + 'user' + ], + data () { + return {} + }, + components: { + DialogModal, + Popover + }, + created () { + this.$store.dispatch('fetchUserInLists', this.user.id) + }, + computed: { + ...mapState({ + allLists: state => state.lists.allLists + }), + inListsSet () { + return new Set(this.user.inLists.map(x => x.id)) + }, + lists () { + if (!this.user.inLists) return [] + return this.allLists.map(list => ({ + ...list, + inList: this.inListsSet.has(list.id) + })) + } + }, + methods: { + toggleList (listId) { + if (this.inListsSet.has(listId)) { + this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId }).then((response) => { + if (!response.ok) { return } + this.$store.dispatch('fetchUserInLists', this.user.id) + }) + } else { + this.$store.dispatch('addListAccount', { accountId: this.user.id, listId }).then((response) => { + if (!response.ok) { return } + this.$store.dispatch('fetchUserInLists', this.user.id) + }) + } + }, + toggleRight (right) { + const store = this.$store + if (this.user.rights[right]) { + store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => { + if (!response.ok) { return } + store.commit('updateRight', { user: this.user, right, value: false }) + }) + } else { + store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => { + if (!response.ok) { return } + store.commit('updateRight', { user: this.user, right, value: true }) + }) + } + }, + toggleActivationStatus () { + this.$store.dispatch('toggleActivationStatus', { user: this.user }) + }, + deleteUserDialog (show) { + this.showDeleteUserDialog = show + }, + deleteUser () { + const store = this.$store + const user = this.user + const { id, name } = user + store.state.api.backendInteractor.deleteUser({ user }) + .then(e => { + this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id) + const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile' + const isTargetUser = this.$route.params.name === name || this.$route.params.id === id + if (isProfile && isTargetUser) { + window.history.back() + } + }) + }, + setToggled (value) { + this.toggled = value + } + } +} + +export default UserListMenu diff --git a/src/components/user_list_menu/user_list_menu.vue b/src/components/user_list_menu/user_list_menu.vue new file mode 100644 index 00000000..06947ab7 --- /dev/null +++ b/src/components/user_list_menu/user_list_menu.vue @@ -0,0 +1,38 @@ +<template> + <div class="UserListMenu"> + <Popover + trigger="hover" + placement="left" + remove-padding + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="list in lists" + :key="list.id" + class="button-default dropdown-item" + @click="toggleList(list.id)" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': list.inList }" + /> + {{ list.title }} + </button> + </div> + </template> + <template #trigger> + <button class="btn button-default dropdown-item -has-submenu"> + {{ $t('lists.manage_lists') }} + <FAIcon + class="chevron-icon" + size="lg" + icon="chevron-right" + /> + </button> + </template> + </Popover> + </div> +</template> + +<script src="./user_list_menu.js"></script> diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js index e24eb9f7..046e0abd 100644 --- a/src/components/user_list_popover/user_list_popover.js +++ b/src/components/user_list_popover/user_list_popover.js @@ -1,5 +1,6 @@ import { defineAsyncComponent } from 'vue' import RichContent from 'src/components/rich_content/rich_content.jsx' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' @@ -15,6 +16,7 @@ const UserListPopover = { ], components: { RichContent, + UnicodeDomainIndicator, Popover: defineAsyncComponent(() => import('../popover/popover.vue')), UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue')) }, diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue index a3ce54c3..8307cc8a 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -29,7 +29,7 @@ :emoji="user.emoji" /> <!-- eslint-enable vue/no-v-html --> - <span class="user-list-screen-name">{{ user.screen_name_ui }}</span> + <span class="user-list-screen-name">{{ user.screen_name_ui }}</span><UnicodeDomainIndicator :user="user" /> </div> </div> </template> @@ -48,7 +48,7 @@ <script src="./user_list_popover.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .user-list-popover { padding: 0.5em; diff --git a/src/components/user_note/user_note.js b/src/components/user_note/user_note.js new file mode 100644 index 00000000..830b2e59 --- /dev/null +++ b/src/components/user_note/user_note.js @@ -0,0 +1,45 @@ +const UserNote = { + props: { + user: Object, + relationship: Object, + editable: Boolean + }, + data () { + return { + localNote: '', + editing: false, + frozen: false + } + }, + computed: { + shouldShow () { + return this.relationship.note || this.editing + } + }, + methods: { + startEditing () { + this.localNote = this.relationship.note + this.editing = true + }, + cancelEditing () { + this.editing = false + }, + finalizeEditing () { + this.frozen = true + + this.$store.dispatch('editUserNote', { + id: this.user.id, + comment: this.localNote + }) + .then(() => { + this.frozen = false + this.editing = false + }) + .catch(() => { + this.frozen = false + }) + } + } +} + +export default UserNote diff --git a/src/components/user_note/user_note.vue b/src/components/user_note/user_note.vue new file mode 100644 index 00000000..4e05951f --- /dev/null +++ b/src/components/user_note/user_note.vue @@ -0,0 +1,88 @@ +<template> + <div + class="user-note" + > + <div class="heading"> + <span>{{ $t('user_card.note') }}</span> + <div class="buttons"> + <button + v-show="!editing && editable" + class="button-default btn" + @click="startEditing" + > + {{ $t('user_card.edit_note') }} + </button> + <button + v-show="editing" + class="button-default btn" + :disabled="frozen" + @click="finalizeEditing" + > + {{ $t('user_card.edit_note_apply') }} + </button> + <button + v-show="editing" + class="button-default btn" + :disabled="frozen" + @click="cancelEditing" + > + {{ $t('user_card.edit_note_cancel') }} + </button> + </div> + </div> + <textarea + v-show="editing" + v-model="localNote" + class="note-text" + /> + <span + v-show="!editing" + class="note-text" + :class="{ '-blank': !relationship.note }" + > + {{ relationship.note || $t('user_card.note_blank') }} + </span> + </div> +</template> + +<script src="./user_note.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.user-note { + display: flex; + flex-direction: column; + + .heading { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75em; + + .btn { + min-width: 95px; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: right; + + .btn { + margin-left: 0.5em; + } + } + } + + .note-text { + align-self: stretch; + } + + .note-text.-blank { + font-style: italic; + color: var(--faint, $fallback--faint); + } +} +</style> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 243de387..95ec97af 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,5 +1,5 @@ <template> - <div class="user-panel"> + <aside class="user-panel"> <div v-if="signedIn" key="user-panel-signed" @@ -16,7 +16,7 @@ v-else key="user-panel" /> - </div> + </aside> </template> <script src="./user_panel.js"></script> diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js index 69b25383..3b12aa1e 100644 --- a/src/components/user_popover/user_popover.js +++ b/src/components/user_popover/user_popover.js @@ -11,8 +11,8 @@ const UserPopover = { Popover: defineAsyncComponent(() => import('../popover/popover.vue')) }, computed: { - userPopoverZoom () { - return this.$store.getters.mergedConfig.userPopoverZoom + userPopoverAvatarAction () { + return this.$store.getters.mergedConfig.userPopoverAvatarAction }, userPopoverOverlay () { return this.$store.getters.mergedConfig.userPopoverOverlay diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue index 4e999672..3b2bbc45 100644 --- a/src/components/user_popover/user_popover.vue +++ b/src/components/user_popover/user_popover.vue @@ -14,7 +14,7 @@ class="user-popover" :user-id="userId" :hide-bio="true" - :avatar-action="userPopoverZoom ? 'zoom' : close" + :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction" :on-close="close" /> </template> @@ -24,10 +24,12 @@ <script src="./user_popover.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; /* popover styles load on-demand, so we need to override */ +/* stylelint-disable block-no-empty */ .user-popover.popover { } +/* stylelint-enable block-no-empty */ </style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 16551624..acb612ed 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -48,7 +48,7 @@ const UserProfile = { }, created () { const routeParams = this.$route.params - this.load(routeParams.name || routeParams.id) + this.load({ name: routeParams.name, id: routeParams.id }) this.tab = get(this.$route, 'query.tab', defaultTabKey) }, unmounted () { @@ -113,12 +113,17 @@ const UserProfile = { this.userId = null this.error = false + const maybeId = userNameOrId.id + const maybeName = userNameOrId.name + // Check if user data is already loaded in store - const user = this.$store.getters.findUser(userNameOrId) + const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName) if (user) { loadById(user.id) } else { - this.$store.dispatch('fetchUser', userNameOrId) + (maybeId + ? this.$store.dispatch('fetchUser', maybeId) + : this.$store.dispatch('fetchUserByName', maybeName)) .then(({ id }) => loadById(id)) .catch((reason) => { const errorMessage = get(reason, 'error.error') @@ -157,12 +162,12 @@ const UserProfile = { watch: { '$route.params.id': function (newVal) { if (newVal) { - this.switchUser(newVal) + this.switchUser({ id: newVal }) } }, '$route.params.name': function (newVal) { if (newVal) { - this.switchUser(newVal) + this.switchUser({ name: newVal }) } }, '$route.query': function (newVal) { diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 6032abeb..c63a303c 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -10,6 +10,7 @@ :selected="timeline.viewing" avatar-action="zoom" rounded="top" + :has-note-editor="true" /> <span v-if="!!user.birthday" @@ -149,7 +150,7 @@ <script src="./user_profile.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .user-profile { flex: 2; @@ -199,7 +200,8 @@ margin: 0 0 0 0.25em; } - .user-profile-field-name, .user-profile-field-value { + .user-profile-field-name, + .user-profile-field-value { line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; @@ -217,6 +219,7 @@ padding: 2em; } } + .user-profile-placeholder { .panel-body { display: flex; diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js index 8d171b2d..67fde084 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.js +++ b/src/components/user_reporting_modal/user_reporting_modal.js @@ -1,15 +1,16 @@ - import Status from '../status/status.vue' import List from '../list/list.vue' import Checkbox from '../checkbox/checkbox.vue' import Modal from '../modal/modal.vue' +import UserLink from '../user_link/user_link.vue' const UserReportingModal = { components: { Status, List, Checkbox, - Modal + Modal, + UserLink }, data () { return { @@ -21,14 +22,17 @@ const UserReportingModal = { } }, computed: { + reportModal () { + return this.$store.state.reports.reportModal + }, isLoggedIn () { return !!this.$store.state.users.currentUser }, isOpen () { - return this.isLoggedIn && this.$store.state.reports.modalActivated + return this.isLoggedIn && this.reportModal.activated }, userId () { - return this.$store.state.reports.userId + return this.reportModal.userId }, user () { return this.$store.getters.findUser(this.userId) @@ -37,10 +41,10 @@ const UserReportingModal = { return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1) }, statuses () { - return this.$store.state.reports.statuses + return this.reportModal.statuses }, preTickedIds () { - return this.$store.state.reports.preTickedIds + return this.reportModal.preTickedIds } }, watch: { diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 429a66e2..092c514e 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -5,9 +5,13 @@ > <div class="user-reporting-panel panel"> <div class="panel-heading"> - <div class="title"> - {{ $t('user_reporting.title', [user.screen_name_ui]) }} - </div> + <i18n-t + tag="div" + keypath="user_reporting.title" + class="title" + > + <UserLink :user="user" /> + </i18n-t> </div> <div class="panel-body"> <div class="user-reporting-panel-left"> @@ -68,7 +72,7 @@ <script src="./user_reporting_modal.js"></script> <style lang="scss"> -@import '../../_variables.scss'; +@import "../../variables"; .user-reporting-panel { width: 90vw; @@ -117,7 +121,7 @@ } .alert { - margin: 1em 0 0 0; + margin: 1em 0 0; line-height: 1.3em; } } diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue index 3a17d0e2..56543d91 100644 --- a/src/components/who_to_follow/who_to_follow.vue +++ b/src/components/who_to_follow/who_to_follow.vue @@ -15,6 +15,3 @@ </template> <script src="./who_to_follow.js"></script> - -<style lang="scss"> -</style> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue index c1ba6fb1..0fecec0b 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.vue +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -33,24 +33,28 @@ .who-to-follow * { vertical-align: middle; } + .who-to-follow img { width: 32px; height: 32px; } + .who-to-follow { - padding: 0em 1em; - margin: 0px; + padding: 0 1em; + margin: 0; } + .who-to-follow-items { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - padding: 0px; - margin: 1em 0em; + padding: 0; + margin: 1em 0; } + .who-to-follow-more { - padding: 0px; - margin: 1em 0em; + padding: 0; + margin: 1em 0; text-align: center; } </style> |
