Merge branch 'develop' into 'feat/conversation-muting'

# Conflicts:
#   src/services/api/api.service.js
This commit is contained in:
Shpuld Shpludson 2019-07-15 19:09:01 +00:00
commit 3370dd80dc
45 changed files with 717 additions and 387 deletions

View file

@ -1,7 +1,7 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue'
import UserFinder from './components/user_finder/user_finder.vue'
import SearchBar from './components/search_bar/search_bar.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@ -19,7 +19,7 @@ export default {
UserPanel,
NavPanel,
Notifications,
UserFinder,
SearchBar,
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
@ -32,7 +32,7 @@ export default {
},
data: () => ({
mobileActivePanel: 'timeline',
finderHidden: true,
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
@ -70,7 +70,7 @@ export default {
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
@ -101,8 +101,8 @@ export default {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onFinderToggled (hidden) {
this.finderHidden = hidden
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
updateMobileState () {
const mobileLayout = windowWidth() <= 800

View file

@ -283,6 +283,31 @@ i[class*=icon-] {
color: var(--icon, $fallback--icon)
}
.btn-block {
display: block;
width: 100%;
}
.btn-group {
position: relative;
display: inline-flex;
vertical-align: middle;
button {
position: relative;
flex: 1 1 auto;
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
.container {
display: flex;

View file

@ -38,9 +38,9 @@
</router-link>
</div>
<div class="item right">
<user-finder
class="button-icon nav-icon mobile-hidden"
@toggled="onFinderToggled"
<search-bar
class="nav-icon mobile-hidden"
@toggled="onSearchBarToggled"
/>
<router-link
class="mobile-hidden"

View file

@ -6,12 +6,12 @@ import ConversationPage from 'components/conversation-page/conversation-page.vue
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
@ -45,7 +45,7 @@ export default (store) => {
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow },
{ name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }

View file

@ -100,7 +100,7 @@
<!-- eslint-disable vue/no-v-html -->
<h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
<div v-html="attachment.oembed.oembedHTML" />
<!-- eslint-enabled vue/no-v-html -->
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</div>

View file

@ -1,8 +1,5 @@
<template>
<div
class="block"
style="position: relative"
>
<div>
<Popper
trigger="click"
append-to-body
@ -131,6 +128,7 @@
</div>
<button
slot="reference"
class="btn btn-default btn-block"
:class="{ pressed: showDropDown }"
@click="toggleMenu"
>

View file

@ -3,7 +3,7 @@
:disabled="progress || disabled"
@click="onClick"
>
<template v-if="progress">
<template v-if="progress && $slots.progress">
<slot name="progress" />
</template>
<template v-else>

View file

@ -0,0 +1,98 @@
import FollowCard from '../follow_card/follow_card.vue'
import Conversation from '../conversation/conversation.vue'
import Status from '../status/status.vue'
import map from 'lodash/map'
const Search = {
components: {
FollowCard,
Conversation,
Status
},
props: [
'query'
],
data () {
return {
loaded: false,
loading: false,
searchTerm: this.query || '',
userIds: [],
statuses: [],
hashtags: [],
currenResultTab: 'statuses'
}
},
computed: {
users () {
return this.userIds.map(userId => this.$store.getters.findUser(userId))
},
visibleStatuses () {
const allStatusesObject = this.$store.state.statuses.allStatusesObject
return this.statuses.filter(status =>
allStatusesObject[status.id] && !allStatusesObject[status.id].deleted
)
}
},
mounted () {
this.search(this.query)
},
watch: {
query (newValue) {
this.searchTerm = newValue
this.search(newValue)
}
},
methods: {
newQuery (query) {
this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus()
},
search (query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.userIds = []
this.statuses = []
this.hashtags = []
this.$refs.searchInput.blur()
this.$store.dispatch('search', { q: query, resolve: true })
.then(data => {
this.loading = false
this.userIds = map(data.accounts, 'id')
this.statuses = data.statuses
this.hashtags = data.hashtags
this.currenResultTab = this.getActiveTab()
this.loaded = true
})
},
resultCount (tabName) {
const length = this[tabName].length
return length === 0 ? '' : ` (${length})`
},
onResultTabSwitch (_index, dataset) {
this.currenResultTab = dataset.filter
},
getActiveTab () {
if (this.visibleStatuses.length > 0) {
return 'statuses'
} else if (this.users.length > 0) {
return 'people'
} else if (this.hashtags.length > 0) {
return 'hashtags'
}
return 'statuses'
},
lastHistoryRecord (hashtag) {
return hashtag.history && hashtag.history[0]
}
}
}
export default Search

View file

@ -0,0 +1,211 @@
<template>
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('nav.search') }}
</div>
</div>
<div class="search-input-container">
<input
ref="searchInput"
v-model="searchTerm"
class="search-input"
:placeholder="$t('nav.search')"
@keyup.enter="newQuery(searchTerm)"
>
<button
class="btn search-button"
@click="newQuery(searchTerm)"
>
<i class="icon-search" />
</button>
</div>
<div
v-if="loading"
class="text-center loading-icon"
>
<i class="icon-spin3 animate-spin" />
</div>
<div v-else-if="loaded">
<div class="search-nav-heading">
<tab-switcher
ref="tabSwitcher"
:on-switch="onResultTabSwitch"
:custom-active="currenResultTab"
>
<span
data-tab-dummy
data-filter="statuses"
:label="$t('user_card.statuses') + resultCount('visibleStatuses')"
/>
<span
data-tab-dummy
data-filter="people"
:label="$t('search.people') + resultCount('users')"
/>
<span
data-tab-dummy
data-filter="hashtags"
:label="$t('search.hashtags') + resultCount('hashtags')"
/>
</tab-switcher>
</div>
</div>
<div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
<div
v-if="visibleStatuses.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<Status
v-for="status in visibleStatuses"
:key="status.id"
:collapsable="false"
:expandable="false"
:compact="false"
class="search-result"
:statusoid="status"
:no-heading="false"
/>
</div>
<div v-else-if="currenResultTab === 'people'">
<div
v-if="users.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<FollowCard
v-for="user in users"
:key="user.id"
:user="user"
class="list-item search-result"
/>
</div>
<div v-else-if="currenResultTab === 'hashtags'">
<div
v-if="hashtags.length === 0 && !loading && loaded"
class="search-result-heading"
>
<h4>{{ $t('search.no_results') }}</h4>
</div>
<div
v-for="hashtag in hashtags"
:key="hashtag.url"
class="status trend search-result"
>
<div class="hashtag">
<router-link :to="{ name: 'tag-timeline', params: { tag: hashtag.name } }">
#{{ hashtag.name }}
</router-link>
<div v-if="lastHistoryRecord(hashtag)">
<span v-if="lastHistoryRecord(hashtag).accounts == 1">
{{ $t('search.person_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
<span v-else>
{{ $t('search.people_talking', { count: lastHistoryRecord(hashtag).accounts }) }}
</span>
</div>
</div>
<div
v-if="lastHistoryRecord(hashtag)"
class="count"
>
{{ lastHistoryRecord(hashtag).uses }}
</div>
</div>
</div>
</div>
<div class="search-result-footer text-center panel-footer faint" />
</div>
</template>
<script src="./search.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.search-result-heading {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
padding: 0.75rem;
text-align: center;
}
@media all and (max-width: 800px) {
.search-nav-heading {
.tab-switcher .tabs .tab-wrapper {
display: block;
justify-content: center;
flex: 1 1 auto;
text-align: center;
}
}
}
.search-result {
box-sizing: border-box;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.search-result-footer {
border-width: 1px 0 0 0;
border-style: solid;
border-color: var(--border, $fallback--border);
padding: 10px;
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
}
.search-input-container {
padding: 0.8rem;
display: flex;
justify-content: center;
.search-input {
width: 100%;
line-height: 1.125rem;
font-size: 1rem;
padding: 0.5rem;
box-sizing: border-box;
}
.search-button {
margin-left: 0.5em;
}
}
.loading-icon {
padding: 1em;
}
.trend {
display: flex;
align-items: center;
.hashtag {
flex: 1 1 auto;
color: $fallback--text;
color: var(--text, $fallback--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
flex: 0 0 auto;
width: 2rem;
font-size: 1.5rem;
line-height: 2.25rem;
font-weight: 500;
text-align: center;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

View file

@ -0,0 +1,27 @@
const SearchBar = {
data: () => ({
searchTerm: undefined,
hidden: true,
error: false,
loading: false
}),
watch: {
'$route': function (route) {
if (route.name === 'search') {
this.searchTerm = route.query.query
}
}
},
methods: {
find (searchTerm) {
this.$router.push({ name: 'search', query: { query: searchTerm } })
this.$refs.searchInput.focus()
},
toggleHidden () {
this.hidden = !this.hidden
this.$emit('toggled', this.hidden)
}
}
}
export default SearchBar

View file

@ -1,36 +1,36 @@
<template>
<div>
<div class="user-finder-container">
<div class="search-bar-container">
<i
v-if="loading"
class="icon-spin4 user-finder-icon animate-spin-slow"
class="icon-spin4 finder-icon animate-spin-slow"
/>
<a
v-if="hidden"
href="#"
:title="$t('finder.find_user')"
:title="$t('nav.search')"
><i
class="icon-user-plus user-finder-icon"
class="button-icon icon-search"
@click.prevent.stop="toggleHidden"
/></a>
<template v-else>
<input
id="user-finder-input"
ref="userSearchInput"
v-model="username"
class="user-finder-input"
:placeholder="$t('finder.find_user')"
id="search-bar-input"
ref="searchInput"
v-model="searchTerm"
class="search-bar-input"
:placeholder="$t('nav.search')"
type="text"
@keyup.enter="findUser(username)"
@keyup.enter="find(searchTerm)"
>
<button
class="btn search-button"
@click="findUser(username)"
@click="find(searchTerm)"
>
<i class="icon-search" />
</button>
<i
class="button-icon icon-cancel user-finder-icon"
class="button-icon icon-cancel"
@click.prevent.stop="toggleHidden"
/>
</template>
@ -38,22 +38,24 @@
</div>
</template>
<script src="./user_finder.js"></script>
<script src="./search_bar.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.user-finder-container {
.search-bar-container {
max-width: 100%;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
justify-content: flex-end;
.user-finder-input,
.search-bar-input,
.search-button {
height: 29px;
}
.user-finder-input {
.search-bar-input {
// TODO: do this properly without a rough guesstimate of 2 icons + paddings
max-width: calc(100% - 30px - 30px - 20px);
}
@ -62,6 +64,10 @@
margin-left: .5em;
margin-right: .5em;
}
.icon-cancel {
cursor: pointer;
}
}
</style>

View file

@ -100,8 +100,8 @@
</ul>
<ul>
<li @click="toggleDrawer">
<router-link :to="{ name: 'user-search' }">
{{ $t("nav.user_search") }}
<router-link :to="{ name: 'search' }">
{{ $t("nav.search") }}
</router-link>
</li>
<li

View file

@ -173,12 +173,13 @@ const Status = {
if (this.status.type === 'retweet') {
return false
}
var checkFollowing = this.$store.state.config.replyVisibility === 'following'
const checkFollowing = this.$store.state.config.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
if (checkFollowing && this.$store.getters.findUser(this.status.attentions[i].id).following) {
const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id)
if (checkFollowing && taggedUser && taggedUser.following) {
return false
}
if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
@ -418,6 +419,18 @@ const Status = {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
}
},
filters: {

View file

@ -4,7 +4,7 @@ import './tab_switcher.scss'
export default Vue.component('tab-switcher', {
name: 'TabSwitcher',
props: ['renderOnlyFocused', 'onSwitch'],
props: ['renderOnlyFocused', 'onSwitch', 'customActive'],
data () {
return {
active: this.$slots.default.findIndex(_ => _.tag)
@ -24,6 +24,14 @@ export default Vue.component('tab-switcher', {
}
this.active = index
}
},
isActiveTab (index) {
const customActiveIndex = this.$slots.default.findIndex(slot => {
const dataFilter = slot.data && slot.data.attrs && slot.data.attrs['data-filter']
return this.customActive && this.customActive === dataFilter
})
return customActiveIndex > -1 ? customActiveIndex === index : index === this.active
}
},
render (h) {
@ -33,7 +41,7 @@ export default Vue.component('tab-switcher', {
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (index === this.active) {
if (this.isActiveTab(index)) {
classesTab.push('active')
classesWrapper.push('active')
}

View file

@ -1,5 +1,6 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
@ -104,7 +105,8 @@ export default {
components: {
UserAvatar,
RemoteFollow,
ModerationTools
ModerationTools,
ProgressButton
},
methods: {
followUser () {
@ -135,6 +137,12 @@ export default {
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser () {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser () {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
setProfileView (v) {
if (this.switcher) {
const store = this.$store

View file

@ -112,101 +112,120 @@
</div>
</div>
<div
v-if="isOtherUser"
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div
v-if="loggedIn"
class="follow"
>
<span v-if="user.following">
<!--Following them!-->
<button
class="pressed"
:disabled="followRequestInProgress"
:title="$t('user_card.follow_unfollow')"
@click="unfollowUser"
>
<template v-if="followRequestInProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else>
{{ $t('user_card.following') }}
</template>
</button>
</span>
<span v-if="!user.following">
<button
:disabled="followRequestInProgress"
:title="followRequestSent ? $t('user_card.follow_again') : ''"
@click="followUser"
>
<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>
</span>
<div v-if="!user.following">
<button
class="btn btn-default btn-block"
:disabled="followRequestInProgress"
:title="followRequestSent ? $t('user_card.follow_again') : ''"
@click="followUser"
>
<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>
</div>
<div v-else-if="followRequestInProgress">
<button
class="btn btn-default btn-block pressed"
disabled
:title="$t('user_card.follow_unfollow')"
@click="unfollowUser"
>
{{ $t('user_card.follow_progress') }}
</button>
</div>
<div
v-if="isOtherUser && loggedIn"
class="mute"
v-else
class="btn-group"
>
<span v-if="user.muted">
<button
class="pressed"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
</span>
<span v-if="!user.muted">
<button @click="muteUser">
{{ $t('user_card.mute') }}
</button>
</span>
<button
class="btn btn-default pressed"
:title="$t('user_card.follow_unfollow')"
@click="unfollowUser"
>
{{ $t('user_card.following') }}
</button>
<ProgressButton
v-if="!user.subscribed"
class="btn btn-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<i class="icon-bell-alt" />
</ProgressButton>
<ProgressButton
v-else
class="btn btn-default pressed"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<i class="icon-bell-ringing-o" />
</ProgressButton>
</div>
<div v-if="!loggedIn && user.is_local">
<RemoteFollow :user="user" />
<div>
<button
v-if="user.muted"
class="btn btn-default btn-block pressed"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
<button
v-else
class="btn btn-default btn-block"
@click="muteUser"
>
{{ $t('user_card.mute') }}
</button>
</div>
<div
v-if="isOtherUser && loggedIn"
class="block"
>
<span v-if="user.statusnet_blocking">
<button
class="pressed"
@click="unblockUser"
>
{{ $t('user_card.blocked') }}
</button>
</span>
<span v-if="!user.statusnet_blocking">
<button @click="blockUser">
{{ $t('user_card.block') }}
</button>
</span>
<div>
<button
v-if="user.statusnet_blocking"
class="btn btn-default btn-block pressed"
@click="unblockUser"
>
{{ $t('user_card.blocked') }}
</button>
<button
v-else
class="btn btn-default btn-block"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
</div>
<div
v-if="isOtherUser && loggedIn"
class="block"
>
<span>
<button @click="reportUser">
{{ $t('user_card.report') }}
</button>
</span>
<div>
<button
class="btn btn-default btn-block"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
</div>
<ModerationTools
v-if="loggedIn.role === &quot;admin&quot;"
:user="user"
/>
</div>
<div
v-if="!loggedIn && user.is_local"
class="user-interactions"
>
<RemoteFollow :user="user" />
</div>
</div>
</div>
<div
@ -487,40 +506,22 @@
display: flex;
flex-flow: row wrap;
justify-content: space-between;
margin-right: -.75em;
div {
> * {
flex: 1 0 0;
margin-right: .75em;
margin-bottom: .6em;
margin: 0 .75em .6em 0;
white-space: nowrap;
}
.mute {
max-width: 220px;
min-height: 28px;
}
.follow {
max-width: 220px;
min-height: 28px;
}
button {
width: 100%;
height: 100%;
margin: 0;
}
.remote-button {
height: 28px !important;
width: 92%;
}
.pressed {
border-bottom-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(0, 0, 0, 0.2);
&.pressed {
// TODO: This should be themed.
border-bottom-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(0, 0, 0, 0.2);
}
}
}
}

View file

@ -1,20 +0,0 @@
const UserFinder = {
data: () => ({
username: undefined,
hidden: true,
error: false,
loading: false
}),
methods: {
findUser (username) {
this.$router.push({ name: 'user-search', query: { query: username } })
this.$refs.userSearchInput.focus()
},
toggleHidden () {
this.hidden = !this.hidden
this.$emit('toggled', this.hidden)
}
}
}
export default UserFinder

View file

@ -3,7 +3,6 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@ -132,7 +131,6 @@ const UserProfile = {
Timeline,
FollowerList,
FriendList,
ModerationTools,
FollowCard,
Conversation
}

View file

@ -1,51 +0,0 @@
import FollowCard from '../follow_card/follow_card.vue'
import map from 'lodash/map'
const userSearch = {
components: {
FollowCard
},
props: [
'query'
],
data () {
return {
username: '',
userIds: [],
loading: false
}
},
computed: {
users () {
return this.userIds.map(userId => this.$store.getters.findUser(userId))
}
},
mounted () {
this.search(this.query)
},
watch: {
query (newV) {
this.search(newV)
}
},
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
this.$store.dispatch('searchUsers', query)
.then((res) => {
this.loading = false
this.userIds = map(res, 'id')
})
}
}
}
export default userSearch

View file

@ -1,57 +0,0 @@
<template>
<div class="user-search panel panel-default">
<div class="panel-heading">
{{ $t('nav.user_search') }}
</div>
<div class="user-search-input-container">
<input
ref="userSearchInput"
v-model="username"
class="user-finder-input"
:placeholder="$t('finder.find_user')"
@keyup.enter="newQuery(username)"
>
<button
class="btn search-button"
@click="newQuery(username)"
>
<i class="icon-search" />
</button>
</div>
<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"
class="list-item"
/>
</div>
</div>
</template>
<script src="./user_search.js"></script>
<style lang="scss">
.user-search-input-container {
margin: 0.5em;
display: flex;
justify-content: center;
.search-button {
margin-left: 0.5em;
}
}
.loading-icon {
padding: 1em;
}
</style>

View file

@ -17,7 +17,6 @@ import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import userSearchApi from '../../services/new_api/user_search.js'
import Mfa from './mfa.vue'
const BlockList = withSubscription({
@ -322,11 +321,8 @@ const UserSettings = {
})
},
queryUserIds (query) {
return userSearchApi.search({ query, store: this.$store })
.then((users) => {
this.$store.dispatch('addNewUsers', users)
return map(users, 'id')
})
return this.$store.dispatch('searchUsers', query)
.then((users) => map(users, 'id'))
},
blockUsers (ids) {
return this.$store.dispatch('blockUsers', ids)

View file

@ -78,6 +78,7 @@
"timeline": "Timeline",
"twkn": "The Whole Known Network",
"user_search": "User Search",
"search": "Search",
"who_to_follow": "Who to follow",
"preferences": "Preferences"
},
@ -531,6 +532,8 @@
"remote_follow": "Remote follow",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking...",
"block_progress": "Blocking...",
@ -595,5 +598,12 @@
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results"
}
}

View file

@ -38,7 +38,8 @@
"interactions": "Взаимодействия",
"public_tl": "Публичная лента",
"timeline": "Лента",
"twkn": "Федеративная лента"
"twkn": "Федеративная лента",
"search": "Поиск"
},
"notifications": {
"broken_favorite": "Неизвестный статус, ищем...",
@ -381,5 +382,12 @@
},
"user_profile": {
"timeline_title": "Лента пользователя"
},
"search": {
"people": "Люди",
"hashtags": "Хэштэги",
"person_talking": "Популярно у {count} человека",
"people_talking": "Популярно у {count} человек",
"no_results": "Ничего не найдено"
}
}

View file

@ -496,10 +496,19 @@ export const mutations = {
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
addFavsAndRepeats (state, { id, favoritedByUsers, rebloggedByUsers }) {
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(({ id }) => currentUser.id === id)
},
addFavs (state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.favoritedBy = favoritedByUsers.filter(_ => _)
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id)
},
updateStatusWithPoll (state, { id, poll }) {
const status = state.allStatusesObject[id]
@ -593,9 +602,26 @@ const statuses = {
Promise.all([
rootState.api.backendInteractor.fetchFavoritedByUsers(id),
rootState.api.backendInteractor.fetchRebloggedByUsers(id)
]).then(([favoritedByUsers, rebloggedByUsers]) =>
commit('addFavsAndRepeats', { id, favoritedByUsers, rebloggedByUsers })
)
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser })
commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })
})
},
fetchFavs ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchFavoritedByUsers(id)
.then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser }))
},
fetchRepeats ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchRebloggedByUsers(id)
.then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }))
},
search (store, { q, resolve, limit, offset, following }) {
return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following })
.then((data) => {
store.commit('addNewUsers', data.accounts)
store.commit('addNewStatuses', { statuses: data.statuses })
return data
})
}
},
mutations

View file

@ -1,5 +1,4 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import userSearchApi from '../services/new_api/user_search.js'
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, merge, last, concat, uniq } from 'lodash'
import { set } from 'vue'
@ -136,6 +135,7 @@ export const mutations = {
user.following = relationship.following
user.muted = relationship.muting
user.statusnet_blocking = relationship.blocking
user.subscribed = relationship.subscribing
}
})
},
@ -305,6 +305,14 @@ const users = {
clearFollowers ({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser ({ rootState, commit }, id) {
return rootState.api.backendInteractor.subscribeUser(id)
.then((relationship) => commit('updateUserRelationship', [relationship]))
},
unsubscribeUser ({ rootState, commit }, id) {
return rootState.api.backendInteractor.unsubscribeUser(id)
.then((relationship) => commit('updateUserRelationship', [relationship]))
},
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
@ -356,14 +364,7 @@ const users = {
})
},
searchUsers (store, query) {
// TODO: Move userSearch api into api.service
return userSearchApi.search({
query,
store: {
state: store.rootState,
getters: store.rootGetters
}
})
return store.rootState.api.backendInteractor.searchUsers(query)
.then((users) => {
store.commit('addNewUsers', users)
return users

View file

@ -55,6 +55,8 @@ const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@ -67,6 +69,7 @@ const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = id => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
const MASTODON_SEARCH_2 = `/api/v2/search`
const oldfetch = window.fetch
@ -78,7 +81,7 @@ let fetch = (url, options) => {
return oldfetch(fullUrl, options)
}
const promisedRequest = ({ method, url, payload, credentials, headers = {} }) => {
const promisedRequest = ({ method, url, params, payload, credentials, headers = {} }) => {
const options = {
method,
headers: {
@ -87,6 +90,11 @@ const promisedRequest = ({ method, url, payload, credentials, headers = {} }) =>
...headers
}
}
if (params) {
url += '?' + Object.entries(params)
.map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
.join('&')
}
if (payload) {
options.body = JSON.stringify(payload)
}
@ -758,6 +766,14 @@ const unmuteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
}
const subscribeUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_SUBSCRIBE_USER(id), credentials, method: 'POST' })
}
const unsubscribeUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNSUBSCRIBE_USER(id), credentials, method: 'POST' })
}
const fetchBlocks = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials })
.then((users) => users.map(parseUser))
@ -849,6 +865,48 @@ const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
})
}
const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
let url = MASTODON_SEARCH_2
let params = []
if (q) {
params.push(['q', encodeURIComponent(q)])
}
if (resolve) {
params.push(['resolve', resolve])
}
if (limit) {
params.push(['limit', limit])
}
if (offset) {
params.push(['offset', offset])
}
if (following) {
params.push(['following', true])
}
let queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching search result', data)
})
.then((data) => { return data.json() })
.then((data) => {
data.accounts = data.accounts.slice(0, limit).map(u => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map(s => parseStatus(s))
return data
})
}
const apiService = {
verifyCredentials,
fetchTimeline,
@ -878,6 +936,8 @@ const apiService = {
fetchMutes,
muteUser,
unmuteUser,
subscribeUser,
unsubscribeUser,
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
@ -913,7 +973,8 @@ const apiService = {
fetchFavoritedByUsers,
fetchRebloggedByUsers,
reportUser,
updateNotificationSettings
updateNotificationSettings,
search2
}
export default apiService

View file

@ -108,6 +108,8 @@ const backendInteractorService = credentials => {
const fetchMutes = () => apiService.fetchMutes({ credentials })
const muteUser = (id) => apiService.muteUser({ credentials, id })
const unmuteUser = (id) => apiService.unmuteUser({ credentials, id })
const subscribeUser = (id) => apiService.subscribeUser({ credentials, id })
const unsubscribeUser = (id) => apiService.unsubscribeUser({ credentials, id })
const fetchBlocks = () => apiService.fetchBlocks({ credentials })
const fetchFollowRequests = () => apiService.fetchFollowRequests({ credentials })
const fetchOAuthTokens = () => apiService.fetchOAuthTokens({ credentials })
@ -148,6 +150,8 @@ const backendInteractorService = credentials => {
const unfavorite = (id) => apiService.unfavorite({ id, credentials })
const retweet = (id) => apiService.retweet({ id, credentials })
const unretweet = (id) => apiService.unretweet({ id, credentials })
const search2 = ({ q, resolve, limit, offset, following }) =>
apiService.search2({ credentials, q, resolve, limit, offset, following })
const backendInteractorServiceInstance = {
fetchStatus,
@ -167,6 +171,8 @@ const backendInteractorService = credentials => {
fetchMutes,
muteUser,
unmuteUser,
subscribeUser,
unsubscribeUser,
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
@ -209,7 +215,8 @@ const backendInteractorService = credentials => {
unfavorite,
retweet,
unretweet,
updateNotificationSettings
updateNotificationSettings,
search2
}
return backendInteractorServiceInstance

View file

@ -68,6 +68,7 @@ export const parseUser = (data) => {
output.following = relationship.following
output.statusnet_blocking = relationship.blocking
output.muted = relationship.muting
output.subscribed = relationship.subscribing
}
output.hide_follows = data.pleroma.hide_follows

View file

@ -1,20 +0,0 @@
import utils from './utils.js'
import { parseUser } from '../entity_normalizer/entity_normalizer.service.js'
const search = ({ query, store }) => {
return utils.request({
store,
url: '/api/v1/accounts/search',
params: {
q: query,
resolve: true
}
})
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const UserSearch = {
search
}
export default UserSearch

View file

@ -1,36 +0,0 @@
const queryParams = (params) => {
return Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&')
}
const headers = (store) => {
const accessToken = store.getters.getToken()
if (accessToken) {
return { 'Authorization': `Bearer ${accessToken}` }
} else {
return {}
}
}
const request = ({ method = 'GET', url, params, store }) => {
const instance = store.state.instance.server
let fullUrl = `${instance}${url}`
if (method === 'GET' && params) {
fullUrl = fullUrl + `?${queryParams(params)}`
}
return window.fetch(fullUrl, {
method,
headers: headers(store),
credentials: 'same-origin'
})
}
const utils = {
queryParams,
request
}
export default utils