diff options
Diffstat (limited to 'src')
31 files changed, 509 insertions, 196 deletions
@@ -66,12 +66,16 @@ export default { }) }, logo () { return this.$store.state.instance.logo }, - style () { + bgStyle () { return { - '--body-background-image': `url(${this.background})`, 'background-image': `url(${this.background})` } }, + bgAppStyle () { + return { + '--body-background-image': `url(${this.background})` + } + }, sitename () { return this.$store.state.instance.name }, chat () { return this.$store.state.chat.channel.state === 'joined' }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, diff --git a/src/App.scss b/src/App.scss index 1eaed6ea..e7784329 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,15 +1,21 @@ @import './_variables.scss'; #app { - background-size: cover; - background-attachment: fixed; - background-repeat: no-repeat; - background-position: 0 50px; min-height: 100vh; max-width: 100%; overflow: hidden; } +.app-bg-wrapper { + position: fixed; + z-index: -1; + height: 100%; + width: 100%; + background-size: cover; + background-repeat: no-repeat; + background-position: 0 50%; +} + i { user-select: none; } @@ -733,3 +739,7 @@ nav { width: 100%; } } + +.btn.btn-default { + min-height: 28px; +} diff --git a/src/App.vue b/src/App.vue index 7541928f..acbbeb75 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@ <template> - <div id="app" v-bind:style="style"> + <div id="app" v-bind:style="bgAppStyle"> + <div class="app-bg-wrapper" v-bind:style="bgStyle"></div> <nav class='nav-bar container' @click="scrollToTop()" id="nav"> <div class='logo' :style='logoBgStyle'> <div class='mask' :style='logoMaskStyle'></div> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index fa44dace..53ecc083 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -55,7 +55,6 @@ const afterStoreSetup = ({ store, i18n }) => { } copyInstanceOption('nsfwCensorImage') - copyInstanceOption('theme') copyInstanceOption('background') copyInstanceOption('hidePostStats') copyInstanceOption('hideUserStats') @@ -96,6 +95,9 @@ const afterStoreSetup = ({ store, i18n }) => { store.dispatch('initializeSocket') } + return store.dispatch('setTheme', config['theme']) + }) + .then(() => { const router = new VueRouter({ mode: 'history', routes: routes(store), diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index c18781de..48b8aaaa 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -9,9 +9,9 @@ const sortById = (a, b) => { if (isSeqA && isSeqB) { return seqA < seqB ? -1 : 1 } else if (isSeqA && !isSeqB) { - return 1 - } else if (!isSeqA && isSeqB) { return -1 + } else if (!isSeqA && isSeqB) { + return 1 } else { return a.id < b.id ? -1 : 1 } @@ -36,6 +36,13 @@ const conversation = { status () { return this.statusoid }, + statusId () { + if (this.statusoid.retweeted_status) { + return this.statusoid.retweeted_status.id + } else { + return this.statusoid.id + } + }, conversation () { if (!this.status) { return [] @@ -79,7 +86,7 @@ const conversation = { const conversationId = this.status.statusnet_conversation_id this.$store.state.api.backendInteractor.fetchConversation({id: conversationId}) .then((statuses) => this.$store.dispatch('addNewStatuses', { statuses })) - .then(() => this.setHighlight(this.statusoid.id)) + .then(() => this.setHighlight(this.statusId)) } else { const id = this.$route.params.id this.$store.state.api.backendInteractor.fetchStatus({id}) @@ -91,11 +98,7 @@ const conversation = { return this.replies[id] || [] }, focused (id) { - if (this.statusoid.retweeted_status) { - return (id === this.statusoid.retweeted_status.id) - } else { - return (id === this.statusoid.id) - } + return id === this.statusId }, setHighlight (id) { this.highlight = id diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js index 6d00eb94..51327e2f 100644 --- a/src/components/follow_list/follow_list.js +++ b/src/components/follow_list/follow_list.js @@ -25,6 +25,9 @@ const FollowList = { }, entries () { return this.showFollowers ? this.user.followers : this.user.friends + }, + showFollowsYou () { + return !this.showFollowers || (this.showFollowers && this.userId !== this.$store.state.users.currentUser.id) } }, methods: { diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue index 24ab97d8..27102edf 100644 --- a/src/components/follow_list/follow_list.vue +++ b/src/components/follow_list/follow_list.vue @@ -3,7 +3,7 @@ <user-card v-for="entry in entries" :key="entry.id" :user="entry" - :showFollows="true" + :noFollowsYou="!showFollowsYou" /> <div class="text-center panel-footer"> <a v-if="error" @click="fetchEntries" class="alert error"> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js new file mode 100644 index 00000000..990c0370 --- /dev/null +++ b/src/components/image_cropper/image_cropper.js @@ -0,0 +1,128 @@ +import Cropper from 'cropperjs' +import 'cropperjs/dist/cropper.css' + +const ImageCropper = { + props: { + trigger: { + type: [String, window.Element], + required: true + }, + submitHandler: { + type: Function, + required: true + }, + cropperOptions: { + type: Object, + default () { + return { + aspectRatio: 1, + autoCropArea: 1, + viewMode: 1, + movable: false, + zoomable: false, + guides: false + } + } + }, + mimes: { + type: String, + default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' + }, + saveButtonLabel: { + type: String + }, + cancelButtonLabel: { + type: String + } + }, + data () { + return { + cropper: undefined, + dataUrl: undefined, + filename: undefined, + submitting: false, + submitError: null + } + }, + computed: { + saveText () { + return this.saveButtonLabel || this.$t('image_cropper.save') + }, + cancelText () { + return this.cancelButtonLabel || this.$t('image_cropper.cancel') + }, + submitErrorMsg () { + return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError + } + }, + methods: { + destroy () { + if (this.cropper) { + this.cropper.destroy() + } + this.$refs.input.value = '' + this.dataUrl = undefined + this.$emit('close') + }, + submit () { + this.submitting = true + this.avatarUploadError = null + this.submitHandler(this.cropper, this.filename) + .then(() => this.destroy()) + .catch((err) => { + this.submitError = err + }) + .finally(() => { + this.submitting = false + }) + }, + pickImage () { + this.$refs.input.click() + }, + createCropper () { + this.cropper = new Cropper(this.$refs.img, this.cropperOptions) + }, + getTriggerDOM () { + return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger) + }, + readFile () { + const fileInput = this.$refs.input + if (fileInput.files != null && fileInput.files[0] != null) { + let reader = new window.FileReader() + reader.onload = (e) => { + this.dataUrl = e.target.result + this.$emit('open') + } + reader.readAsDataURL(fileInput.files[0]) + this.filename = fileInput.files[0].name || 'unknown' + this.$emit('changed', fileInput.files[0], reader) + } + }, + clearError () { + this.submitError = null + } + }, + mounted () { + // listen for click event on trigger + const trigger = this.getTriggerDOM() + if (!trigger) { + this.$emit('error', 'No image make trigger found.', 'user') + } else { + trigger.addEventListener('click', this.pickImage) + } + // listen for input file changes + const fileInput = this.$refs.input + fileInput.addEventListener('change', this.readFile) + }, + beforeDestroy: function () { + // remove the event listeners + const trigger = this.getTriggerDOM() + if (trigger) { + trigger.removeEventListener('click', this.pickImage) + } + const fileInput = this.$refs.input + fileInput.removeEventListener('change', this.readFile) + } +} + +export default ImageCropper diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue new file mode 100644 index 00000000..24a6f3bd --- /dev/null +++ b/src/components/image_cropper/image_cropper.vue @@ -0,0 +1,42 @@ +<template> + <div class="image-cropper"> + <div v-if="dataUrl"> + <div class="image-cropper-image-container"> + <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> + </div> + <div class="image-cropper-buttons-wrapper"> + <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button> + <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> + <i class="icon-spin4 animate-spin" v-if="submitting"></i> + </div> + <div class="alert error" v-if="submitError"> + {{submitErrorMsg}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> + <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + </div> +</template> + +<script src="./image_cropper.js"></script> + +<style lang="scss"> +.image-cropper { + &-img-input { + display: none; + } + + &-image-container { + position: relative; + + img { + display: block; + max-width: 100%; + } + } + + &-buttons-wrapper { + margin-top: 15px; + } +} +</style> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 534a9839..23c1acdb 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -91,7 +91,8 @@ const settings = { }, currentSaveStateNotice () { return this.$store.state.interface.settings.currentSaveStateNotice - } + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } }, watch: { hideAttachmentsLocal (value) { diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index dfb2e49d..f5e00995 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -27,7 +27,7 @@ <li> <interface-language-switcher /> </li> - <li> + <li v-if="instanceSpecificPanelPresent"> <input type="checkbox" id="hideISP" v-model="hideISPLocal"> <label for="hideISP">{{$t('settings.hide_isp')}}</label> </li> @@ -311,20 +311,6 @@ color: $fallback--cRed; } - .old-avatar { - width: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .new-avatar { - object-fit: cover; - width: 128px; - height: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - .btn { min-height: 28px; min-width: 10em; diff --git a/src/components/status/status.js b/src/components/status/status.js index 1e2b8794..0273a5be 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -10,7 +10,7 @@ import LinkPreview from '../link-preview/link-preview.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' -import { mentionMatchesUrl } from 'src/services/mention_matcher/mention_matcher.js' +import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' import { filter, find, unescape } from 'lodash' const Status = { @@ -282,7 +282,7 @@ const Status = { } if (target.tagName === 'A') { if (target.className.match(/mention/)) { - const href = target.getAttribute('href') + const href = target.href const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) if (attn) { event.stopPropagation() @@ -292,6 +292,15 @@ const Status = { return } } + if (target.className.match(/hashtag/)) { + // Extract tag name from link url + const tag = extractTagFromUrl(target.href) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + return + } + } window.open(target.href, '_blank') } }, @@ -348,6 +357,9 @@ const Status = { generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) }, + generateTagLink (tag) { + return `/tag/${tag}` + }, setMedia () { const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments return () => this.$store.dispatch('setMedia', attachments) diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 5bc7c664..3fc5b486 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -56,7 +56,7 @@ </div> <h4 class="replies" v-if="inConversation && !noReplyLinks"> <small v-if="replies.length">Replies:</small> - <small class="reply-link" v-for="reply in replies"> + <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies"> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> </small> </h4> @@ -438,6 +438,8 @@ .user-name { font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; img { width: 14px; @@ -552,7 +554,7 @@ a.unmute { .timeline > { .status-el:last-child { - border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;; + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-bottom: none; } diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index ecc36a4d..28e22f09 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -1,16 +1,20 @@ import UserCardContent from '../user_card_content/user_card_content.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' const UserCard = { props: [ 'user', - 'showFollows', + 'noFollowsYou', 'showApproval' ], data () { return { - userExpanded: false + userExpanded: false, + followRequestInProgress: false, + followRequestSent: false, + updated: false } }, components: { @@ -18,7 +22,11 @@ const UserCard = { UserAvatar }, computed: { - currentUser () { return this.$store.state.users.currentUser } + currentUser () { return this.$store.state.users.currentUser }, + following () { return this.updated ? this.updated.following : this.user.following }, + showFollow () { + return !this.showApproval && (!this.following || this.updated && !this.updated.following) + } }, methods: { toggleUserExpanded () { @@ -34,6 +42,21 @@ const UserCard = { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + followUser () { + this.followRequestInProgress = true + requestFollow(this.user, this.$store).then(({ sent, updated }) => { + this.followRequestInProgress = false + this.followRequestSent = sent + this.updated = updated + }) + }, + unfollowUser () { + this.followRequestInProgress = true + requestUnfollow(this.user, this.$store).then(({ updated }) => { + this.followRequestInProgress = false + this.updated = updated + }) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 7129430b..ce4edb3c 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -1,32 +1,57 @@ <template> <div class="card"> <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> </router-link> - <div class="usercard" v-if="userExpanded"> - <user-card-content :user="user" :switcher="false"></user-card-content> - </div> - <div class="name-and-screen-name" v-else> - <div :title="user.name" v-if="user.name_html" class="user-name"> - <span v-html="user.name_html"></span> - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} - </span> + <div class="user-card-main-content"> + <div class="usercard" v-if="userExpanded"> + <user-card-content :user="user" :switcher="false"></user-card-content> </div> - <div :title="user.name" v-else class="user-name"> - {{ user.name }} - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> + <div class="name-and-screen-name" v-if="!userExpanded"> + <div :title="user.name" class="user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> + </div> + <div class="user-link-action"> + <router-link class='user-screen-name' :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + </div> + </div> + <div class="follow-box" v-if="!userExpanded"> + <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> + <button + v-if="showFollow" + class="btn btn-default" + @click="followUser" + :disabled="followRequestInProgress" + :title="followRequestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="followRequestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="followRequestInProgress"> + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> + </div> + <div class="approval" v-if="showApproval"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> </div> - - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> - </div> - <div class="approval" v-if="showApproval"> - <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> - <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> </div> </div> </template> @@ -36,11 +61,18 @@ <style lang="scss"> @import '../../_variables.scss'; -.name-and-screen-name { +.user-card-main-content { + display: flex; + flex-direction: column; + flex: 1 1 100%; margin-left: 0.7em; - margin-top:0.0em; + min-width: 0; +} + +.name-and-screen-name { text-align: left; width: 100%; + .user-name { img { object-fit: contain; @@ -49,13 +81,15 @@ vertical-align: middle; } } -} -.follows-you { - margin-left: 2em; - float: right; + .user-link-action { + display: flex; + align-items: flex-start; + justify-content: space-between; + } } + .card { display: flex; flex: 1 0; @@ -66,16 +100,31 @@ border-bottom: 1px solid; margin: 0; border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); + border-bottom-color: var(--border, $fallback--border); .avatar { padding: 0; } + + .follow-box { + text-align: center; + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + line-height: 1.5em; + + .btn { + margin-top: 0.5em; + margin-left: auto; + width: 10em; + } + } } .usercard { width: fill-available; - margin: 0.2em 0 0 0.7em; border-radius: $fallback--panelRadius; border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; @@ -96,9 +145,15 @@ } .approval { + display: flex; + flex-direction: row; + flex-wrap: wrap; button { - width: 100%; - margin-bottom: 0.5em; + margin-top: 0.5em; + margin-right: 0.5em; + flex: 1 1; + max-width: 12em; + min-width: 8em; } } </style> diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 1888f8c6..7a7b89d4 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -1,5 +1,6 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' export default { @@ -92,69 +93,17 @@ export default { }, methods: { followUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.followUser(this.user.id) - .then((followedUser) => store.commit('addNewUsers', [followedUser])) - .then(() => { - // For locked users we just mark it that we sent the follow request - if (this.user.locked) { - this.followRequestInProgress = false - this.followRequestSent = true - return - } - - if (this.user.following) { - // If we get result immediately, just stop. - this.followRequestInProgress = false - return - } - - // But usually we don't get result immediately, so we ask server - // for updated user profile to confirm if we are following them - // Sometimes it takes several tries. Sometimes we end up not following - // user anyway, probably because they locked themselves and we - // don't know that yet. - // Recursive Promise, it will call itself up to 3 times. - const fetchUser = (attempt) => new Promise((resolve, reject) => { - setTimeout(() => { - store.state.api.backendInteractor.fetchUser({ id: this.user.id }) - .then((user) => store.commit('addNewUsers', [user])) - .then(() => resolve([this.user.following, attempt])) - .catch((e) => reject(e)) - }, 500) - }).then(([following, attempt]) => { - if (!following && attempt <= 3) { - // If we BE reports that we still not following that user - retry, - // increment attempts by one - return fetchUser(++attempt) - } else { - // If we run out of attempts, just return whatever status is. - return following - } - }) - - return fetchUser(1) - .then((following) => { - if (following) { - // We confirmed and everything its good. - this.followRequestInProgress = false - } else { - // If after all the tries, just treat it as if user is locked - this.followRequestInProgress = false - this.followRequestSent = true - } - }) - }) + requestFollow(this.user, this.$store).then(({sent}) => { + this.followRequestInProgress = false + this.followRequestSent = sent + }) }, unfollowUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.unfollowUser(this.user.id) - .then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser])) - .then(() => { - this.followRequestInProgress = false - }) + requestUnfollow(this.user, this.$store).then(() => { + this.followRequestInProgress = false + }) }, blockUser () { const store = this.$store diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 7f9909c4..4d1950c5 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -386,6 +386,4 @@ } } -.floater { -} </style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index a22b8722..37179ce1 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -16,7 +16,7 @@ const UserProfile = { } }, destroyed () { - this.cleanUp(this.userId) + this.cleanUp() }, computed: { timeline () { diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index d20bf308..dce3eeed 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,6 +1,7 @@ import { unescape } from 'lodash' import TabSwitcher from '../tab_switcher/tab_switcher.js' +import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' @@ -20,14 +21,12 @@ const UserSettings = { followImportError: false, followsImported: false, enableFollowsExport: true, - avatarUploading: false, + pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, followListUploading: false, - avatarPreview: null, bannerPreview: null, backgroundPreview: null, - avatarUploadError: null, bannerUploadError: null, backgroundUploadError: null, deletingAccount: false, @@ -41,7 +40,8 @@ const UserSettings = { }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + ImageCropper }, computed: { user () { @@ -117,35 +117,15 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar () { - if (!this.avatarPreview) { return } - - let img = this.avatarPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - if (imginfo.height > imginfo.width) { - cropX = 0 - cropW = imginfo.width - cropY = Math.floor((imginfo.height - imginfo.width) / 2) - cropH = imginfo.width - } else { - cropY = 0 - cropH = imginfo.height - cropX = Math.floor((imginfo.width - imginfo.height) / 2) - cropW = imginfo.height - } - this.avatarUploading = true - this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => { + submitAvatar (cropper) { + const img = cropper.getCroppedCanvas().toDataURL('image/jpeg') + return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) - this.avatarPreview = null } else { - this.avatarUploadError = this.$t('upload.error.base') + user.error + throw new Error(this.$t('upload.error.base') + user.error) } - this.avatarUploading = false }) }, clearUploadError (slot) { diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 134f70ef..8ab92e95 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -48,19 +48,10 @@ <h2>{{$t('settings.avatar')}}</h2> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> + <img :src="user.profile_image_url_original" class="current-avatar"></img> <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview"> - </img> - <div> - <input type="file" @change="uploadFile('avatar', $event)" ></input> - </div> - <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i> - <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button> - <div class='alert error' v-if="avatarUploadError"> - Error: {{ avatarUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('avatar')"></i> - </div> + <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> + <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> </div> <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> @@ -167,6 +158,8 @@ </script> <style lang="scss"> +@import '../../_variables.scss'; + .profile-edit { .bio { margin: 0; @@ -178,7 +171,7 @@ } .banner { - max-width: 400px; + max-width: 100%; } .uploading { @@ -193,5 +186,13 @@ .bg { max-width: 100%; } + + .current-avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } } </style> diff --git a/src/i18n/en.json b/src/i18n/en.json index 14e2a699..78e8fced 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -21,6 +21,11 @@ "more": "More", "generic_error": "An error occured" }, + "image_cropper": { + "crop_picture": "Crop picture", + "save": "Save", + "cancel": "Cancel" + }, "login": { "login": "Log in", "description": "Log in with OAuth", @@ -210,6 +215,7 @@ "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", "tooltipRadius": "Tooltips/alerts", + "upload_a_photo": "Upload a photo", "user_settings": "User Settings", "values": { "false": "no", @@ -348,7 +354,7 @@ "follow_sent": "Request sent!", "follow_progress": "Requesting…", "follow_again": "Send request again?", - "follow_unfollow": "Stop following", + "follow_unfollow": "Unfollow", "followees": "Following", "followers": "Followers", "following": "Following!", diff --git a/src/i18n/es.json b/src/i18n/es.json index 29c8aec4..d14e7a31 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -140,6 +140,7 @@ "use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.", "hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)", "hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)", + "hide_filtered_statuses": "Ocultar estados filtrados", "import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv", "import_theme": "Importar tema", "inputRadius": "Campos de entrada", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index afce03a4..7849aa20 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -17,7 +17,9 @@ }, "general": { "apply": "てきよう", - "submit": "そうしん" + "submit": "そうしん", + "more": "つづき", + "generic_error": "エラーになりました" }, "login": { "login": "ログイン", @@ -26,7 +28,8 @@ "password": "パスワード", "placeholder": "れい: lain", "register": "はじめる", - "username": "ユーザーめい" + "username": "ユーザーめい", + "hint": "はなしあいにくわわるには、ログインしてください" }, "nav": { "about": "これはなに?", @@ -49,7 +52,8 @@ "load_older": "ふるいつうちをみる", "notifications": "つうち", "read": "よんだ!", - "repeated_you": "あなたのステータスがリピートされました" + "repeated_you": "あなたのステータスがリピートされました", + "no_more_notifications": "つうちはありません" }, "post_status": { "new_status": "とうこうする", @@ -117,6 +121,7 @@ "delete_account_description": "あなたのアカウントとメッセージが、きえます。", "delete_account_error": "アカウントをけすことが、できなかったかもしれません。インスタンスのかんりしゃに、れんらくしてください。", "delete_account_instructions": "ほんとうにアカウントをけしてもいいなら、パスワードをかいてください。", + "avatar_size_instruction": "アバターのおおきさは、150×150ピクセルか、それよりもおおきくするといいです。", "export_theme": "セーブ", "filtering": "フィルタリング", "filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください。", @@ -132,8 +137,10 @@ "hide_attachments_in_tl": "タイムラインのファイルをかくす", "hide_isp": "インスタンススペシフィックパネルをかくす", "preload_images": "がぞうをさきよみする", + "use_one_click_nsfw": "NSFWなファイルを1クリックでひらく", "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", + "hide_filtered_statuses": "フィルターされたとうこうをかくす", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_theme": "ロード", "inputRadius": "インプットフィールド", @@ -148,6 +155,8 @@ "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできる", "loop_video": "ビデオをくりかえす", "loop_video_silent_only": "おとのないビデオだけくりかえす", + "play_videos_in_modal": "ビデオをメディアビューアーでみる", + "use_contain_fit": "がぞうのサムネイルを、きりぬかない", "name": "なまえ", "name_bio": "なまえとプロフィール", "new_password": "あたらしいパスワード", @@ -157,8 +166,10 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストをつかわない", - "hide_follows_description": "フォローしている人を表示しない", - "hide_followers_description": "フォローしている人を表示しない", + "hide_follows_description": "フォローしているひとをみせない", + "hide_followers_description": "フォロワーをみせない", + "show_admin_badge": "アドミンのしるしをみる", + "show_moderator_badge": "モデレーターのしるしをみる", "nsfw_clickthrough": "NSFWなファイルをかくす", "panelRadius": "パネル", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", @@ -185,6 +196,8 @@ "subject_line_email": "メールふう: \"re: サブジェクト\"", "subject_line_mastodon": "マストドンふう: そのままコピー", "subject_line_noop": "コピーしない", + "post_status_content_type": "とうこうのコンテントタイプ", + "status_content_type_plain": "プレーンテキスト", "stop_gifs": "カーソルをかさねたとき、GIFをうごかす", "streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする", "text": "もじ", @@ -318,13 +331,15 @@ "no_retweet_hint": "とうこうを「フォロワーのみ」または「ダイレクト」にすると、リピートできなくなります", "repeated": "リピート", "show_new": "よみこみ", - "up_to_date": "さいしん" + "up_to_date": "さいしん", + "no_more_statuses": "これでおわりです" }, "user_card": { "approve": "うけいれ", "block": "ブロック", "blocked": "ブロックしています!", "deny": "おことわり", + "favorites": "おきにいり", "follow": "フォロー", "follow_sent": "リクエストを、おくりました!", "follow_progress": "リクエストしています…", @@ -335,6 +350,7 @@ "following": "フォローしています!", "follows_you": "フォローされました!", "its_you": "これはあなたです!", + "media": "メディア", "mute": "ミュート", "muted": "ミュートしています!", "per_day": "/日", diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index ccd92633..6f7202ce 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -48,7 +48,7 @@ export default function createPersistedState ({ return getState(key, storage).then((savedState) => { return store => { try { - if (typeof savedState === 'object') { + if (savedState !== null && typeof savedState === 'object') { // build user cache const usersState = savedState.users || {} usersState.usersObject = {} diff --git a/src/modules/instance.js b/src/modules/instance.js index 9bef5235..59c6b91c 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -66,9 +66,11 @@ const instance = { case 'name': dispatch('setPageTitle') break - case 'theme': - setPreset(value, commit) } + }, + setTheme ({ commit }, themeName) { + commit('setInstanceOption', { name: 'theme', value: themeName }) + return setPreset(themeName, commit) } } } diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 56619455..46117fd7 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -296,7 +296,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot notifObj.image = action.attachments[0].url } - if (notification.fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) { let notification = new window.Notification(title, notifObj) // Chrome is known for not closing notifications automatically // according to MDN, anyway. diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 92daa04e..13d31d91 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -258,7 +258,7 @@ const fetchFriends = ({id, page, credentials}) => { } const exportFriends = ({id, credentials}) => { - let url = `${FRIENDS_URL}?user_id=${id}&export=true` + let url = `${FRIENDS_URL}?user_id=${id}&all=true` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) .then((data) => data.map(parseUser)) diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js new file mode 100644 index 00000000..1e9bd679 --- /dev/null +++ b/src/services/follow_manipulate/follow_manipulate.js @@ -0,0 +1,74 @@ +const fetchUser = (attempt, user, store) => new Promise((resolve, reject) => { + setTimeout(() => { + store.state.api.backendInteractor.fetchUser({ id: user.id }) + .then((user) => store.commit('addNewUsers', [user])) + .then(() => resolve([user.following, attempt])) + .catch((e) => reject(e)) + }, 500) +}).then(([following, attempt]) => { + if (!following && attempt <= 3) { + // If we BE reports that we still not following that user - retry, + // increment attempts by one + return fetchUser(++attempt, user, store) + } else { + // If we run out of attempts, just return whatever status is. + return following + } +}) + +export const requestFollow = (user, store) => new Promise((resolve, reject) => { + store.state.api.backendInteractor.followUser(user.id) + .then((updated) => { + store.commit('addNewUsers', [updated]) + + // For locked users we just mark it that we sent the follow request + if (updated.locked) { + resolve({ + sent: true, + updated + }) + } + + if (updated.following) { + // If we get result immediately, just stop. + resolve({ + sent: false, + updated + }) + } + + // But usually we don't get result immediately, so we ask server + // for updated user profile to confirm if we are following them + // Sometimes it takes several tries. Sometimes we end up not following + // user anyway, probably because they locked themselves and we + // don't know that yet. + // Recursive Promise, it will call itself up to 3 times. + + return fetchUser(1, user, store) + .then((following) => { + if (following) { + // We confirmed and everything's good. + resolve({ + sent: false, + updated + }) + } else { + // If after all the tries, just treat it as if user is locked + resolve({ + sent: false, + updated + }) + } + }) + }) +}) + +export const requestUnfollow = (user, store) => new Promise((resolve, reject) => { + store.state.api.backendInteractor.unfollowUser(user.id) + .then((updated) => { + store.commit('addNewUsers', [updated]) + resolve({ + updated + }) + }) +}) diff --git a/src/services/matcher/matcher.service.js b/src/services/matcher/matcher.service.js new file mode 100644 index 00000000..b6c4e909 --- /dev/null +++ b/src/services/matcher/matcher.service.js @@ -0,0 +1,23 @@ +export const mentionMatchesUrl = (attention, url) => { + if (url === attention.statusnet_profile_url) { + return true + } + const [namepart, instancepart] = attention.screen_name.split('@') + const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g') + + return !!url.match(matchstring) +} + +/** + * Extract tag name from pleroma or mastodon url. + * i.e https://bikeshed.party/tag/photo or https://quey.org/tags/sky + * @param {string} url + */ +export const extractTagFromUrl = (url) => { + const regex = /tag[s]*\/(\w+)$/g + const result = regex.exec(url) + if (!result) { + return false + } + return result[1] +} diff --git a/src/services/mention_matcher/mention_matcher.js b/src/services/mention_matcher/mention_matcher.js deleted file mode 100644 index 2c1ed970..00000000 --- a/src/services/mention_matcher/mention_matcher.js +++ /dev/null @@ -1,9 +0,0 @@ - -export const mentionMatchesUrl = (attention, url) => { - if (url === attention.statusnet_profile_url) { - return true - } - const [namepart, instancepart] = attention.screen_name.split('@') - const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g') - return !!url.match(matchstring) -} diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index 10e7ed9b..d0b6ccbf 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -480,7 +480,7 @@ const getThemes = () => { } const setPreset = (val, commit) => { - getThemes().then((themes) => { + return getThemes().then((themes) => { const theme = themes[val] ? themes[val] : themes['pleroma-dark'] const isV1 = Array.isArray(theme) const data = isV1 ? {} : theme.theme |
