feat(users): implement user following system and following feed
Add follow/unfollow actions and social stats to user profiles Introduce Following feed scope in Life view Add notifications for new followers
This commit is contained in:
@@ -61,6 +61,7 @@ type LifeCommentPageState = {
|
||||
};
|
||||
|
||||
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||
type LifeFeedScope = 'all' | 'following';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const posts = ref<LifePost[]>([]);
|
||||
@@ -79,6 +80,7 @@ const activeLanguageCode = ref('all');
|
||||
const activeGameVersionId = ref('all');
|
||||
const activeRateableFilter = ref('all');
|
||||
const activeSort = ref<LifePostSort>('latest');
|
||||
const activeFeedScope = ref<LifeFeedScope>('all');
|
||||
const body = ref('');
|
||||
const selectedCategoryId = ref('');
|
||||
const selectedGameVersionId = ref('');
|
||||
@@ -181,6 +183,10 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
|
||||
{ value: 'oldest', label: t('pages.life.sortOldest') },
|
||||
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
|
||||
]);
|
||||
const feedScopeOptions = computed<TabOption[]>(() => [
|
||||
{ value: 'all', label: t('pages.life.allFeed') },
|
||||
{ value: 'following', label: t('pages.life.followingFeed') }
|
||||
]);
|
||||
const defaultLifeCategoryId = computed(() => {
|
||||
const category = lifeCategories.value.find((item) => item.isDefault);
|
||||
return category ? String(category.id) : '';
|
||||
@@ -196,6 +202,7 @@ async function loadCurrentUser() {
|
||||
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
activeFeedScope.value = 'all';
|
||||
authReady.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +212,7 @@ async function loadCurrentUser() {
|
||||
currentUser.value = response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
activeFeedScope.value = 'all';
|
||||
setAuthToken(null);
|
||||
} finally {
|
||||
authReady.value = true;
|
||||
@@ -265,7 +273,7 @@ async function loadPosts() {
|
||||
loadMorePaused.value = false;
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({
|
||||
const params = {
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
categoryId: selectedFeedCategoryId.value,
|
||||
@@ -273,7 +281,8 @@ async function loadPosts() {
|
||||
gameVersionId: selectedFeedGameVersionId.value,
|
||||
rateable: selectedRateableFilter.value,
|
||||
sort: activeSort.value
|
||||
});
|
||||
};
|
||||
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -309,7 +318,7 @@ async function loadMorePosts() {
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({
|
||||
const params = {
|
||||
cursor,
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
@@ -318,7 +327,8 @@ async function loadMorePosts() {
|
||||
gameVersionId: selectedFeedGameVersionId.value,
|
||||
rateable: selectedRateableFilter.value,
|
||||
sort: activeSort.value
|
||||
});
|
||||
};
|
||||
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -447,7 +457,7 @@ async function submitPost() {
|
||||
replacePost(updated);
|
||||
} else {
|
||||
const created = await api.createLifePost(payload());
|
||||
if (activeSort.value !== 'latest') {
|
||||
if (activeSort.value !== 'latest' || activeFeedScope.value === 'following') {
|
||||
void loadPosts();
|
||||
} else if (matchesCurrentFilters(created)) {
|
||||
posts.value = [created, ...posts.value];
|
||||
@@ -1205,6 +1215,9 @@ watch(activeRateableFilter, () => {
|
||||
watch(activeSort, () => {
|
||||
void loadPosts();
|
||||
});
|
||||
watch(activeFeedScope, () => {
|
||||
void loadPosts();
|
||||
});
|
||||
watch(locale, () => {
|
||||
void loadLanguages();
|
||||
void loadLifeCategories();
|
||||
@@ -1220,8 +1233,10 @@ onMounted(() => {
|
||||
void loadLifeCategories();
|
||||
void loadPosts();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void loadCurrentUser();
|
||||
void loadPosts();
|
||||
void (async () => {
|
||||
await loadCurrentUser();
|
||||
await loadPosts();
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1382,6 +1397,13 @@ onUnmounted(() => {
|
||||
@close="closeReactionUsersModal"
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
v-if="currentUser"
|
||||
id="life-feed-scope"
|
||||
v-model="activeFeedScope"
|
||||
:tabs="feedScopeOptions"
|
||||
:label="t('pages.life.feedScope')"
|
||||
/>
|
||||
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
||||
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
|
||||
|
||||
|
||||
@@ -70,10 +70,12 @@ const commentFilter = ref<CommentFilter>('all');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const passwordBusy = ref(false);
|
||||
const followBusy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
const passwordMessage = ref('');
|
||||
const passwordErrorMessage = ref('');
|
||||
const followErrorMessage = ref('');
|
||||
const referralSummaryMessage = ref('');
|
||||
const referralSummaryErrorMessage = ref('');
|
||||
const referralMessage = ref('');
|
||||
@@ -111,6 +113,18 @@ const hasChanges = computed(() => {
|
||||
if (!user || !canShowAccount.value) return false;
|
||||
return trimmedDisplayName.value !== user.displayName;
|
||||
});
|
||||
const canFollowProfile = computed(() => {
|
||||
const user = currentUser.value;
|
||||
const target = profile.value?.user;
|
||||
return Boolean(user && target && user.id !== target.id && user.permissions.includes('users.follow'));
|
||||
});
|
||||
const followButtonLabel = computed(() => {
|
||||
const relation = profile.value?.social.viewerRelation ?? 'none';
|
||||
if (relation === 'friends') return t('pages.profile.friend');
|
||||
if (relation === 'following') return t('pages.profile.following');
|
||||
if (relation === 'followed-by') return t('pages.profile.followBack');
|
||||
return t('pages.profile.follow');
|
||||
});
|
||||
const profileInitial = computed(() => {
|
||||
const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || '';
|
||||
return name.charAt(0).toUpperCase() || '#';
|
||||
@@ -176,6 +190,14 @@ const communityStats = computed(() => {
|
||||
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
||||
];
|
||||
});
|
||||
const socialStats = computed(() => {
|
||||
const social = profile.value?.social;
|
||||
return [
|
||||
{ label: t('pages.profile.followers'), value: social?.followerCount ?? 0 },
|
||||
{ label: t('pages.profile.followingCount'), value: social?.followingCount ?? 0 },
|
||||
{ label: t('pages.profile.friends'), value: social?.friendCount ?? 0 }
|
||||
];
|
||||
});
|
||||
const filteredContributions = computed(() => {
|
||||
const items = profile.value?.contributions ?? [];
|
||||
if (contributionFilter.value === 'all') {
|
||||
@@ -280,6 +302,7 @@ async function loadProfile() {
|
||||
errorMessage.value = '';
|
||||
passwordMessage.value = '';
|
||||
passwordErrorMessage.value = '';
|
||||
followErrorMessage.value = '';
|
||||
referralSummaryMessage.value = '';
|
||||
referralSummaryErrorMessage.value = '';
|
||||
referralMessage.value = '';
|
||||
@@ -339,6 +362,27 @@ async function loadProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
const target = profile.value?.user;
|
||||
if (!target || !canFollowProfile.value || followBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
followErrorMessage.value = '';
|
||||
followBusy.value = true;
|
||||
try {
|
||||
const relation = profile.value?.social.viewerRelation ?? 'none';
|
||||
const response = relation === 'following' || relation === 'friends'
|
||||
? await api.unfollowUser(target.id)
|
||||
: await api.followUser(target.id);
|
||||
profile.value = response.profile;
|
||||
} catch (error) {
|
||||
followErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.followFailed');
|
||||
} finally {
|
||||
followBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
@@ -680,6 +724,14 @@ onMounted(() => {
|
||||
:tone="currentUser.emailVerified ? 'success' : 'warning'"
|
||||
/>
|
||||
|
||||
<div v-if="canFollowProfile" class="profile-follow-actions">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="followBusy" @click="toggleFollow">
|
||||
<Icon :icon="iconReferral" class="ui-icon" aria-hidden="true" />
|
||||
{{ followButtonLabel }}
|
||||
</button>
|
||||
<StatusMessage v-if="followErrorMessage" variant="danger" :duration="0">{{ followErrorMessage }}</StatusMessage>
|
||||
</div>
|
||||
|
||||
<dl class="profile-stat-strip">
|
||||
<div v-for="item in headlineStats" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
@@ -687,6 +739,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl class="profile-stat-strip profile-stat-strip--social">
|
||||
<div v-for="item in socialStats" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="canShowAccount && referral" class="profile-referral-summary">
|
||||
<div>
|
||||
<span>{{ t('pages.profile.referralCode') }}</span>
|
||||
|
||||
Reference in New Issue
Block a user