summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-11 03:34:47 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-05-23 10:22:13 -0400
commit3c949f0b8113e88f474b7e27b1c0abcfe0664081 (patch)
tree486792c6825d79b137671bc4353b92427186bc9f
parentmerge: Cleanup admin user UI (!1012) (diff)
downloadsharkey-3c949f0b8113e88f474b7e27b1c0abcfe0664081.tar.gz
sharkey-3c949f0b8113e88f474b7e27b1c0abcfe0664081.tar.bz2
sharkey-3c949f0b8113e88f474b7e27b1c0abcfe0664081.zip
overhaul trending polls
* Split into local, global, and completed sections * Don't require credential, but check for local/global timeline perms * Fix rate limit * Return polls where the current user has already voted * Return non-public polls if the user has permission to view them * Apply user/instance blocks * Fetch polls + notes + users in a single step to speed up pack
-rw-r--r--locales/index.d.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts94
-rw-r--r--packages/frontend/src/pages/explore.featured.vue44
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts2
-rw-r--r--packages/misskey-js/src/autogen/types.ts8
-rw-r--r--sharkey-locales/en-US.yml4
6 files changed, 140 insertions, 24 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 69c63cc714..8a3799ce58 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -13070,6 +13070,18 @@ export interface Locale extends ILocale {
*/
"popularUsersLocal": ParameterizedString<"name">;
/**
+ * Polls trending on {host}
+ */
+ "pollsOnLocal": ParameterizedString<"host">;
+ /**
+ * Polls trending on the global network
+ */
+ "pollsOnRemote": string;
+ /**
+ * Polls that have ended recently
+ */
+ "pollsExpired": string;
+ /**
* Silenced
*/
"silenced": string;
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
index 33a9c281b3..8fed8ae590 100644
--- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
+++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -9,13 +9,13 @@ import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepo
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['notes'],
- requireCredential: true,
- kind: 'read:account',
-
res: {
type: 'array',
optional: false, nullable: false,
@@ -26,10 +26,24 @@ export const meta = {
},
},
- // 2 calls per second
+ errors: {
+ ltlDisabled: {
+ message: 'Local timeline has been disabled.',
+ code: 'LTL_DISABLED',
+ id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
+ },
+ gtlDisabled: {
+ message: 'Global timeline has been disabled.',
+ code: 'GTL_DISABLED',
+ id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b',
+ },
+ },
+
+ // Up to 10 calls, then 2 per second
limit: {
- duration: 1000,
- max: 2,
+ type: 'bucket',
+ size: 10,
+ dripRate: 500,
},
} as const;
@@ -39,6 +53,8 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
excludeChannels: { type: 'boolean', default: false },
+ local: { type: 'boolean', nullable: true, default: null },
+ expired: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -59,18 +75,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private mutingsRepository: MutingsRepository,
private noteEntityService: NoteEntityService,
+ private readonly queryService: QueryService,
+ private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.pollsRepository.createQueryBuilder('poll')
- .where('poll.userHost IS NULL')
- .andWhere('poll.userId != :meId', { meId: me.id })
- .andWhere('poll.noteVisibility = \'public\'')
- .andWhere(new Brackets(qb => {
+ .innerJoinAndSelect('poll.note', 'note')
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ ;
+
+ if (me) {
+ query.andWhere('poll.userId != :meId', { meId: me.id });
+ }
+
+ if (ps.expired) {
+ query.andWhere('poll.expiresAt IS NOT NULL');
+ query.andWhere('poll.expiresAt < :expiresMax', {
+ expiresMax: new Date(),
+ });
+ query.andWhere('poll.expiresAt >= :expiresMin', {
+ expiresMin: new Date(Date.now() - (1000 * 60 * 60 * 24 * 7)),
+ });
+ } else {
+ query.andWhere(new Brackets(qb => {
qb
.where('poll.expiresAt IS NULL')
.orWhere('poll.expiresAt > :now', { now: new Date() });
}));
+ }
+
+ const policies = await this.roleService.getUserPolicies(me?.id ?? null);
+ if (ps.local != null) {
+ if (ps.local) {
+ if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled);
+ query.andWhere('poll.userHost IS NULL');
+ } else {
+ if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
+ query.andWhere('poll.userHost IS NOT NULL');
+ }
+ } else {
+ if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled);
+ if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
+ }
+ /*
//#region exclude arleady voted polls
const votedQuery = this.pollVotesRepository.createQueryBuilder('vote')
.select('vote.noteId')
@@ -81,16 +133,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.setParameters(votedQuery.getParameters());
//#endregion
+ */
- //#region mute
- const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
- .select('muting.muteeId')
- .where('muting.muterId = :muterId', { muterId: me.id });
-
- query
- .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`);
-
- query.setParameters(mutingQuery.getParameters());
+ //#region block/mute/vis
+ this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) {
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ }
//#endregion
//#region exclude channels
@@ -107,6 +158,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (polls.length === 0) return [];
+ /*
const notes = await this.notesRepository.find({
where: {
id: In(polls.map(poll => poll.noteId)),
@@ -115,6 +167,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
id: 'DESC',
},
});
+ */
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const notes = polls.map(poll => poll.note!);
return await this.noteEntityService.packMany(notes, me, {
detail: true,
diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue
index a47e3efbc8..32ee8e2a40 100644
--- a/packages/frontend/src/pages/explore.featured.vue
+++ b/packages/frontend/src/pages/explore.featured.vue
@@ -10,27 +10,67 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="polls">{{ i18n.ts.poll }}</option>
</MkTab>
<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
- <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
+ <div v-else-if="tab === 'polls'">
+ <MkFoldableSection class="_margin">
+ <template #header><i class="ph-house ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.tsx.pollsOnLocal({ host: instance.name ?? host }) }}</template>
+ <MkNotes :pagination="paginationForPollsLocal" :disableAutoLoad="true"/>
+ </MkFoldableSection>
+
+ <MkFoldableSection class="_margin">
+ <template #header><i class="ph-globe ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsOnRemote }}</template>
+ <MkNotes :pagination="paginationForPollsRemote" :disableAutoLoad="true"/>
+ </MkFoldableSection>
+
+ <MkFoldableSection class="_margin">
+ <template #header><i class="ph-timer ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pollsExpired }}</template>
+ <MkNotes :pagination="paginationForPollsExpired" :disableAutoLoad="true"/>
+ </MkFoldableSection>
+ </div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
+import { host } from '@@/js/config.js';
import MkNotes from '@/components/MkNotes.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
+import { instance } from '@/instance.js';
const paginationForNotes = {
endpoint: 'notes/featured' as const,
limit: 10,
};
-const paginationForPolls = {
+const paginationForPollsLocal = {
+ endpoint: 'notes/polls/recommendation' as const,
+ limit: 10,
+ offsetMode: true,
+ params: {
+ excludeChannels: true,
+ local: true,
+ },
+};
+
+const paginationForPollsRemote = {
+ endpoint: 'notes/polls/recommendation' as const,
+ limit: 10,
+ offsetMode: true,
+ params: {
+ excludeChannels: true,
+ local: false,
+ },
+};
+
+const paginationForPollsExpired = {
endpoint: 'notes/polls/recommendation' as const,
limit: 10,
offsetMode: true,
params: {
excludeChannels: true,
+ local: null,
+ expired: true,
},
};
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 0dfe042811..8827fe9c39 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3840,7 +3840,7 @@ declare module '../api.js' {
/**
* No description provided.
*
- * **Credential required**: *Yes* / **Permission**: *read:account*
+ * **Credential required**: *No*
*/
request<E extends 'notes/polls/recommendation', P extends Endpoints[E]['req']>(
endpoint: E,
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index dee803a9e6..74703a2730 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3317,7 +3317,7 @@ export type paths = {
* notes/polls/recommendation
* @description No description provided.
*
- * **Credential required**: *Yes* / **Permission**: *read:account*
+ * **Credential required**: *No*
*/
post: operations['notes___polls___recommendation'];
};
@@ -27492,7 +27492,7 @@ export type operations = {
* notes/polls/recommendation
* @description No description provided.
*
- * **Credential required**: *Yes* / **Permission**: *read:account*
+ * **Credential required**: *No*
*/
notes___polls___recommendation: {
requestBody: {
@@ -27504,6 +27504,10 @@ export type operations = {
offset?: number;
/** @default false */
excludeChannels?: boolean;
+ /** @default null */
+ local?: boolean | null;
+ /** @default false */
+ expired?: boolean;
};
};
};
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index f1ad66fc8c..f3be1ea7cd 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -572,6 +572,10 @@ bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, an
popularUsersGlobal: "Users popular on the global network"
popularUsersLocal: "Users popular on {name}"
+pollsOnLocal: "Polls trending on {host}"
+pollsOnRemote: "Polls trending on the global network"
+pollsExpired: "Polls that have ended recently"
+
silenced: "Silenced"
totalFollowers: "Total followers"
totalFollowing: "Total following"