merge: feat: Allow users to view pending follow requests they sent (!663)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663 Approved-by: Hazelnoot <acomputerdog@gmail.com> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
b15f25758a
@ -111,6 +111,7 @@ followRequest: "Send follow request"
|
||||
followRequests: "Follow requests"
|
||||
unfollow: "Unfollow"
|
||||
followRequestPending: "Follow request pending"
|
||||
pendingFollowRequests: "Pending follow requests"
|
||||
enterEmoji: "Enter an emoji"
|
||||
renote: "Boost"
|
||||
unrenote: "Remove boost"
|
||||
|
@ -374,6 +374,13 @@ export class UserEntityService implements OnModuleInit {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
||||
return this.followRequestsRepository.existsBy({
|
||||
followerId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' {
|
||||
if (user.hideOnlineStatus) return 'unknown';
|
||||
@ -642,6 +649,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
hasUnreadChannel: false, // 後方互換性のため
|
||||
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
|
||||
unreadNotificationsCount: notificationsInfo?.unreadCount,
|
||||
mutedWords: profile!.mutedWords,
|
||||
hardMutedWords: profile!.hardMutedWords,
|
||||
|
@ -578,6 +578,10 @@ export const packedMeDetailedOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
hasPendingSentFollowRequest: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
unreadNotificationsCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
|
@ -189,6 +189,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_cancel from './endpoints/following/requests/cancel.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___gallery_featured from './endpoints/gallery/featured.js';
|
||||
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
|
||||
@ -586,6 +587,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_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_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 $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 };
|
||||
@ -987,6 +989,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$following_requests_accept,
|
||||
$following_requests_cancel,
|
||||
$following_requests_list,
|
||||
$following_requests_sent,
|
||||
$following_requests_reject,
|
||||
$gallery_featured,
|
||||
$gallery_popular,
|
||||
|
@ -195,6 +195,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_cancel from './endpoints/following/requests/cancel.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___gallery_featured from './endpoints/gallery/featured.js';
|
||||
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
|
||||
@ -590,6 +591,7 @@ const eps = [
|
||||
['following/requests/accept', ep___following_requests_accept],
|
||||
['following/requests/cancel', ep___following_requests_cancel],
|
||||
['following/requests/list', ep___following_requests_list],
|
||||
['following/requests/sent', ep___following_requests_sent],
|
||||
['following/requests/reject', ep___following_requests_reject],
|
||||
['gallery/featured', ep___gallery_featured],
|
||||
['gallery/popular', ep___gallery_popular],
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -7,9 +7,9 @@ process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { inspect } from 'node:util';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { api, post, role, signup, successfulApiCall, uploadFile } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
|
||||
describe('ユーザー', () => {
|
||||
// エンティティとしてのユーザーを主眼においたテストを記述する
|
||||
@ -139,6 +139,7 @@ describe('ユーザー', () => {
|
||||
hasUnreadNotification: user.hasUnreadNotification,
|
||||
unreadNotificationsCount: user.unreadNotificationsCount,
|
||||
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
|
||||
hasPendingSentFollowRequest: user.hasPendingSentFollowRequest,
|
||||
unreadAnnouncements: user.unreadAnnouncements,
|
||||
mutedWords: user.mutedWords,
|
||||
hardMutedWords: user.hardMutedWords,
|
||||
@ -378,6 +379,7 @@ describe('ユーザー', () => {
|
||||
assert.strictEqual(response.hasUnreadNotification, false);
|
||||
assert.strictEqual(response.unreadNotificationsCount, 0);
|
||||
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
|
||||
assert.strictEqual(response.hasPendingSentFollowRequest, false);
|
||||
assert.deepStrictEqual(response.unreadAnnouncements, []);
|
||||
assert.deepStrictEqual(response.mutedWords, []);
|
||||
assert.deepStrictEqual(response.mutedInstances, []);
|
||||
|
@ -41,7 +41,7 @@ export const navbarItemDef = reactive({
|
||||
followRequests: {
|
||||
title: i18n.ts.followRequests,
|
||||
icon: 'ti ti-user-plus',
|
||||
show: computed(() => $i != null && $i.isLocked),
|
||||
show: computed(() => $i != null && ($i.isLocked || $i.hasPendingReceivedFollowRequest || $i.hasPendingSentFollowRequest)),
|
||||
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
|
||||
to: '/my/follow-requests',
|
||||
},
|
||||
|
@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader/></template>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
|
||||
<div :key="tab" class="_gaps">
|
||||
<MkPagination ref="paginationComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
@ -17,27 +19,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #default="{items}">
|
||||
<div class="mk-follow-requests">
|
||||
<div v-for="req in items" :key="req.id" class="user _panel">
|
||||
<MkAvatar class="avatar" :user="req.follower" indicator link preview/>
|
||||
<MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/>
|
||||
<div class="body">
|
||||
<div class="name">
|
||||
<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
|
||||
<p class="acct">@{{ acct(req.follower) }}</p>
|
||||
<MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA>
|
||||
<p class="acct">@{{ acct(displayUser(req)) }}</p>
|
||||
</div>
|
||||
<div class="commands">
|
||||
<MkButton class="command" rounded primary @click="accept(req.follower)"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
|
||||
<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>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkHorizontalSwipe>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef, computed } from 'vue';
|
||||
import { shallowRef, computed, ref } from 'vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { userPage, acct } from '@/filters/user.js';
|
||||
@ -45,29 +49,53 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
|
||||
import { $i } from '@/account';
|
||||
|
||||
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/sent' as const,
|
||||
limit: 10,
|
||||
},
|
||||
);
|
||||
|
||||
function accept(user) {
|
||||
misskeyApi('following/requests/accept', { userId: user.id }).then(() => {
|
||||
paginationComponent.value.reload();
|
||||
paginationComponent.value?.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function reject(user) {
|
||||
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 headerTabs = computed(() => []);
|
||||
const headerTabs = computed(() => [
|
||||
{
|
||||
key: 'list',
|
||||
title: i18n.ts.followRequests,
|
||||
icon: 'ph-envelope ph-bold ph-lg',
|
||||
}, {
|
||||
key: 'sent',
|
||||
title: i18n.ts.pendingFollowRequests,
|
||||
icon: 'ph-paper-plane-tilt ph-bold ph-lg',
|
||||
},
|
||||
]);
|
||||
|
||||
const tab = ref($i?.isLocked || !$i.hasPendingSentFollowRequest ? 'list' : 'sent');
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.followRequests,
|
||||
|
@ -1502,6 +1502,8 @@ declare namespace entities {
|
||||
FollowingRequestsCancelResponse,
|
||||
FollowingRequestsListRequest,
|
||||
FollowingRequestsListResponse,
|
||||
FollowingRequestsSentRequest,
|
||||
FollowingRequestsSentResponse,
|
||||
FollowingRequestsRejectRequest,
|
||||
GalleryFeaturedRequest,
|
||||
GalleryFeaturedResponse,
|
||||
@ -2023,6 +2025,12 @@ type FollowingRequestsListResponse = operations['following___requests___list']['
|
||||
// @public (undocumented)
|
||||
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)
|
||||
type FollowingUpdateAllRequest = operations['following___update-all']['requestBody']['content']['application/json'];
|
||||
|
||||
|
@ -2029,6 +2029,17 @@ declare module '../api.js' {
|
||||
credential?: string | null,
|
||||
): 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.
|
||||
*
|
||||
|
@ -280,6 +280,8 @@ import type {
|
||||
FollowingRequestsCancelResponse,
|
||||
FollowingRequestsListRequest,
|
||||
FollowingRequestsListResponse,
|
||||
FollowingRequestsSentRequest,
|
||||
FollowingRequestsSentResponse,
|
||||
FollowingRequestsRejectRequest,
|
||||
GalleryFeaturedRequest,
|
||||
GalleryFeaturedResponse,
|
||||
@ -774,6 +776,7 @@ export type Endpoints = {
|
||||
'following/requests/accept': { req: FollowingRequestsAcceptRequest; res: EmptyResponse };
|
||||
'following/requests/cancel': { req: FollowingRequestsCancelRequest; res: FollowingRequestsCancelResponse };
|
||||
'following/requests/list': { req: FollowingRequestsListRequest; res: FollowingRequestsListResponse };
|
||||
'following/requests/sent': { req: FollowingRequestsSentRequest; res: FollowingRequestsSentResponse };
|
||||
'following/requests/reject': { req: FollowingRequestsRejectRequest; res: EmptyResponse };
|
||||
'gallery/featured': { req: GalleryFeaturedRequest; res: GalleryFeaturedResponse };
|
||||
'gallery/popular': { req: EmptyRequest; res: GalleryPopularResponse };
|
||||
@ -1170,6 +1173,7 @@ export const endpointReqTypes: Record<keyof Endpoints, 'application/json' | 'mul
|
||||
'following/requests/accept': 'application/json',
|
||||
'following/requests/cancel': 'application/json',
|
||||
'following/requests/list': 'application/json',
|
||||
'following/requests/sent': 'application/json',
|
||||
'following/requests/reject': 'application/json',
|
||||
'gallery/featured': 'application/json',
|
||||
'gallery/popular': 'application/json',
|
||||
|
@ -283,6 +283,8 @@ export type FollowingRequestsCancelRequest = operations['following___requests___
|
||||
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 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 GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json'];
|
||||
export type GalleryFeaturedResponse = operations['gallery___featured']['responses']['200']['content']['application/json'];
|
||||
|
@ -1770,6 +1770,15 @@ export type paths = {
|
||||
*/
|
||||
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
|
||||
@ -3962,6 +3971,7 @@ export type components = {
|
||||
hasUnreadChannel: boolean;
|
||||
hasUnreadNotification: boolean;
|
||||
hasPendingReceivedFollowRequest: boolean;
|
||||
hasPendingSentFollowRequest: boolean;
|
||||
unreadNotificationsCount: number;
|
||||
mutedWords: string[][];
|
||||
hardMutedWords: string[][];
|
||||
@ -16244,6 +16254,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
|
||||
* @description No description provided.
|
||||
|
Loading…
Reference in New Issue
Block a user