aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/attachment/attachment.vue2
-rw-r--r--src/components/autocomplete_input/autocomplete_input.js149
-rw-r--r--src/components/autocomplete_input/autocomplete_input.vue104
-rw-r--r--src/components/basic_user_card/basic_user_card.js28
-rw-r--r--src/components/basic_user_card/basic_user_card.vue79
-rw-r--r--src/components/block_card/block_card.js37
-rw-r--r--src/components/block_card/block_card.vue34
-rw-r--r--src/components/chat_panel/chat_panel.vue11
-rw-r--r--src/components/follow_card/follow_card.js45
-rw-r--r--src/components/follow_card/follow_card.vue53
-rw-r--r--src/components/follow_list/follow_list.js63
-rw-r--r--src/components/follow_list/follow_list.vue34
-rw-r--r--src/components/follow_request_card/follow_request_card.js20
-rw-r--r--src/components/follow_request_card/follow_request_card.vue29
-rw-r--r--src/components/follow_requests/follow_requests.js13
-rw-r--r--src/components/follow_requests/follow_requests.vue2
-rw-r--r--src/components/gallery/gallery.vue4
-rw-r--r--src/components/image_cropper/image_cropper.js128
-rw-r--r--src/components/image_cropper/image_cropper.vue42
-rw-r--r--src/components/link-preview/link-preview.vue5
-rw-r--r--src/components/media_modal/media_modal.js51
-rw-r--r--src/components/media_modal/media_modal.vue81
-rw-r--r--src/components/mute_card/mute_card.js37
-rw-r--r--src/components/mute_card/mute_card.vue24
-rw-r--r--src/components/nav_panel/nav_panel.js13
-rw-r--r--src/components/nav_panel/nav_panel.vue11
-rw-r--r--src/components/notification/notification.vue6
-rw-r--r--src/components/notifications/notifications.scss5
-rw-r--r--src/components/post_status_form/post_status_form.js142
-rw-r--r--src/components/post_status_form/post_status_form.vue102
-rw-r--r--src/components/registration/registration.vue12
-rw-r--r--src/components/settings/settings.js11
-rw-r--r--src/components/settings/settings.vue40
-rw-r--r--src/components/side_drawer/side_drawer.js3
-rw-r--r--src/components/side_drawer/side_drawer.vue4
-rw-r--r--src/components/status/status.js10
-rw-r--r--src/components/status/status.vue269
-rw-r--r--src/components/timeline/timeline.js18
-rw-r--r--src/components/timeline/timeline.vue5
-rw-r--r--src/components/user_card/user_card.js65
-rw-r--r--src/components/user_card/user_card.vue137
-rw-r--r--src/components/user_card_content/user_card_content.vue30
-rw-r--r--src/components/user_finder/user_finder.js1
-rw-r--r--src/components/user_finder/user_finder.vue2
-rw-r--r--src/components/user_profile/user_profile.js53
-rw-r--r--src/components/user_profile/user_profile.vue16
-rw-r--r--src/components/user_search/user_search.js10
-rw-r--r--src/components/user_search/user_search.vue13
-rw-r--r--src/components/user_settings/user_settings.js85
-rw-r--r--src/components/user_settings/user_settings.vue86
-rw-r--r--src/components/who_to_follow/who_to_follow.js4
-rw-r--r--src/components/who_to_follow/who_to_follow.vue2
52 files changed, 1395 insertions, 835 deletions
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index a93c9014..c58bebd3 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -88,7 +88,7 @@
.attachment {
position: relative;
- margin: 0.5em 0.5em 0em 0em;
+ margin-top: 0.5em;
align-self: flex-start;
line-height: 0;
diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js
deleted file mode 100644
index 1544e7bb..00000000
--- a/src/components/autocomplete_input/autocomplete_input.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import Completion from '../../services/completion/completion.js'
-import { take, filter, map } from 'lodash'
-
-const AutoCompleteInput = {
- props: [
- 'id',
- 'classObj',
- 'value',
- 'placeholder',
- 'autoResize',
- 'multiline',
- 'drop',
- 'dragoverPrevent',
- 'paste',
- 'keydownMetaEnter',
- 'keyupCtrlEnter'
- ],
- components: {},
- mounted () {
- this.autoResize && this.resize(this.$refs.textarea)
- const textLength = this.$refs.textarea.value.length
- this.$refs.textarea.setSelectionRange(textLength, textLength)
- },
- data () {
- return {
- caret: 0,
- highlighted: 0
- }
- },
- computed: {
- users () {
- return this.$store.state.users.users
- },
- emoji () {
- return this.$store.state.instance.emoji || []
- },
- customEmoji () {
- return this.$store.state.instance.customEmoji || []
- },
- textAtCaret () {
- return (this.wordAtCaret || {}).word || ''
- },
- wordAtCaret () {
- const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
- return word
- },
- candidates () {
- const firstchar = this.textAtCaret.charAt(0)
- if (firstchar === '@') {
- const query = this.textAtCaret.slice(1).toUpperCase()
- const matchedUsers = filter(this.users, (user) => {
- return user.screen_name.toUpperCase().startsWith(query) ||
- user.name && user.name.toUpperCase().startsWith(query)
- })
- if (matchedUsers.length <= 0) {
- return false
- }
- // eslint-disable-next-line camelcase
- return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
- // eslint-disable-next-line camelcase
- screen_name: `@${screen_name}`,
- name: name,
- img: profile_image_url_original,
- highlighted: index === this.highlighted
- }))
- } else if (firstchar === ':') {
- if (this.textAtCaret === ':') { return }
- const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
- if (matchedEmoji.length <= 0) {
- return false
- }
- return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
- screen_name: `:${shortcode}:`,
- name: '',
- utf: utf || '',
- // eslint-disable-next-line camelcase
- img: utf ? '' : this.$store.state.instance.server + image_url,
- highlighted: index === this.highlighted
- }))
- } else {
- return false
- }
- }
- },
- methods: {
- setCaret ({target: {selectionStart}}) {
- this.caret = selectionStart
- },
- cycleBackward (e) {
- const len = this.candidates.length || 0
- if (len > 0) {
- e.preventDefault()
- this.highlighted -= 1
- if (this.highlighted < 0) {
- this.highlighted = this.candidates.length - 1
- }
- } else {
- this.highlighted = 0
- }
- },
- cycleForward (e) {
- const len = this.candidates.length || 0
- if (len > 0) {
- if (e.shiftKey) { return }
- e.preventDefault()
- this.highlighted += 1
- if (this.highlighted >= len) {
- this.highlighted = 0
- }
- } else {
- this.highlighted = 0
- }
- },
- replace (replacement) {
- this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement))
- const el = this.$el.querySelector('textarea') || this.$el.querySelector('input')
- el.focus()
- this.caret = 0
- },
- replaceCandidate (e) {
- const len = this.candidates.length || 0
- if (this.textAtCaret === ':' || e.ctrlKey) { return }
- if (len > 0) {
- e.preventDefault()
- const candidate = this.candidates[this.highlighted]
- const replacement = candidate.utf || (candidate.screen_name + ' ')
- this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement))
- const el = this.$el.querySelector('textarea') || this.$el.querySelector('input')
- el.focus()
- this.caret = 0
- this.highlighted = 0
- }
- },
- resize (e) {
- const target = e.target || e
- if (!(target instanceof window.Element)) { return }
- const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
- Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
- // Auto is needed to make textbox shrink when removing lines
- target.style.height = 'auto'
- target.style.height = `${target.scrollHeight - vertPadding}px`
- if (target.value === '') {
- target.style.height = null
- }
- }
- }
-}
-
-export default AutoCompleteInput
diff --git a/src/components/autocomplete_input/autocomplete_input.vue b/src/components/autocomplete_input/autocomplete_input.vue
deleted file mode 100644
index 1e26b76b..00000000
--- a/src/components/autocomplete_input/autocomplete_input.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<template>
- <div style="display: flex; flex-direction: column;">
- <textarea
- v-if="multiline"
- ref="textarea"
- rows="1"
- :value="value" :class="classObj" :id="id" :placeholder="placeholder"
- @input="$emit('input', $event.target.value), autoResize && resize($event)"
- @click="setCaret"
- @keyup="setCaret"
- @keydown.down="cycleForward"
- @keydown.up="cycleBackward"
- @keydown.shift.tab="cycleBackward"
- @keydown.tab="cycleForward"
- @keydown.enter="replaceCandidate"
- @drop="drop && drop($event)"
- @dragover.prevent="dragoverPrevent && dragoverPrevent($event)"
- @paste="paste && paste($event)"
- @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)"
- @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)">
- </textarea>
- <input
- v-else
- ref="textarea"
- :value="value" :class="classObj" :id="id" :placeholder="placeholder"
- @input="$emit('input', $event.target.value), autoResize && resize($event)"
- @click="setCaret"
- @keyup="setCaret"
- @keydown.down="cycleForward"
- @keydown.up="cycleBackward"
- @keydown.shift.tab="cycleBackward"
- @keydown.tab="cycleForward"
- @keydown.enter="replaceCandidate"
- @drop="drop && drop($event)"
- @dragover.prevent="dragoverPrevent && dragoverPrevent($event)"
- @paste="paste && paste($event)"
- @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)"
- @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"/>
- <div style="position:relative;" v-if="candidates">
- <div class="autocomplete-panel">
- <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
- <div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
- <span v-if="candidate.img"><img :src="candidate.img"></img></span>
- <span v-else>{{candidate.utf}}</span>
- <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
- </div>
- </div>
- </div>
- </div>
- </div>
-</template>
-
-<script src="./autocomplete_input.js"></script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.autocomplete-panel {
- margin: 0 0.5em 0 0.5em;
- border-radius: $fallback--tooltipRadius;
- border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
- position: absolute;
- z-index: 1;
- box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
- // this doesn't match original but i don't care, making it uniform.
- box-shadow: var(--popupShadow);
- min-width: 75%;
- background: $fallback--bg;
- background: var(--bg, $fallback--bg);
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
-}
-
-.autocomplete {
- cursor: pointer;
- padding: 0.2em 0.4em 0.2em 0.4em;
- border-bottom: 1px solid rgba(0, 0, 0, 0.4);
- display: flex;
-
- img {
- width: 24px;
- height: 24px;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- object-fit: contain;
- }
-
- span {
- line-height: 24px;
- margin: 0 0.1em 0 0.2em;
- }
-
- small {
- margin-left: .5em;
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- }
-
- &.highlighted {
- background-color: $fallback--fg;
- background-color: var(--lightBg, $fallback--fg);
- }
-}
-</style>
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
new file mode 100644
index 00000000..a8441446
--- /dev/null
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -0,0 +1,28 @@
+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'
+
+const BasicUserCard = {
+ props: [
+ 'user'
+ ],
+ data () {
+ return {
+ userExpanded: false
+ }
+ },
+ components: {
+ UserCardContent,
+ UserAvatar
+ },
+ methods: {
+ toggleUserExpanded () {
+ this.userExpanded = !this.userExpanded
+ },
+ userProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+ }
+ }
+}
+
+export default BasicUserCard
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
new file mode 100644
index 00000000..77fb0aa0
--- /dev/null
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -0,0 +1,79 @@
+<template>
+ <div class="user-card">
+ <router-link :to="userProfileLink(user)">
+ <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
+ </router-link>
+ <div class="user-card-expanded-content" v-if="userExpanded">
+ <user-card-content :user="user" :switcher="false"></user-card-content>
+ </div>
+ <div class="user-card-collapsed-content" v-else>
+ <div :title="user.name" class="user-card-user-name">
+ <span v-if="user.name_html" v-html="user.name_html"></span>
+ <span v-else>{{ user.name }}</span>
+ </div>
+ <div>
+ <router-link class="user-card-screen-name" :to="userProfileLink(user)">
+ @{{user.screen_name}}
+ </router-link>
+ </div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
+
+<script src="./basic_user_card.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.user-card {
+ display: flex;
+ flex: 1 0;
+ padding-top: 0.6em;
+ padding-right: 1em;
+ padding-bottom: 0.6em;
+ padding-left: 1em;
+ border-bottom: 1px solid;
+ margin: 0;
+ border-bottom-color: $fallback--border;
+ border-bottom-color: var(--border, $fallback--border);
+
+ &-collapsed-content {
+ margin-left: 0.7em;
+ text-align: left;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &-user-name {
+ img {
+ object-fit: contain;
+ height: 16px;
+ width: 16px;
+ vertical-align: middle;
+ }
+ }
+
+ &-expanded-content {
+ flex: 1;
+ margin-left: 0.7em;
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ border-style: solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ border-width: 1px;
+ overflow: hidden;
+
+ .panel-heading {
+ background: transparent;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ p {
+ margin-bottom: 0;
+ }
+ }
+}
+</style>
diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js
new file mode 100644
index 00000000..11fa27b4
--- /dev/null
+++ b/src/components/block_card/block_card.js
@@ -0,0 +1,37 @@
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+
+const BlockCard = {
+ props: ['userId'],
+ data () {
+ return {
+ progress: false
+ }
+ },
+ computed: {
+ user () {
+ return this.$store.getters.userById(this.userId)
+ },
+ blocked () {
+ return this.user.statusnet_blocking
+ }
+ },
+ components: {
+ BasicUserCard
+ },
+ methods: {
+ unblockUser () {
+ this.progress = true
+ this.$store.dispatch('unblockUser', this.user.id).then(() => {
+ this.progress = false
+ })
+ },
+ blockUser () {
+ this.progress = true
+ this.$store.dispatch('blockUser', this.user.id).then(() => {
+ this.progress = false
+ })
+ }
+ }
+}
+
+export default BlockCard
diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue
new file mode 100644
index 00000000..8eb56e25
--- /dev/null
+++ b/src/components/block_card/block_card.vue
@@ -0,0 +1,34 @@
+<template>
+ <basic-user-card :user="user">
+ <div class="block-card-content-container">
+ <button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked">
+ <template v-if="progress">
+ {{ $t('user_card.unblock_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.unblock') }}
+ </template>
+ </button>
+ <button class="btn btn-default" @click="blockUser" :disabled="progress" v-else>
+ <template v-if="progress">
+ {{ $t('user_card.block_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.block') }}
+ </template>
+ </button>
+ </div>
+ </basic-user-card>
+</template>
+
+<script src="./block_card.js"></script>
+
+<style lang="scss">
+.block-card-content-container {
+ margin-top: 0.5em;
+ text-align: right;
+ button {
+ width: 10em;
+ }
+}
+</style>
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index bf65efc5..b37469ac 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -3,8 +3,8 @@
<div class="panel panel-default">
<div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
<div class="title">
- {{$t('chat.title')}}
- <i class="icon-cancel" style="float: right;" v-if="floating"></i>
+ <span>{{$t('chat.title')}}</span>
+ <i class="icon-cancel" v-if="floating"></i>
</div>
</div>
<div class="chat-window" v-chat-scroll>
@@ -98,4 +98,11 @@
resize: none;
}
}
+
+.chat-panel {
+ .title {
+ display: flex;
+ justify-content: space-between;
+ }
+}
</style>
diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js
new file mode 100644
index 00000000..425c9c3e
--- /dev/null
+++ b/src/components/follow_card/follow_card.js
@@ -0,0 +1,45 @@
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
+
+const FollowCard = {
+ props: [
+ 'user',
+ 'noFollowsYou'
+ ],
+ data () {
+ return {
+ inProgress: false,
+ requestSent: false,
+ updated: false
+ }
+ },
+ components: {
+ BasicUserCard
+ },
+ computed: {
+ isMe () { return this.$store.state.users.currentUser.id === this.user.id },
+ following () { return this.updated ? this.updated.following : this.user.following },
+ showFollow () {
+ return !this.following || this.updated && !this.updated.following
+ }
+ },
+ methods: {
+ followUser () {
+ this.inProgress = true
+ requestFollow(this.user, this.$store).then(({ sent, updated }) => {
+ this.inProgress = false
+ this.requestSent = sent
+ this.updated = updated
+ })
+ },
+ unfollowUser () {
+ this.inProgress = true
+ requestUnfollow(this.user, this.$store).then(({ updated }) => {
+ this.inProgress = false
+ this.updated = updated
+ })
+ }
+ }
+}
+
+export default FollowCard
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
new file mode 100644
index 00000000..6cb064eb
--- /dev/null
+++ b/src/components/follow_card/follow_card.vue
@@ -0,0 +1,53 @@
+<template>
+ <basic-user-card :user="user">
+ <div class="follow-card-content-container">
+ <span class="faint" v-if="!noFollowsYou && user.follows_you">
+ {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
+ </span>
+ <button
+ v-if="showFollow"
+ class="btn btn-default"
+ @click="followUser"
+ :disabled="inProgress"
+ :title="requestSent ? $t('user_card.follow_again') : ''"
+ >
+ <template v-if="inProgress">
+ {{ $t('user_card.follow_progress') }}
+ </template>
+ <template v-else-if="requestSent">
+ {{ $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="inProgress">
+ <template v-if="inProgress">
+ {{ $t('user_card.follow_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.follow_unfollow') }}
+ </template>
+ </button>
+ </div>
+ </basic-user-card>
+</template>
+
+<script src="./follow_card.js"></script>
+
+<style lang="scss">
+.follow-card-content-container {
+ 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;
+ }
+}
+</style>
diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js
deleted file mode 100644
index acdb216d..00000000
--- a/src/components/follow_list/follow_list.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import UserCard from '../user_card/user_card.vue'
-
-const FollowList = {
- data () {
- return {
- loading: false,
- bottomedOut: false,
- error: false
- }
- },
- props: ['userId', 'showFollowers'],
- created () {
- window.addEventListener('scroll', this.scrollLoad)
- if (this.entries.length === 0) {
- this.fetchEntries()
- }
- },
- destroyed () {
- window.removeEventListener('scroll', this.scrollLoad)
- this.$store.dispatch('clearFriendsAndFollowers', this.userId)
- },
- computed: {
- user () {
- return this.$store.getters.userById(this.userId)
- },
- entries () {
- return this.showFollowers ? this.user.followers : this.user.friends
- },
- showActions () { return this.$store.state.users.currentUser.id === this.userId }
- },
- methods: {
- fetchEntries () {
- if (!this.loading) {
- const command = this.showFollowers ? 'addFollowers' : 'addFriends'
- this.loading = true
- this.$store.dispatch(command, this.userId).then(entries => {
- this.error = false
- this.loading = false
- this.bottomedOut = entries.length === 0
- }).catch(() => {
- this.error = true
- this.loading = false
- })
- }
- },
- scrollLoad (e) {
- const bodyBRect = document.body.getBoundingClientRect()
- const height = Math.max(bodyBRect.height, -(bodyBRect.y))
- if (this.loading === false &&
- this.bottomedOut === false &&
- this.$el.offsetHeight > 0 &&
- (window.innerHeight + window.pageYOffset) >= (height - 750)
- ) {
- this.fetchEntries()
- }
- }
- },
- components: {
- UserCard
- }
-}
-
-export default FollowList
diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue
deleted file mode 100644
index 7be2e7b7..00000000
--- a/src/components/follow_list/follow_list.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<template>
- <div class="follow-list">
- <user-card
- v-for="entry in entries"
- :key="entry.id" :user="entry"
- :showFollows="!showFollowers"
- :showActions="showActions"
- />
- <div class="text-center panel-footer">
- <a v-if="error" @click="fetchEntries" class="alert error">
- {{$t('general.generic_error')}}
- </a>
- <i v-else-if="loading" class="icon-spin3 animate-spin"/>
- <span v-else-if="bottomedOut"></span>
- <a v-else @click="fetchEntries">{{$t('general.more')}}</a>
- </div>
- </div>
-</template>
-
-<script src="./follow_list.js"></script>
-
-<style lang="scss">
-
-.follow-list {
- .panel-footer {
- padding: 10px;
- }
-
- .error {
- font-size: 14px;
- }
-}
-
-</style>
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
new file mode 100644
index 00000000..1a00a1c1
--- /dev/null
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -0,0 +1,20 @@
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+
+const FollowRequestCard = {
+ props: ['user'],
+ components: {
+ BasicUserCard
+ },
+ methods: {
+ approveUser () {
+ this.$store.state.api.backendInteractor.approveUser(this.user.id)
+ this.$store.dispatch('removeFollowRequest', this.user)
+ },
+ denyUser () {
+ this.$store.state.api.backendInteractor.denyUser(this.user.id)
+ this.$store.dispatch('removeFollowRequest', this.user)
+ }
+ }
+}
+
+export default FollowRequestCard
diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue
new file mode 100644
index 00000000..4a3bbba4
--- /dev/null
+++ b/src/components/follow_request_card/follow_request_card.vue
@@ -0,0 +1,29 @@
+<template>
+ <basic-user-card :user="user">
+ <div class="follow-request-card-content-container">
+ <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>
+ </basic-user-card>
+</template>
+
+<script src="./follow_request_card.js"></script>
+
+<style lang="scss">
+.follow-request-card-content-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ button {
+ margin-top: 0.5em;
+ margin-right: 0.5em;
+ flex: 1 1;
+ max-width: 12em;
+ min-width: 8em;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+}
+</style>
diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js
index 11a228aa..704a76c6 100644
--- a/src/components/follow_requests/follow_requests.js
+++ b/src/components/follow_requests/follow_requests.js
@@ -1,22 +1,13 @@
-import UserCard from '../user_card/user_card.vue'
+import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
const FollowRequests = {
components: {
- UserCard
- },
- created () {
- this.updateRequests()
+ FollowRequestCard
},
computed: {
requests () {
return this.$store.state.api.followRequests
}
- },
- methods: {
- updateRequests () {
- this.$store.state.api.backendInteractor.fetchFollowRequests()
- .then((requests) => { this.$store.commit('setFollowRequests', requests) })
- }
}
}
diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue
index 87dc4194..b83c2d68 100644
--- a/src/components/follow_requests/follow_requests.vue
+++ b/src/components/follow_requests/follow_requests.vue
@@ -4,7 +4,7 @@
{{$t('nav.friend_requests')}}
</div>
<div class="panel-body">
- <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card>
+ <FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/>
</div>
</div>
</template>
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index 3f90caa9..ea525c95 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -27,7 +27,6 @@
align-content: stretch;
flex-grow: 1;
margin-top: 0.5em;
- margin-bottom: 0.25em;
.attachments, .attachment {
margin: 0 0.5em 0 0;
@@ -36,6 +35,9 @@
box-sizing: border-box;
// to make failed images a bit more noticeable on chromium
min-width: 2em;
+ &:last-child {
+ margin: 0;
+ }
}
.image-attachment {
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js
new file mode 100644
index 00000000..49d51846
--- /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.file)
+ .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) {
+ this.file = fileInput.files[0]
+ let reader = new window.FileReader()
+ reader.onload = (e) => {
+ this.dataUrl = e.target.result
+ this.$emit('open')
+ }
+ reader.readAsDataURL(this.file)
+ this.$emit('changed', this.file, 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/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue
index e4a247c5..64b1a58b 100644
--- a/src/components/link-preview/link-preview.vue
+++ b/src/components/link-preview/link-preview.vue
@@ -23,10 +23,7 @@
flex-direction: row;
cursor: pointer;
overflow: hidden;
-
- // TODO: clean up the random margins in attachments, this makes preview line
- // up with attachments...
- margin-right: 0.5em;
+ margin-top: 0.5em;
.card-image {
flex-shrink: 0;
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 14ae19d4..992d7129 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -11,27 +11,62 @@ const MediaModal = {
showing () {
return this.$store.state.mediaViewer.activated
},
+ media () {
+ return this.$store.state.mediaViewer.media
+ },
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
currentMedia () {
- return this.$store.state.mediaViewer.media[this.currentIndex]
+ return this.media[this.currentIndex]
+ },
+ canNavigate () {
+ return this.media.length > 1
},
type () {
return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
}
},
- created () {
- document.addEventListener('keyup', e => {
- if (e.keyCode === 27 && this.showing) { // escape
- this.hide()
- }
- })
- },
methods: {
hide () {
this.$store.dispatch('closeMediaViewer')
+ },
+ goPrev () {
+ if (this.canNavigate) {
+ const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
+ this.$store.dispatch('setCurrent', this.media[prevIndex])
+ }
+ },
+ goNext () {
+ if (this.canNavigate) {
+ const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
+ this.$store.dispatch('setCurrent', this.media[nextIndex])
+ }
+ },
+ handleKeyupEvent (e) {
+ if (this.showing && e.keyCode === 27) { // escape
+ this.hide()
+ }
+ },
+ handleKeydownEvent (e) {
+ if (!this.showing) {
+ return
+ }
+
+ if (e.keyCode === 39) { // arrow right
+ this.goNext()
+ } else if (e.keyCode === 37) { // arrow left
+ this.goPrev()
+ }
}
+ },
+ mounted () {
+ document.addEventListener('keyup', this.handleKeyupEvent)
+ document.addEventListener('keydown', this.handleKeydownEvent)
+ },
+ destroyed () {
+ document.removeEventListener('keyup', this.handleKeyupEvent)
+ document.removeEventListener('keydown', this.handleKeydownEvent)
}
}
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 796d4e40..427bf12b 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -8,6 +8,22 @@
:controls="true"
@click.stop.native="">
</VideoAttachment>
+ <button
+ :title="$t('media_modal.previous')"
+ class="modal-view-button-arrow modal-view-button-arrow--prev"
+ v-if="canNavigate"
+ @click.stop.prevent="goPrev"
+ >
+ <i class="icon-left-open arrow-icon" />
+ </button>
+ <button
+ :title="$t('media_modal.next')"
+ class="modal-view-button-arrow modal-view-button-arrow--next"
+ v-if="canNavigate"
+ @click.stop.prevent="goNext"
+ >
+ <i class="icon-right-open arrow-icon" />
+ </button>
</div>
</template>
@@ -19,15 +35,29 @@
.modal-view {
z-index: 1000;
position: fixed;
- width: 100vw;
- height: 100vh;
top: 0;
left: 0;
+ right: 0;
+ bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
- cursor: pointer;
+
+ &:hover {
+ .modal-view-button-arrow {
+ opacity: 0.75;
+
+ &:focus,
+ &:hover {
+ outline: none;
+ box-shadow: none;
+ }
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
}
.modal-image {
@@ -35,4 +65,49 @@
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
}
+
+.modal-view-button-arrow {
+ position: absolute;
+ display: block;
+ top: 50%;
+ margin-top: -50px;
+ width: 70px;
+ height: 100px;
+ border: 0;
+ padding: 0;
+ opacity: 0;
+ box-shadow: none;
+ background: none;
+ appearance: none;
+ overflow: visible;
+ cursor: pointer;
+ transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+
+ .arrow-icon {
+ position: absolute;
+ top: 35px;
+ height: 30px;
+ width: 32px;
+ font-size: 14px;
+ line-height: 30px;
+ color: #FFF;
+ text-align: center;
+ background-color: rgba(0,0,0,.3);
+ }
+
+ &--prev {
+ left: 0;
+ .arrow-icon {
+ left: 6px;
+ }
+ }
+
+ &--next {
+ right: 0;
+ .arrow-icon {
+ right: 6px;
+ }
+ }
+}
+
</style>
diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js
new file mode 100644
index 00000000..5dd0a9e5
--- /dev/null
+++ b/src/components/mute_card/mute_card.js
@@ -0,0 +1,37 @@
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+
+const MuteCard = {
+ props: ['userId'],
+ data () {
+ return {
+ progress: false
+ }
+ },
+ computed: {
+ user () {
+ return this.$store.getters.userById(this.userId)
+ },
+ muted () {
+ return this.user.muted
+ }
+ },
+ components: {
+ BasicUserCard
+ },
+ methods: {
+ unmuteUser () {
+ this.progress = true
+ this.$store.dispatch('unmuteUser', this.user.id).then(() => {
+ this.progress = false
+ })
+ },
+ muteUser () {
+ this.progress = true
+ this.$store.dispatch('muteUser', this.user.id).then(() => {
+ this.progress = false
+ })
+ }
+ }
+}
+
+export default MuteCard
diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue
new file mode 100644
index 00000000..e1bfe20b
--- /dev/null
+++ b/src/components/mute_card/mute_card.vue
@@ -0,0 +1,24 @@
+<template>
+ <basic-user-card :user="user">
+ <template slot="secondary-area">
+ <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted">
+ <template v-if="progress">
+ {{ $t('user_card.unmute_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.unmute') }}
+ </template>
+ </button>
+ <button class="btn btn-default" @click="muteUser" :disabled="progress" v-else>
+ <template v-if="progress">
+ {{ $t('user_card.mute_progress') }}
+ </template>
+ <template v-else>
+ {{ $t('user_card.mute') }}
+ </template>
+ </button>
+ </template>
+ </basic-user-card>
+</template>
+
+<script src="./mute_card.js"></script> \ No newline at end of file
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index ea5d7ea4..aa3f7605 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,10 +1,23 @@
+import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
+
const NavPanel = {
+ created () {
+ if (this.currentUser && this.currentUser.locked) {
+ const store = this.$store
+ const credentials = store.state.users.currentUser.credentials
+
+ followRequestFetcher.startFetching({ store, credentials })
+ }
+ },
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
chat () {
return this.$store.state.chat.channel
+ },
+ followRequestCount () {
+ return this.$store.state.api.followRequests.length
}
}
}
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 3aa0a793..7a7212fb 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -19,7 +19,10 @@
</li>
<li v-if='currentUser && currentUser.locked'>
<router-link :to="{ name: 'friend-requests' }">
- {{ $t("nav.friend_requests") }}
+ {{ $t("nav.friend_requests")}}
+ <span v-if='followRequestCount > 0' class="badge follow-request-count">
+ {{followRequestCount}}
+ </span>
</router-link>
</li>
<li>
@@ -52,6 +55,12 @@
padding: 0;
}
+.follow-request-count {
+ margin: -6px 10px;
+ background-color: $fallback--bg;
+ background-color: var(--input, $fallback--faint);
+}
+
.nav-panel li {
border-bottom: 1px solid;
border-color: $fallback--border;
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index a0a55cba..87925cfc 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -25,7 +25,11 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
- <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
+ <div class="timeago">
+ <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
+ <timeago :since="notification.action.created_at" :auto-update="240"></timeago>
+ </router-link>
+ </div>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
<router-link :to="userProfileLink(notification.action.user)">
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index bc81d45c..2240c10a 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -103,6 +103,7 @@
flex: 1 1 0;
display: flex;
flex-wrap: nowrap;
+ justify-content: space-between;
.name-and-action {
flex: 1;
@@ -123,9 +124,9 @@
object-fit: contain
}
}
+
.timeago {
- float: right;
- font-size: 12px;
+ margin-right: .2em;
}
.icon-retweet.lit {
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 8e30264d..23a2c7e2 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,8 +1,8 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
-import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
-import { reject, map, uniqBy } from 'lodash'
+import Completion from '../../services/completion/completion.js'
+import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
@@ -28,10 +28,13 @@ const PostStatusForm = {
'subject'
],
components: {
- MediaUpload,
- AutoCompleteInput
+ MediaUpload
},
mounted () {
+ this.resize(this.$refs.textarea)
+ const textLength = this.$refs.textarea.value.length
+ this.$refs.textarea.setSelectionRange(textLength, textLength)
+
if (this.replyTo) {
this.$refs.textarea.focus()
}
@@ -53,18 +56,25 @@ const PostStatusForm = {
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
+ const contentType = typeof this.$store.state.config.postContentType === 'undefined'
+ ? this.$store.state.instance.postContentType
+ : this.$store.state.config.postContentType
+
return {
dropFiles: [],
submitDisabled: false,
error: null,
posting: false,
+ highlighted: 0,
newStatus: {
spoilerText: this.subject || '',
status: statusText,
nsfw: false,
files: [],
- visibility: scope
- }
+ visibility: scope,
+ contentType
+ },
+ caret: 0
}
},
computed: {
@@ -76,6 +86,59 @@ const PostStatusForm = {
direct: { selected: this.newStatus.visibility === 'direct' }
}
},
+ candidates () {
+ const firstchar = this.textAtCaret.charAt(0)
+ if (firstchar === '@') {
+ const query = this.textAtCaret.slice(1).toUpperCase()
+ const matchedUsers = filter(this.users, (user) => {
+ return user.screen_name.toUpperCase().startsWith(query) ||
+ user.name && user.name.toUpperCase().startsWith(query)
+ })
+ if (matchedUsers.length <= 0) {
+ return false
+ }
+ // eslint-disable-next-line camelcase
+ return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
+ // eslint-disable-next-line camelcase
+ screen_name: `@${screen_name}`,
+ name: name,
+ img: profile_image_url_original,
+ highlighted: index === this.highlighted
+ }))
+ } else if (firstchar === ':') {
+ if (this.textAtCaret === ':') { return }
+ const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
+ if (matchedEmoji.length <= 0) {
+ return false
+ }
+ return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
+ screen_name: `:${shortcode}:`,
+ name: '',
+ utf: utf || '',
+ // eslint-disable-next-line camelcase
+ img: utf ? '' : this.$store.state.instance.server + image_url,
+ highlighted: index === this.highlighted
+ }))
+ } else {
+ return false
+ }
+ },
+ textAtCaret () {
+ return (this.wordAtCaret || {}).word || ''
+ },
+ wordAtCaret () {
+ const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
+ return word
+ },
+ users () {
+ return this.$store.state.users.users
+ },
+ emoji () {
+ return this.$store.state.instance.emoji || []
+ },
+ customEmoji () {
+ return this.$store.state.instance.customEmoji || []
+ },
statusLength () {
return this.newStatus.status.length
},
@@ -109,15 +172,58 @@ const PostStatusForm = {
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
- defaultPostContentType () {
- return typeof this.$store.state.config.postContentType === 'undefined'
- ? this.$store.state.instance.postContentType
- : this.$store.state.config.postContentType
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
}
},
methods: {
- postStatusCopy () {
- this.postStatus(this.newStatus)
+ replace (replacement) {
+ this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
+ const el = this.$el.querySelector('textarea')
+ el.focus()
+ this.caret = 0
+ },
+ replaceCandidate (e) {
+ const len = this.candidates.length || 0
+ if (this.textAtCaret === ':' || e.ctrlKey) { return }
+ if (len > 0) {
+ e.preventDefault()
+ const candidate = this.candidates[this.highlighted]
+ const replacement = candidate.utf || (candidate.screen_name + ' ')
+ this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
+ const el = this.$el.querySelector('textarea')
+ el.focus()
+ this.caret = 0
+ this.highlighted = 0
+ }
+ },
+ cycleBackward (e) {
+ const len = this.candidates.length || 0
+ if (len > 0) {
+ e.preventDefault()
+ this.highlighted -= 1
+ if (this.highlighted < 0) {
+ this.highlighted = this.candidates.length - 1
+ }
+ } else {
+ this.highlighted = 0
+ }
+ },
+ cycleForward (e) {
+ const len = this.candidates.length || 0
+ if (len > 0) {
+ if (e.shiftKey) { return }
+ e.preventDefault()
+ this.highlighted += 1
+ if (this.highlighted >= len) {
+ this.highlighted = 0
+ }
+ } else {
+ this.highlighted = 0
+ }
+ },
+ setCaret ({target: {selectionStart}}) {
+ this.caret = selectionStart
},
postStatus (newStatus) {
if (this.posting) { return }
@@ -202,6 +308,18 @@ const PostStatusForm = {
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
+ resize (e) {
+ const target = e.target || e
+ if (!(target instanceof window.Element)) { return }
+ const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
+ Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
+ // Auto is needed to make textbox shrink when removing lines
+ target.style.height = 'auto'
+ target.style.height = `${target.scrollHeight - vertPadding}px`
+ if (target.value === '') {
+ target.style.height = null
+ }
+ },
clearError () {
this.error = null
},
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index ef3a7901..0ddde4ea 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -16,23 +16,31 @@
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
class="form-cw">
- <auto-complete-input v-model="newStatus.status"
- :classObj="{ 'form-control': true }"
- :placeholder="$t('post_status.default')"
- :autoResize="true"
- :multiline="true"
- :drop="fileDrop"
- :dragoverPrevent="fileDrag"
- :paste="paste"
- :keydownMetaEnter="postStatusCopy"
- :keyupCtrlEnter="postStatusCopy"/>
+ <textarea
+ ref="textarea"
+ @click="setCaret"
+ @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
+ @keydown.down="cycleForward"
+ @keydown.up="cycleBackward"
+ @keydown.shift.tab="cycleBackward"
+ @keydown.tab="cycleForward"
+ @keydown.enter="replaceCandidate"
+ @keydown.meta.enter="postStatus(newStatus)"
+ @keyup.ctrl.enter="postStatus(newStatus)"
+ @drop="fileDrop"
+ @dragover.prevent="fileDrag"
+ @input="resize"
+ @paste="paste"
+ :disabled="posting"
+ >
+ </textarea>
<div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
- <select id="post-content-type" v-model="defaultPostContentType" class="form-control">
- <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option>
- <option value="text/html">HTML</option>
- <option value="text/markdown">Markdown</option>
+ <select id="post-content-type" v-model="newStatus.contentType" class="form-control">
+ <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
+ {{$t(`post_status.content_type["${postFormat}"]`)}}
+ </option>
</select>
<i class="icon-down-open"></i>
</label>
@@ -46,6 +54,17 @@
</div>
</div>
</div>
+ <div style="position:relative;" v-if="candidates">
+ <div class="autocomplete-panel">
+ <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
+ <div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
+ <span v-if="candidate.img"><img :src="candidate.img"></img></span>
+ <span v-else>{{candidate.utf}}</span>
+ <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
+ </div>
+ </div>
+ </div>
+ </div>
<div class='form-bottom'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
@@ -101,6 +120,14 @@
}
}
+.post-status-form {
+ .visibility-tray {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: row-reverse;
+ }
+}
+
.post-status-form, .login {
.form-bottom {
display: flex;
@@ -233,5 +260,52 @@
cursor: pointer;
z-index: 4;
}
+
+ .autocomplete-panel {
+ margin: 0 0.5em 0 0.5em;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ position: absolute;
+ z-index: 1;
+ box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+ // this doesn't match original but i don't care, making it uniform.
+ box-shadow: var(--popupShadow);
+ min-width: 75%;
+ background: $fallback--bg;
+ background: var(--bg, $fallback--bg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+
+ .autocomplete {
+ cursor: pointer;
+ padding: 0.2em 0.4em 0.2em 0.4em;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ display: flex;
+
+ img {
+ width: 24px;
+ height: 24px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ object-fit: contain;
+ }
+
+ span {
+ line-height: 24px;
+ margin: 0 0.1em 0 0.2em;
+ }
+
+ small {
+ margin-left: .5em;
+ color: $fallback--faint;
+ color: var(--faint, $fallback--faint);
+ }
+
+ &.highlighted {
+ background-color: $fallback--fg;
+ background-color: var(--lightBg, $fallback--fg);
+ }
+ }
}
</style>
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index f428ead3..e22b308d 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -9,7 +9,7 @@
<div class='text-fields'>
<div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }">
<label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label>
- <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'>
+ <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')">
</div>
<div class="form-error" v-if="$v.user.username.$dirty">
<ul>
@@ -21,7 +21,7 @@
<div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }">
<label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label>
- <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'>
+ <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')">
</div>
<div class="form-error" v-if="$v.user.fullname.$dirty">
<ul>
@@ -44,8 +44,8 @@
</div>
<div class='form-group'>
- <label class='form--label' for='bio'>{{$t('registration.bio')}}</label>
- <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'>
+ <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label>
+ <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea>
</div>
<div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }">
@@ -139,6 +139,10 @@ $validations-cRed: #f04124;
flex-direction: column;
}
+ textarea {
+ min-height: 100px;
+ }
+
.form-group {
display: flex;
flex-direction: column;
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index 534a9839..979457a5 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -12,6 +12,7 @@ const settings = {
return {
hideAttachmentsLocal: user.hideAttachments,
hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
+ maxThumbnails: user.maxThumbnails,
hideNsfwLocal: user.hideNsfw,
useOneClickNsfw: user.useOneClickNsfw,
hideISPLocal: user.hideISP,
@@ -91,7 +92,11 @@ const settings = {
},
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
- }
+ },
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
+ },
+ instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }
},
watch: {
hideAttachmentsLocal (value) {
@@ -185,6 +190,10 @@ const settings = {
},
useContainFit (value) {
this.$store.dispatch('setOption', { name: 'useContainFit', value })
+ },
+ maxThumbnails (value) {
+ value = this.maxThumbnails = Math.floor(Math.max(value, 0))
+ this.$store.dispatch('setOption', { name: 'maxThumbnails', value })
}
}
}
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index dfb2e49d..d2346747 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>
@@ -105,17 +105,9 @@
{{$t('settings.post_status_content_type')}}
<label for="postContentType" class="select">
<select id="postContentType" v-model="postContentTypeLocal">
- <option value="text/plain">
- {{$t('settings.status_content_type_plain')}}
- {{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}}
- </option>
- <option value="text/html">
- HTML
- {{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}}
- </option>
- <option value="text/markdown">
- Markdown
- {{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}}
+ <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
+ {{$t(`post_status.content_type["${postFormat}"]`)}}
+ {{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}}
</option>
</select>
<i class="icon-down-open"/>
@@ -137,6 +129,10 @@
<label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label>
</li>
<li>
+ <label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label>
+ <input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1">
+ </li>
+ <li>
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label>
</li>
@@ -146,7 +142,7 @@
<label for="preloadImage">{{$t('settings.preload_images')}}</label>
</li>
<li>
- <input type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw">
+ <input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw">
<label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label>
</li>
</ul>
@@ -311,25 +307,15 @@
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;
padding: 0 2em;
}
+
+ .number-input {
+ max-width: 6em;
+ }
}
.select-multiple {
display: flex;
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 40ffa1dd..b5c49059 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -32,6 +32,9 @@ const SideDrawer = {
},
sitename () {
return this.$store.state.instance.name
+ },
+ followRequestCount () {
+ return this.$store.state.api.followRequests.length
}
},
methods: {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index a6c6f237..6996380d 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -45,6 +45,10 @@
<li v-if="currentUser && currentUser.locked" @click="toggleDrawer">
<router-link to='/friend-requests'>
{{ $t("nav.friend_requests") }}
+ <span v-if='followRequestCount > 0' class="badge follow-request-count">
+ {{followRequestCount}}
+ </span>
+
</router-link>
</li>
<li @click="toggleDrawer">
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 0273a5be..fbbca6c4 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -23,7 +23,7 @@ const Status = {
'highlight',
'compact',
'replies',
- 'noReplyLinks',
+ 'isPreview',
'noHeading',
'inlineExpanded'
],
@@ -40,8 +40,7 @@ const Status = {
expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? !this.$store.state.instance.collapseMessageWithSubject
: !this.$store.state.config.collapseMessageWithSubject,
- betterShadow: this.$store.state.interface.browserSupport.cssFilter,
- maxAttachments: 9
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
computed: {
@@ -225,7 +224,7 @@ const Status = {
attachmentSize () {
if ((this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation) ||
- (this.status.attachments.length > this.maxAttachments)) {
+ (this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
@@ -249,6 +248,9 @@ const Status = {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
+ },
+ maxThumbnails () {
+ return this.$store.state.config.maxThumbnails
}
},
components: {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index aae365d1..4dd20362 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,6 +1,6 @@
<template>
<div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
- <template v-if="muted && !noReplyLinks">
+ <template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
<router-link :to="userProfileLink">
@@ -13,7 +13,7 @@
</template>
<template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
- <UserAvatar v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
+ <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
<span class="user-name">
<router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/>
@@ -31,57 +31,69 @@
</router-link>
</div>
<div class="status-body">
- <div class="usercard media-body" v-if="userExpanded">
+ <div class="usercard" v-if="userExpanded">
<user-card-content :user="status.user" :switcher="false"></user-card-content>
</div>
- <div v-if="!noHeading" class="media-body container media-heading">
- <div class="media-heading-left">
- <div class="name-and-links">
+ <div v-if="!noHeading" class="media-heading">
+ <div class="heading-name-row">
+ <div class="name-and-account-name">
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4>
- <span class="links">
- <router-link :to="userProfileLink">
- {{status.user.screen_name}}
- </router-link>
- <span v-if="isReply" class="faint reply-info">
- <i class="icon-right-open"></i>
- <router-link :to="replyProfileLink">
- {{replyToName}}
- </router-link>
- </span>
- <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :aria-label="$t('tool_tip.reply')">
- <i class="button-icon icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i>
+ <router-link class="account-name" :to="userProfileLink">
+ {{status.user.screen_name}}
+ </router-link>
+ </div>
+
+ <span class="heading-right">
+ <router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }">
+ <timeago :since="status.created_at" :auto-update="60"></timeago>
+ </router-link>
+ <div class="button-icon visibility-icon" v-if="status.visibility">
+ <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
+ </div>
+ <a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source">
+ <i class="button-icon icon-link-ext-alt"></i>
+ </a>
+ <template v-if="expandable && !isPreview">
+ <a href="#" @click.prevent="toggleExpanded" title="Expand">
+ <i class="button-icon icon-plus-squared"></i>
</a>
+ </template>
+ <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a>
+ </span>
+ </div>
+
+ <div class="heading-reply-row">
+ <div v-if="isReply" class="reply-to-and-accountname">
+ <a class="reply-to"
+ href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+ :aria-label="$t('tool_tip.reply')"
+ @mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)"
+ @mouseleave.prevent.stop="replyLeave()"
+ >
+ <i class="button-icon icon-reply" v-if="!isPreview"></i>
+ <span class="faint-link reply-to-text">{{$t('status.reply_to')}}</span>
+ </a>
+ <router-link :to="replyProfileLink">
+ {{replyToName}}
+ </router-link>
+ <span class="faint replies-separator" v-if="replies && replies.length">
+ -
</span>
</div>
- <h4 class="replies" v-if="inConversation && !noReplyLinks">
- <small v-if="replies.length">Replies:</small>
- <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}}&nbsp;</a>
- </small>
- </h4>
- </div>
- <div class="media-heading-right">
- <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }">
- <timeago :since="status.created_at" :auto-update="60"></timeago>
- </router-link>
- <div class="button-icon visibility-icon" v-if="status.visibility">
- <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
+ <div class="replies" v-if="inConversation && !isPreview">
+ <span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span>
+ <span class="reply-link faint" v-if="replies" v-for="reply in replies">
+ <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a>
+ </span>
</div>
- <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source">
- <i class="button-icon icon-link-ext-alt"></i>
- </a>
- <template v-if="expandable">
- <a href="#" @click.prevent="toggleExpanded" title="Expand">
- <i class="button-icon icon-plus-squared"></i>
- </a>
- </template>
- <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a>
</div>
+
+
</div>
<div v-if="showPreview" class="status-preview-container">
- <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status>
+ <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status>
<div class="status-preview status-preview-loading" v-else>
<i class="icon-spin4 animate-spin"></i>
</div>
@@ -123,7 +135,7 @@
<link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" />
</div>
- <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'>
+ <div v-if="!noHeading && !isPreview" class='status-actions media-body'>
<div v-if="loggedIn">
<a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')">
<i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i>
@@ -147,6 +159,8 @@
<style lang="scss">
@import '../../_variables.scss';
+$status-margin: 0.75em;
+
.status-body {
flex: 1;
min-width: 0;
@@ -202,13 +216,16 @@
}
}
+.media-left {
+ margin-right: $status-margin;
+}
+
.status-el {
hyphens: auto;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
- line-height: 18px;
min-width: 0;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
@@ -229,22 +246,34 @@
.media-body {
flex: 1;
padding: 0;
- margin: 0 0 0.25em 0.8em;
}
.usercard {
- margin-bottom: .7em
+ margin: 0;
+ margin-bottom: $status-margin;
}
- .media-heading {
- flex-wrap: nowrap;
- line-height: 18px;
+ .user-name {
+ white-space: nowrap;
+ font-size: 14px;
+ overflow: hidden;
+ flex-shrink: 0;
+ max-width: 85%;
+ font-weight: bold;
+
+ img {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
}
- .media-heading-left {
+ .media-heading {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
+ margin-bottom: 0.5em;
a {
display: inline-block;
@@ -254,83 +283,102 @@
small {
font-weight: lighter;
}
- h4 {
- white-space: nowrap;
- font-size: 14px;
- margin-right: 0.25em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .name-and-links {
+
+ .heading-name-row {
padding: 0;
- flex: 1 0;
display: flex;
- flex-wrap: wrap;
- align-items: baseline;
+ justify-content: space-between;
+ line-height: 18px;
+
+ .name-and-account-name {
+ display: flex;
+ min-width: 0;
+ }
.user-name {
- margin-right: .45em;
+ flex-shrink: 1;
+ margin-right: 0.4em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
+ .account-name {
+ min-width: 1.6em;
+ margin-right: 0.4em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 0;
}
}
- .links {
+ .heading-right {
display: flex;
+ flex-shrink: 0;
+ }
+
+ .timeago {
+ margin-right: 0.2em;
+ }
+
+ .heading-reply-row {
+ align-content: baseline;
font-size: 12px;
- color: $fallback--link;
- color: var(--link, $fallback--link);
+ line-height: 18px;
max-width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+
a {
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
- & > span {
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
- & > a:last-child {
- flex-shrink: 0;
+ }
+
+ .reply-to-and-accountname {
+ display: flex;
+ height: 18px;
+ margin-right: 0.5em;
+ overflow: hidden;
+ max-width: 100%;
+ .icon-reply {
+ transform: scaleX(-1);
}
}
+
.reply-info {
display: flex;
}
- .replies {
- line-height: 16px;
+
+ .reply-to {
+ display: flex;
}
- .reply-link {
- margin-right: 0.2em;
+
+ .reply-to-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 0 0.4em 0 0.2em;
}
- }
- .media-heading-right {
- display: inline-flex;
- flex-shrink: 0;
- flex-wrap: nowrap;
- margin-left: .25em;
- align-self: baseline;
+ .replies-separator {
+ margin-left: 0.4em;
+ }
- .timeago {
- margin-right: 0.2em;
+ .replies {
+ line-height: 18px;
font-size: 12px;
- align-self: last baseline;
+ display: flex;
+ flex-wrap: wrap;
+ & > * {
+ margin-right: 0.4em;
+ }
}
- > * {
- margin-left: 0.2em;
- }
- a:hover i {
- color: $fallback--text;
- color: var(--text, $fallback--text);
+ .reply-link {
+ height: 17px;
}
}
@@ -366,14 +414,19 @@
}
.status-content {
- margin-right: 0.5em;
font-family: var(--postFont, sans-serif);
+ line-height: 1.4em;
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
+
+ &.emoji {
+ width: 32px;
+ height: 32px;
+ }
}
blockquote {
@@ -390,9 +443,11 @@
}
p {
- margin: 0;
- margin-top: 0.2em;
- margin-bottom: 0.5em;
+ margin: 0 0 1em 0;
+ }
+
+ p:last-child {
+ margin: 0 0 0 0;
}
h1 {
@@ -417,7 +472,7 @@
}
.retweet-info {
- padding: 0.4em 0.6em 0 0.6em;
+ padding: 0.4em $status-margin;
margin: 0;
.avatar.still-image {
@@ -488,10 +543,10 @@
.status-actions {
width: 100%;
display: flex;
+ margin-top: $status-margin;
div, favorite-button {
- padding-top: 0.25em;
- max-width: 6em;
+ max-width: 4em;
flex: 1;
}
}
@@ -517,9 +572,9 @@
.status {
display: flex;
- padding: 0.6em;
+ padding: $status-margin;
&.is-retweet {
- padding-top: 0.1em;
+ padding-top: 0;
}
}
@@ -554,7 +609,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/timeline/timeline.js b/src/components/timeline/timeline.js
index 85e0a055..655bfb3f 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,7 +1,6 @@
import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
-import UserCard from '../user_card/user_card.vue'
import { throttle } from 'lodash'
const Timeline = {
@@ -11,7 +10,8 @@ const Timeline = {
'title',
'userId',
'tag',
- 'embedded'
+ 'embedded',
+ 'count'
],
data () {
return {
@@ -43,8 +43,7 @@ const Timeline = {
},
components: {
Status,
- StatusOrConversation,
- UserCard
+ StatusOrConversation
},
created () {
const store = this.$store
@@ -53,6 +52,8 @@ const Timeline = {
window.addEventListener('scroll', this.scrollLoad)
+ if (this.timelineName === 'friends' && !credentials) { return false }
+
timelineFetcher.fetchAndUpdate({
store,
credentials,
@@ -67,14 +68,21 @@ const Timeline = {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
this.unfocused = document.hidden
}
+ window.addEventListener('keydown', this.handleShortKey)
},
destroyed () {
window.removeEventListener('scroll', this.scrollLoad)
+ window.removeEventListener('keydown', this.handleShortKey)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
+ handleShortKey (e) {
+ if (e.key === '.') this.showNewStatuses()
+ },
showNewStatuses () {
+ if (this.newStatusCount === 0) return
+
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@@ -98,7 +106,7 @@ const Timeline = {
tag: this.tag
}).then(statuses => {
store.commit('setLoading', { timeline: this.timelineName, value: false })
- if (statuses.length === 0) {
+ if (statuses && statuses.length === 0) {
this.bottomedOut = true
}
})
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index e3eea3bd..8f28d65c 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -20,7 +20,10 @@
</div>
</div>
<div :class="classes.footer">
- <div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint">
+ <div v-if="count===0" class="new-status-notification text-center panel-footer faint">
+ {{$t('timeline.no_statuses')}}
+ </div>
+ <div v-else-if="bottomedOut" class="new-status-notification text-center panel-footer faint">
{{$t('timeline.no_more_statuses')}}
</div>
<a v-else-if="!timeline.loading" href="#" v-on:click.prevent='fetchOlderStatuses()'>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
deleted file mode 100644
index a4c84716..00000000
--- a/src/components/user_card/user_card.js
+++ /dev/null
@@ -1,65 +0,0 @@
-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',
- 'showApproval',
- 'showActions'
- ],
- data () {
- return {
- userExpanded: false,
- followRequestInProgress: false,
- followRequestSent: false,
- updated: false
- }
- },
- components: {
- UserCardContent,
- UserAvatar
- },
- computed: {
- currentUser () { return this.$store.state.users.currentUser },
- following () { return this.updated ? this.updated.following : this.user.following },
- showFollow () {
- return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following)
- }
- },
- methods: {
- toggleUserExpanded () {
- this.userExpanded = !this.userExpanded
- },
- approveUser () {
- this.$store.state.api.backendInteractor.approveUser(this.user.id)
- this.$store.dispatch('removeFollowRequest', this.user)
- },
- denyUser () {
- this.$store.state.api.backendInteractor.denyUser(this.user.id)
- this.$store.dispatch('removeFollowRequest', this.user)
- },
- 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
- })
- }
- }
-}
-
-export default UserCard
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
deleted file mode 100644
index 12960c02..00000000
--- a/src/components/user_card/user_card.vue
+++ /dev/null
@@ -1,137 +0,0 @@
-<template>
- <div class="card">
- <router-link :to="userProfileLink(user)">
- <UserAvatar class="avatar" :compact="true" @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" class="user-name">
- <span v-if="user.name_html" v-html="user.name_html"></span>
- <span v-else>{{ user.name }}</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>
- <div class="user-link-action">
- <router-link class='user-screen-name' :to="userProfileLink(user)">
- @{{user.screen_name}}
- </router-link>
- <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="showActions && showFollows && following" class="btn btn-default" @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>
- <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>
-
-<script src="./user_card.js"></script>
-
-<style lang="scss">
-@import '../../_variables.scss';
-
-.name-and-screen-name {
- margin-left: 0.7em;
- margin-top:0.0em;
- text-align: left;
- width: 100%;
- .user-name {
- display: flex;
- justify-content: space-between;
-
- img {
- object-fit: contain;
- height: 16px;
- width: 16px;
- vertical-align: middle;
- }
- }
-
- .user-link-action {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
-
- button {
- margin-top: 3px;
- }
- }
-}
-
-.follows-you {
- margin-left: 2em;
-}
-
-.card {
- display: flex;
- flex: 1 0;
- padding-top: 0.6em;
- padding-right: 1em;
- padding-bottom: 0.6em;
- padding-left: 1em;
- border-bottom: 1px solid;
- margin: 0;
- border-bottom-color: $fallback--border;
- border-bottom-color: var(--border, $fallback--border);
-
- .avatar {
- padding: 0;
- }
-}
-
-.usercard {
- width: fill-available;
- margin: 0.2em 0 0 0.7em;
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- border-style: solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- border-width: 1px;
- overflow: hidden;
-
- .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
-
- p {
- margin-bottom: 0;
- }
-}
-
-.approval {
- button {
- width: 100%;
- margin-bottom: 0.5em;
- }
-}
-</style>
diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue
index 7f9909c4..702c3385 100644
--- a/src/components/user_card_content/user_card_content.vue
+++ b/src/components/user_card_content/user_card_content.vue
@@ -13,7 +13,7 @@
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
- <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser">
+ <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i>
</a>
</div>
@@ -222,6 +222,14 @@
overflow: hidden;
flex: 1 1 auto;
margin-right: 1em;
+ font-size: 15px;
+
+ img {
+ object-fit: contain;
+ height: 16px;
+ width: 16px;
+ vertical-align: middle;
+ }
}
.user-screen-name {
@@ -386,6 +394,24 @@
}
}
-.floater {
+.usercard {
+ width: fill-available;
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ border-style: solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ border-width: 1px;
+ overflow: hidden;
+
+ .panel-heading {
+ background: transparent;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ p {
+ margin-bottom: 0;
+ }
}
</style>
diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js
index 55c6c402..27153f45 100644
--- a/src/components/user_finder/user_finder.js
+++ b/src/components/user_finder/user_finder.js
@@ -8,6 +8,7 @@ const UserFinder = {
methods: {
findUser (username) {
this.$router.push({ name: 'user-search', query: { query: username } })
+ this.$refs.userSearchInput.focus()
},
toggleHidden () {
this.hidden = !this.hidden
diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue
index 37d628fa..a118ffe2 100644
--- a/src/components/user_finder/user_finder.vue
+++ b/src/components/user_finder/user_finder.vue
@@ -4,7 +4,7 @@
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" />
<a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a>
<template v-else>
- <input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
+ <input class="user-finder-input" ref="userSearchInput" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
<button class="btn search-button" @click="findUser(username)">
<i class="icon-search"/>
</button>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index a22b8722..cdf1cee9 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,9 +1,39 @@
+import { compose } from 'vue-compose'
+import get from 'lodash/get'
import UserCardContent from '../user_card_content/user_card_content.vue'
-import UserCard from '../user_card/user_card.vue'
+import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
-import FollowList from '../follow_list/follow_list.vue'
+import withLoadMore from '../../hocs/with_load_more/with_load_more'
+import withList from '../../hocs/with_list/with_list'
+
+const FollowerList = compose(
+ withLoadMore({
+ fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
+ select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []),
+ destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
+ childPropName: 'entries',
+ additionalPropNames: ['userId']
+ }),
+ withList({ getEntryProps: user => ({ user }) })
+)(FollowCard)
+
+const FriendList = compose(
+ withLoadMore({
+ fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
+ select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []),
+ destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
+ childPropName: 'entries',
+ additionalPropNames: ['userId']
+ }),
+ withList({ getEntryProps: user => ({ user }) })
+)(FollowCard)
const UserProfile = {
+ data () {
+ return {
+ error: false
+ }
+ },
created () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
@@ -13,10 +43,20 @@ const UserProfile = {
this.startFetchFavorites()
if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy)
+ .catch((reason) => {
+ const errorMessage = get(reason, 'error.error')
+ if (errorMessage === 'No user with such user_id') { // Known error
+ this.error = this.$t('user_profile.profile_does_not_exist')
+ } else if (errorMessage) {
+ this.error = errorMessage
+ } else {
+ this.error = this.$t('user_profile.profile_loading_error')
+ }
+ })
}
},
destroyed () {
- this.cleanUp(this.userId)
+ this.cleanUp()
},
computed: {
timeline () {
@@ -101,13 +141,16 @@ const UserProfile = {
}
this.cleanUp()
this.startUp()
+ },
+ $route () {
+ this.$refs.tabSwitcher.activateTab(0)()
}
},
components: {
UserCardContent,
- UserCard,
Timeline,
- FollowList
+ FollowerList,
+ FriendList
}
}
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 79461291..8090efa5 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -6,10 +6,11 @@
:switcher="true"
:selected="timeline.viewing"
/>
- <tab-switcher :renderOnlyFocused="true">
+ <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
<Timeline
:label="$t('user_card.statuses')"
:disabled="!user.statuses_count"
+ :count="user.statuses_count"
:embedded="true"
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
@@ -17,16 +18,10 @@
:user-id="fetchBy"
/>
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
- <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" />
- <div class="userlist-placeholder" v-else>
- <i class="icon-spin3 animate-spin"></i>
- </div>
+ <FriendList :userId="userId" />
</div>
<div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count">
- <FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" />
- <div class="userlist-placeholder" v-else>
- <i class="icon-spin3 animate-spin"></i>
- </div>
+ <FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" />
</div>
<Timeline
:label="$t('user_card.media')"
@@ -54,7 +49,8 @@
</div>
</div>
<div class="panel-body">
- <i class="icon-spin3 animate-spin"></i>
+ <span v-if="error">{{ error }}</span>
+ <i class="icon-spin3 animate-spin" v-else></i>
</div>
</div>
</div>
diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js
index 9c026276..55040826 100644
--- a/src/components/user_search/user_search.js
+++ b/src/components/user_search/user_search.js
@@ -1,8 +1,8 @@
-import UserCard from '../user_card/user_card.vue'
+import FollowCard from '../follow_card/follow_card.vue'
import userSearchApi from '../../services/new_api/user_search.js'
const userSearch = {
components: {
- UserCard
+ FollowCard
},
props: [
'query'
@@ -10,7 +10,8 @@ const userSearch = {
data () {
return {
username: '',
- users: []
+ users: [],
+ loading: false
}
},
mounted () {
@@ -24,14 +25,17 @@ const userSearch = {
methods: {
newQuery (query) {
this.$router.push({ name: 'user-search', query: { query } })
+ this.$refs.userSearchInput.focus()
},
search (query) {
if (!query) {
this.users = []
return
}
+ this.loading = true
userSearchApi.search({query, store: this.$store})
.then((res) => {
+ this.loading = false
this.users = res
})
}
diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue
index 3c2bd3fb..1269eea6 100644
--- a/src/components/user_search/user_search.vue
+++ b/src/components/user_search/user_search.vue
@@ -4,13 +4,16 @@
{{$t('nav.user_search')}}
</div>
<div class="user-search-input-container">
- <input class="user-finder-input" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/>
+ <input class="user-finder-input" ref="userSearchInput" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/>
<button class="btn search-button" @click="newQuery(username)">
<i class="icon-search"/>
</button>
</div>
- <div class="panel-body">
- <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card>
+ <div v-if="loading" class="text-center loading-icon">
+ <i class="icon-spin3 animate-spin"/>
+ </div>
+ <div v-else class="panel-body">
+ <FollowCard v-for="user in users" :key="user.id" :user="user"/>
</div>
</div>
</template>
@@ -27,4 +30,8 @@
margin-left: 0.5em;
}
}
+
+.loading-icon {
+ padding: 1em;
+}
</style>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index fa389c3b..c0ab759c 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -1,9 +1,32 @@
-import { unescape } from 'lodash'
-
+import { compose } from 'vue-compose'
+import unescape from 'lodash/unescape'
+import get from 'lodash/get'
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 AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
+import BlockCard from '../block_card/block_card.vue'
+import MuteCard from '../mute_card/mute_card.vue'
+import withSubscription from '../../hocs/with_subscription/with_subscription'
+import withList from '../../hocs/with_list/with_list'
+
+const BlockList = compose(
+ withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchBlocks'),
+ select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
+ childPropName: 'entries'
+ }),
+ withList({ getEntryProps: userId => ({ userId }) })
+)(BlockCard)
+
+const MuteList = compose(
+ withSubscription({
+ fetch: (props, $store) => $store.dispatch('fetchMutes'),
+ select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
+ childPropName: 'entries'
+ }),
+ withList({ getEntryProps: userId => ({ userId }) })
+)(MuteCard)
const UserSettings = {
data () {
@@ -21,14 +44,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,
@@ -40,10 +61,15 @@ const UserSettings = {
activeTab: 'profile'
}
},
+ created () {
+ this.$store.dispatch('fetchTokens')
+ },
components: {
StyleSwitcher,
TabSwitcher,
- AutoCompleteInput
+ ImageCropper,
+ BlockList,
+ MuteList
},
computed: {
user () {
@@ -62,6 +88,18 @@ const UserSettings = {
private: { selected: this.newDefaultScope === 'private' },
direct: { selected: this.newDefaultScope === 'direct' }
}
+ },
+ currentSaveStateNotice () {
+ return this.$store.state.interface.settings.currentSaveStateNotice
+ },
+ oauthTokens () {
+ return this.$store.state.oauthTokens.tokens.map(oauthToken => {
+ return {
+ id: oauthToken.id,
+ appName: oauthToken.app_name,
+ validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
+ }
+ })
}
},
methods: {
@@ -119,35 +157,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, file) {
+ const img = cropper.getCroppedCanvas().toDataURL(file.type)
+ 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) {
@@ -301,6 +319,11 @@ const UserSettings = {
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
+ },
+ revokeToken (id) {
+ if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
+ this.$store.dispatch('revokeToken', id)
+ }
}
}
}
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index ad7c17bd..a1123638 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -1,7 +1,20 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
- {{$t('settings.user_settings')}}
+ <div class="title">
+ {{$t('settings.user_settings')}}
+ </div>
+ <transition name="fade">
+ <template v-if="currentSaveStateNotice">
+ <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error">
+ {{ $t('settings.saving_err') }}
+ </div>
+
+ <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error">
+ {{ $t('settings.saving_ok') }}
+ </div>
+ </template>
+ </transition>
</div>
<div class="panel-body profile-edit">
<tab-switcher>
@@ -9,9 +22,9 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
- <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/>
+ <input class='name-changer' id='username' v-model="newName"></input>
<p>{{$t('settings.bio')}}</p>
- <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/>
+ <textarea class="bio" v-model="newBio"></textarea>
<p>
<input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@@ -48,19 +61,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>
@@ -118,6 +122,30 @@
</div>
<div class="setting-item">
+ <h2>{{$t('settings.oauth_tokens')}}</h2>
+ <table class="oauth-tokens">
+ <thead>
+ <tr>
+ <th>{{$t('settings.app_name')}}</th>
+ <th>{{$t('settings.valid_until')}}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id">
+ <td>{{oauthToken.appName}}</td>
+ <td>{{oauthToken.validUntil}}</td>
+ <td class="actions">
+ <button class="btn btn-default" @click="revokeToken(oauthToken.id)">
+ {{$t('settings.revoke_token')}}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="setting-item">
<h2>{{$t('settings.delete_account')}}</h2>
<p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>
<div v-if="deletingAccount">
@@ -158,6 +186,12 @@
<h2>{{$t('settings.follow_export_processing')}}</h2>
</div>
</div>
+
+ <div :label="$t('settings.blocks_tab')">
+ <block-list :refresh="true">
+ <template slot="empty">{{$t('settings.no_blocks')}}</template>
+ </block-list>
+ </div>
</tab-switcher>
</div>
</div>
@@ -167,6 +201,8 @@
</script>
<style lang="scss">
+@import '../../_variables.scss';
+
.profile-edit {
.bio {
margin: 0;
@@ -193,5 +229,25 @@
.bg {
max-width: 100%;
}
+
+ .current-avatar {
+ display: block;
+ width: 150px;
+ height: 150px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ }
+
+ .oauth-tokens {
+ width: 100%;
+
+ th {
+ text-align: left;
+ }
+
+ .actions {
+ text-align: right;
+ }
+ }
}
</style>
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index 82098fc2..be0b8827 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -1,9 +1,9 @@
import apiService from '../../services/api/api.service.js'
-import UserCard from '../user_card/user_card.vue'
+import FollowCard from '../follow_card/follow_card.vue'
const WhoToFollow = {
components: {
- UserCard
+ FollowCard
},
data () {
return {
diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue
index df2e03c8..1630f5ac 100644
--- a/src/components/who_to_follow/who_to_follow.vue
+++ b/src/components/who_to_follow/who_to_follow.vue
@@ -4,7 +4,7 @@
{{$t('who_to_follow.who_to_follow')}}
</div>
<div class="panel-body">
- <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card>
+ <FollowCard v-for="user in users" :key="user.id" :user="user"/>
</div>
</div>
</template>