From 6aeb791f5f754b0bf65f030c9c327ee463e183bb Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 5 Dec 2021 17:51:28 +0100 Subject: [PATCH 1/6] Allow (un)following someone in the user popover This adds a new button for signed-in users to the user popover to follow (or unfollow) the user shown in the popover. The goal of this is to greatly reduce the friction to follow (and unfollow) a user and make the following feature more widely-adopted. Previously, you had to visit the profile of the user you are trying to (un)follow and see the button in the top right of the page. This also allows you to quickly determine whether or not you are currently following a person. Changelog: added --- app/assets/javascripts/api/user_api.js | 12 ++ .../javascripts/lib/utils/users_cache.js | 11 ++ app/assets/javascripts/user_popovers.js | 3 + .../components/user_popover/user_popover.vue | 79 +++++++++- doc/api/users.md | 3 +- lib/api/entities/user.rb | 3 + locale/gitlab.pot | 6 + spec/frontend/lib/utils/users_cache_spec.js | 25 ++++ spec/frontend/user_popovers_spec.js | 16 ++ .../user_popover/user_popover_spec.js | 138 ++++++++++++++++++ spec/lib/api/entities/user_spec.rb | 45 +++++- 11 files changed, 337 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 09995fad628836..c743b18d572a43 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -10,6 +10,8 @@ const USER_PATH = '/api/:version/users/:id'; const USER_STATUS_PATH = '/api/:version/users/:id/status'; const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; const USER_POST_STATUS_PATH = '/api/:version/user/status'; +const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; +const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; export function getUsers(query, options) { const url = buildApiUrl(USERS_PATH); @@ -69,3 +71,13 @@ export function updateUserStatus({ emoji, message, availability, clearStatusAfte clear_status_after: clearStatusAfter, }); } + +export function followUser(userId) { + const url = buildApiUrl(USER_FOLLOW_PATH).replace(':id', encodeURIComponent(userId)); + return axios.post(url); +} + +export function unfollowUser(userId) { + const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId)); + return axios.post(url); +} diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js index 54f69ef8e1b407..bd000bb26febf4 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -35,6 +35,17 @@ class UsersCache extends Cache { // missing catch is intentional, error handling depends on use case } + updateById(userId, data) { + if (!this.hasData(userId)) { + return; + } + + this.internalStorage[userId] = { + ...this.internalStorage[userId], + ...data, + }; + } + retrieveStatusById(userId) { if (this.hasData(userId) && this.get(userId).status) { return Promise.resolve(this.get(userId).status); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 4413be384e50e8..453d19d37cd68c 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -32,6 +32,7 @@ const populateUserInfo = (user) => { ([userData, status]) => { if (userData) { Object.assign(user, { + id: userId, avatarUrl: userData.avatar_url, bot: userData.bot, username: userData.username, @@ -42,6 +43,7 @@ const populateUserInfo = (user) => { websiteUrl: userData.website_url, pronouns: userData.pronouns, localTime: userData.local_time, + isFollowed: userData.is_followed, loaded: true, }); } @@ -97,6 +99,7 @@ export default function addPopovers(elements = document.querySelectorAll('.js-us bio: null, workInformation: null, status: null, + isFollowed: false, loaded: false, }; const renderedPopover = new UserPopoverComponent({ diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 2c09fa7123041e..7d1b80826aee9c 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -6,9 +6,13 @@ import { GlIcon, GlSafeHtmlDirective, GlSprintf, + GlButton, } from '@gitlab/ui'; +import { __ } from '~/locale'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '~/emoji'; +import createFlash from '~/flash'; +import { followUser, unfollowUser } from '~/rest_api'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; const MAX_SKELETON_LINES = 4; @@ -24,6 +28,7 @@ export default { UserAvatarImage, UserNameWithStatus, GlSprintf, + GlButton, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -44,6 +49,11 @@ export default { default: 'top', }, }, + data() { + return { + toggleFollowLoading: false, + }; + }, computed: { statusHtml() { if (!this.user.status) { @@ -64,6 +74,59 @@ export default { availabilityStatus() { return this.user?.status?.availability || ''; }, + isNotCurrentUser() { + return !this.userIsLoading && this.user.username !== gon.current_username; + }, + shouldRenderToggleFollowButton() { + return this.isNotCurrentUser && typeof this.user?.isFollowed !== 'undefined'; + }, + toggleFollowButtonText() { + if (this.toggleFollowLoading) return null; + + return this.user?.isFollowed ? __('Unfollow') : __('Follow'); + }, + toggleFollowButtonVariant() { + return this.user?.isFollowed ? 'default' : 'confirm'; + }, + }, + methods: { + async toggleFollow() { + if (this.user.isFollowed) { + this.unfollow(); + } else { + this.follow(); + } + }, + async follow() { + this.toggleFollowLoading = true; + try { + await followUser(this.user.id); + this.$emit('follow'); + } catch (error) { + createFlash({ + message: __('An error occurred while trying to follow this user, please try again.'), + error, + captureError: true, + }); + } finally { + this.toggleFollowLoading = false; + } + }, + async unfollow() { + this.toggleFollowLoading = true; + try { + await unfollowUser(this.user.id); + this.$emit('unfollow'); + } catch (error) { + createFlash({ + message: __('An error occurred while trying to unfollow this user, please try again.'), + error, + captureError: true, + }); + } finally { + this.toggleFollowLoading = false; + } + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; @@ -73,10 +136,22 @@ export default {
-
+
+
+ {{ toggleFollowButtonText }} +
-
+