FEAT: Allow users to view pending follow requests they sent

This commit implements the `following/requests/sent` interface firstly
implemented on Firefish, and provides a UI interface to view the pending
follow requests users sent.
This commit is contained in:
Lhc_fl 2024-10-06 18:00:23 +08:00 committed by kakkokari-gtyih
parent b1073714ba
commit 0959dec291
10 changed files with 237 additions and 33 deletions

View file

@ -187,6 +187,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js'; import * as ep___following_requests_list from './endpoints/following/requests/list.js';
import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js'; import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js'; import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@ -574,6 +575,7 @@ const $following_invalidate: Provider = { provide: 'ep:following/invalidate', us
const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default };
const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default };
const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default }; const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default };
const $following_requests_sent: Provider = { provide: 'ep:following/requests/sent', useClass: ep___following_requests_sent.default };
const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default }; const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default };
const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default }; const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default };
const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default }; const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default };
@ -965,6 +967,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$following_requests_accept, $following_requests_accept,
$following_requests_cancel, $following_requests_cancel,
$following_requests_list, $following_requests_list,
$following_requests_sent,
$following_requests_reject, $following_requests_reject,
$gallery_featured, $gallery_featured,
$gallery_popular, $gallery_popular,

View file

@ -193,6 +193,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js'; import * as ep___following_requests_list from './endpoints/following/requests/list.js';
import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js'; import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js'; import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@ -578,6 +579,7 @@ const eps = [
['following/requests/accept', ep___following_requests_accept], ['following/requests/accept', ep___following_requests_accept],
['following/requests/cancel', ep___following_requests_cancel], ['following/requests/cancel', ep___following_requests_cancel],
['following/requests/list', ep___following_requests_list], ['following/requests/list', ep___following_requests_list],
['following/requests/sent', ep___following_requests_sent],
['following/requests/reject', ep___following_requests_reject], ['following/requests/reject', ep___following_requests_reject],
['gallery/featured', ep___gallery_featured], ['gallery/featured', ep___gallery_featured],
['gallery/popular', ep___gallery_popular], ['gallery/popular', ep___gallery_popular],

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import type { FollowRequestsRepository } from '@/models/_.js';
import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['following', 'account'],
requireCredential: true,
kind: 'read:following',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
follower: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
followee: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private followRequestEntityService: FollowRequestEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId)
.andWhere('request.followerId = :meId', { meId: me.id });
const requests = await query
.limit(ps.limit)
.getMany();
return await this.followRequestEntityService.packMany(requests, me);
});
}
}

View file

@ -40,7 +40,6 @@ export const navbarItemDef = reactive({
followRequests: { followRequests: {
title: i18n.ts.followRequests, title: i18n.ts.followRequests,
icon: 'ti ti-user-plus', icon: 'ti ti-user-plus',
show: computed(() => $i != null && $i.isLocked),
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
to: '/my/follow-requests', to: '/my/follow-requests',
}, },

View file

@ -5,39 +5,43 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<MkPagination ref="paginationComponent" :pagination="pagination"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<template #empty> <div :key="tab" class="_gaps">
<div class="_fullinfo"> <MkPagination ref="paginationComponent" :pagination="pagination">
<img :src="infoImageUrl" class="_ghost"/> <template #empty>
<div>{{ i18n.ts.noFollowRequests }}</div> <div class="_fullinfo">
</div> <img :src="infoImageUrl" class="_ghost"/>
</template> <div>{{ i18n.ts.noFollowRequests }}</div>
<template #default="{items}"> </div>
<div class="mk-follow-requests"> </template>
<div v-for="req in items" :key="req.id" class="user _panel"> <template #default="{items}">
<MkAvatar class="avatar" :user="req.follower" indicator link preview/> <div class="mk-follow-requests">
<div class="body"> <div v-for="req in items" :key="req.id" class="user _panel">
<div class="name"> <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/>
<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> <div class="body">
<p class="acct">@{{ acct(req.follower) }}</p> <div class="name">
</div> <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA>
<div class="commands"> <p class="acct">@{{ acct(displayUser(req)) }}</p>
<MkButton class="command" rounded primary @click="accept(req.follower)"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> </div>
<MkButton class="command" rounded danger @click="reject(req.follower)"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> <div v-if="tab === 'list'" class="commands">
<MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
</div>
</div>
</div> </div>
</div> </div>
</div> </template>
</div> </MkPagination>
</template> </div>
</MkPagination> </MkHorizontalSwipe>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef, computed } from 'vue'; import { shallowRef, computed, ref } from 'vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { userPage, acct } from '@/filters/user.js'; import { userPage, acct } from '@/filters/user.js';
@ -45,29 +49,52 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination = { const pagination = computed(() => tab.value === 'list'
endpoint: 'following/requests/list' as const, ? {
limit: 10, endpoint: 'following/requests/list' as const,
}; limit: 10,
}
: {
endpoint: 'following/requests/sent' as const,
limit: 10,
},
);
function accept(user) { function accept(user) {
misskeyApi('following/requests/accept', { userId: user.id }).then(() => { misskeyApi('following/requests/accept', { userId: user.id }).then(() => {
paginationComponent.value.reload(); paginationComponent.value?.reload();
}); });
} }
function reject(user) { function reject(user) {
misskeyApi('following/requests/reject', { userId: user.id }).then(() => { misskeyApi('following/requests/reject', { userId: user.id }).then(() => {
paginationComponent.value.reload(); paginationComponent.value?.reload();
}); });
} }
function displayUser(req) {
return tab.value === 'list' ? req.follower : req.followee;
}
const headerActions = computed(() => []); const headerActions = computed(() => []);
const headerTabs = computed(() => []); const headerTabs = computed(() => [
{
key: 'list',
title: i18n.ts.followRequests,
icon: 'ph-envelope ph-bold ph-lg',
}, {
key: 'sent',
title: i18n.ts.followRequestPending,
icon: 'ph-paper-plane-tilt ph-bold ph-lg',
},
]);
const tab = ref('list');
definePageMetadata(() => ({ definePageMetadata(() => ({
title: i18n.ts.followRequests, title: i18n.ts.followRequests,

View file

@ -1500,6 +1500,8 @@ declare namespace entities {
FollowingRequestsCancelResponse, FollowingRequestsCancelResponse,
FollowingRequestsListRequest, FollowingRequestsListRequest,
FollowingRequestsListResponse, FollowingRequestsListResponse,
FollowingRequestsSentRequest,
FollowingRequestsSentResponse,
FollowingRequestsRejectRequest, FollowingRequestsRejectRequest,
GalleryFeaturedRequest, GalleryFeaturedRequest,
GalleryFeaturedResponse, GalleryFeaturedResponse,
@ -2014,6 +2016,12 @@ type FollowingRequestsListResponse = operations['following___requests___list']['
// @public (undocumented) // @public (undocumented)
type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json']; type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json'];
// @public (undocumented)
type FollowingRequestsSentRequest = operations['following___requests___sent']['requestBody']['content']['application/json'];
// @public (undocumented)
type FollowingRequestsSentResponse = operations['following___requests___sent']['responses']['200']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type FollowingUpdateAllRequest = operations['following___update-all']['requestBody']['content']['application/json']; type FollowingUpdateAllRequest = operations['following___update-all']['requestBody']['content']['application/json'];

View file

@ -2008,6 +2008,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
request<E extends 'following/requests/sent', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* No description provided. * No description provided.
* *

View file

@ -278,6 +278,8 @@ import type {
FollowingRequestsCancelResponse, FollowingRequestsCancelResponse,
FollowingRequestsListRequest, FollowingRequestsListRequest,
FollowingRequestsListResponse, FollowingRequestsListResponse,
FollowingRequestsSentRequest,
FollowingRequestsSentResponse,
FollowingRequestsRejectRequest, FollowingRequestsRejectRequest,
GalleryFeaturedRequest, GalleryFeaturedRequest,
GalleryFeaturedResponse, GalleryFeaturedResponse,
@ -760,6 +762,7 @@ export type Endpoints = {
'following/requests/accept': { req: FollowingRequestsAcceptRequest; res: EmptyResponse }; 'following/requests/accept': { req: FollowingRequestsAcceptRequest; res: EmptyResponse };
'following/requests/cancel': { req: FollowingRequestsCancelRequest; res: FollowingRequestsCancelResponse }; 'following/requests/cancel': { req: FollowingRequestsCancelRequest; res: FollowingRequestsCancelResponse };
'following/requests/list': { req: FollowingRequestsListRequest; res: FollowingRequestsListResponse }; 'following/requests/list': { req: FollowingRequestsListRequest; res: FollowingRequestsListResponse };
'following/requests/sent': { req: FollowingRequestsSentRequest; res: FollowingRequestsSentResponse };
'following/requests/reject': { req: FollowingRequestsRejectRequest; res: EmptyResponse }; 'following/requests/reject': { req: FollowingRequestsRejectRequest; res: EmptyResponse };
'gallery/featured': { req: GalleryFeaturedRequest; res: GalleryFeaturedResponse }; 'gallery/featured': { req: GalleryFeaturedRequest; res: GalleryFeaturedResponse };
'gallery/popular': { req: EmptyRequest; res: GalleryPopularResponse }; 'gallery/popular': { req: EmptyRequest; res: GalleryPopularResponse };

View file

@ -281,6 +281,8 @@ export type FollowingRequestsCancelRequest = operations['following___requests___
export type FollowingRequestsCancelResponse = operations['following___requests___cancel']['responses']['200']['content']['application/json']; export type FollowingRequestsCancelResponse = operations['following___requests___cancel']['responses']['200']['content']['application/json'];
export type FollowingRequestsListRequest = operations['following___requests___list']['requestBody']['content']['application/json']; export type FollowingRequestsListRequest = operations['following___requests___list']['requestBody']['content']['application/json'];
export type FollowingRequestsListResponse = operations['following___requests___list']['responses']['200']['content']['application/json']; export type FollowingRequestsListResponse = operations['following___requests___list']['responses']['200']['content']['application/json'];
export type FollowingRequestsSentRequest = operations['following___requests___sent']['requestBody']['content']['application/json'];
export type FollowingRequestsSentResponse = operations['following___requests___sent']['responses']['200']['content']['application/json'];
export type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json']; export type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json'];
export type GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json']; export type GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json'];
export type GalleryFeaturedResponse = operations['gallery___featured']['responses']['200']['content']['application/json']; export type GalleryFeaturedResponse = operations['gallery___featured']['responses']['200']['content']['application/json'];

View file

@ -1753,6 +1753,15 @@ export type paths = {
*/ */
post: operations['following___requests___list']; post: operations['following___requests___list'];
}; };
'/following/requests/sent': {
/**
* following/requests/sent
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
post: operations['following___requests___sent'];
};
'/following/requests/reject': { '/following/requests/reject': {
/** /**
* following/requests/reject * following/requests/reject
@ -16027,6 +16036,69 @@ export type operations = {
}; };
}; };
}; };
/**
* following/requests/sent
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
following___requests___sent: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
/** @default 10 */
limit?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
/** Format: id */
id: string;
follower: components['schemas']['UserLite'];
followee: components['schemas']['UserLite'];
}[];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/** /**
* following/requests/reject * following/requests/reject
* @description No description provided. * @description No description provided.