From a54de07260c3555d0230492970448604ffb9d586 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Tue, 18 Feb 2020 08:41:32 +0900 Subject: [PATCH] Resolve #5963 --- locales/ja-JP.yml | 3 + migration/1581979837262-promo.ts | 28 +++++++++ src/client/components/date-separated-list.vue | 2 +- src/client/components/note.vue | 38 +++++++++++- src/client/components/notes.vue | 2 +- src/db/postgre.ts | 4 ++ src/models/entities/promo-note.ts | 28 +++++++++ src/models/entities/promo-read.ts | 35 +++++++++++ src/models/index.ts | 4 ++ src/models/repositories/note.ts | 1 + src/server/api/common/inject-promo.ts | 36 ++++++++++++ .../api/endpoints/admin/promo/create.ts | 58 +++++++++++++++++++ .../api/endpoints/notes/global-timeline.ts | 4 +- .../api/endpoints/notes/hybrid-timeline.ts | 3 + .../api/endpoints/notes/local-timeline.ts | 3 + src/server/api/endpoints/notes/timeline.ts | 3 + 16 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 migration/1581979837262-promo.ts create mode 100644 src/models/entities/promo-note.ts create mode 100644 src/models/entities/promo-read.ts create mode 100644 src/server/api/common/inject-promo.ts create mode 100644 src/server/api/endpoints/admin/promo/create.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 059ae7e60f..288ffd39fb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -412,6 +412,9 @@ dayOverDayChanges: "前日比" accessibility: "アクセシビリティ" clinetSettings: "クライアント設定" accountSettings: "アカウント設定" +promotion: "プロモーション" +promote: "プロモート" +numberOfDays: "日数" _ago: unknown: "謎" diff --git a/migration/1581979837262-promo.ts b/migration/1581979837262-promo.ts new file mode 100644 index 0000000000..2c4f25c4d7 --- /dev/null +++ b/migration/1581979837262-promo.ts @@ -0,0 +1,28 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class promo1581979837262 implements MigrationInterface { + name = 'promo1581979837262' + + public async up(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`CREATE TABLE "promo_note" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "REL_e263909ca4fe5d57f8d4230dd5" UNIQUE ("noteId"), CONSTRAINT "PK_e263909ca4fe5d57f8d4230dd5c" PRIMARY KEY ("noteId"))`, undefined); + await queryRunner.query(`CREATE INDEX "IDX_83f0862e9bae44af52ced7099e" ON "promo_note" ("userId") `, undefined); + await queryRunner.query(`CREATE TABLE "promo_read" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_61917c1541002422b703318b7c9" PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE INDEX "IDX_9657d55550c3d37bfafaf7d4b0" ON "promo_read" ("userId") `, undefined); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId") `, undefined); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); + await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); + await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); + } + + public async down(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4"`, undefined); + await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05"`, undefined); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_2882b8a1a07c7d281a98b6db16"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_9657d55550c3d37bfafaf7d4b0"`, undefined); + await queryRunner.query(`DROP TABLE "promo_read"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_83f0862e9bae44af52ced7099e"`, undefined); + await queryRunner.query(`DROP TABLE "promo_note"`, undefined); + } + +} diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue index 461459f3ba..c425c02dce 100644 --- a/src/client/components/date-separated-list.vue +++ b/src/client/components/date-separated-list.vue @@ -2,7 +2,7 @@ <sequential-entrance class="sqadhkmv" ref="list" :direction="direction" :reversed="reversed"> <template v-for="(item, i) in items"> <slot :item="item" :i="i"></slot> - <div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()"> + <div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate() && !item._prInjectionId_ && !items[i + 1]._prInjectionId_"> <p class="date"> <span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span> <span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span> diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 963e2045f1..e6b522d8d0 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -10,6 +10,7 @@ <x-sub v-for="note in conversation" :key="note.id" :note="note"/> <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> <div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> + <div class="pinned" v-if="appearNote._prInjectionId_"><fa :icon="faBullhorn"/> {{ $t('promotion') }}</div> <div class="renote" v-if="isRenote"> <mk-avatar class="avatar" :user="note.user"/> <fa :icon="faRetweet"/> @@ -83,7 +84,7 @@ <script lang="ts"> import Vue from 'vue'; -import { faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons'; +import { faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons'; import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { parse } from '../../mfm/parse'; import { sum, unique } from '../../prelude/array'; @@ -140,7 +141,7 @@ export default Vue.extend({ replies: [], showContent: false, hideThisNote: false, - faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan + faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan }; }, @@ -522,6 +523,15 @@ export default Vue.extend({ text: this.$t('pin'), action: () => this.togglePin(true) } : undefined, + ...(this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [ + null, + { + icon: faBullhorn, + text: this.$t('promote'), + action: this.promote + }] + : [] + ), ...(this.appearNote.userId == this.$store.state.i.id ? [ null, { @@ -614,6 +624,30 @@ export default Vue.extend({ }); }, + async promote() { + const { canceled, result: days } = await this.$root.dialog({ + title: this.$t('numberOfDays'), + input: { type: 'number' } + }); + + if (canceled) return; + + this.$root.api('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + focus() { this.$el.focus(); }, diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index fb3a4314ba..2bf6327b09 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -15,7 +15,7 @@ </div> <x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> - <x-note :note="note" :detail="detail" :key="note.id"/> + <x-note :note="note" :detail="detail" :key="note._prInjectionId_ || note.id"/> </x-list> <div class="more" v-if="more && !reversed" style="margin-top: var(--margin);"> diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 38c7794402..021fe9ef69 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -55,6 +55,8 @@ import { Clip } from '../models/entities/clip'; import { ClipNote } from '../models/entities/clip-note'; import { Antenna } from '../models/entities/antenna'; import { AntennaNote } from '../models/entities/antenna-note'; +import { PromoNote } from '../models/entities/promo-note'; +import { PromoRead } from '../models/entities/promo-read'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -140,6 +142,8 @@ export const entities = [ ClipNote, Antenna, AntennaNote, + PromoNote, + PromoRead, ReversiGame, ReversiMatching, ...charts as any diff --git a/src/models/entities/promo-note.ts b/src/models/entities/promo-note.ts new file mode 100644 index 0000000000..474f1cb235 --- /dev/null +++ b/src/models/entities/promo-note.ts @@ -0,0 +1,28 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class PromoNote { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Column('timestamp with time zone') + public expiresAt: Date; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]' + }) + public userId: User['id']; + //#endregion +} diff --git a/src/models/entities/promo-read.ts b/src/models/entities/promo-read.ts new file mode 100644 index 0000000000..2e0977b6b5 --- /dev/null +++ b/src/models/entities/promo-read.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class PromoRead { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the PromoRead.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; +} diff --git a/src/models/index.ts b/src/models/index.ts index ea8fa6f911..39f185e6f4 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -50,6 +50,8 @@ import { ClipRepository } from './repositories/clip'; import { ClipNote } from './entities/clip-note'; import { AntennaRepository } from './repositories/antenna'; import { AntennaNote } from './entities/antenna-note'; +import { PromoNote } from './entities/promo-note'; +import { PromoRead } from './entities/promo-read'; export const Announcements = getRepository(Announcement); export const AnnouncementReads = getRepository(AnnouncementRead); @@ -102,3 +104,5 @@ export const Clips = getCustomRepository(ClipRepository); export const ClipNotes = getRepository(ClipNote); export const Antennas = getCustomRepository(AntennaRepository); export const AntennaNotes = getRepository(AntennaNote); +export const PromoNotes = getRepository(PromoNote); +export const PromoReads = getRepository(PromoRead); diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts index 3f7295bbdb..5d0a8768d1 100644 --- a/src/models/repositories/note.ts +++ b/src/models/repositories/note.ts @@ -196,6 +196,7 @@ export class NoteRepository extends Repository<Note> { renoteId: note.renoteId, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri || undefined, + _prInjectionId_: (note as any)._prInjectionId_ || undefined, ...(opts.detail ? { reply: note.replyId ? this.pack(note.replyId, meId, { diff --git a/src/server/api/common/inject-promo.ts b/src/server/api/common/inject-promo.ts new file mode 100644 index 0000000000..785d7af085 --- /dev/null +++ b/src/server/api/common/inject-promo.ts @@ -0,0 +1,36 @@ +import rndstr from 'rndstr'; +import { Note } from '../../../models/entities/note'; +import { User } from '../../../models/entities/user'; +import { PromoReads, PromoNotes, Notes, Users } from '../../../models'; +import { ensure } from '../../../prelude/ensure'; + +export async function injectPromo(user: User, timeline: Note[]) { + if (timeline.length < 5) return; + + // TODO: readやexpireフィルタはクエリ側でやる + + const reads = await PromoReads.find({ + userId: user.id + }); + + let promos = await PromoNotes.find(); + + promos = promos.filter(n => n.expiresAt.getTime() > Date.now()); + promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId)); + + if (promos.length === 0) return; + + const promo = promos[Math.floor(Math.random() * promos.length)]; + + // Pick random promo + const note = await Notes.findOne(promo.noteId).then(ensure); + + // Join + note.user = await Users.findOne(note.userId).then(ensure); + + (note as any)._prInjectionId_ = rndstr('a-z0-9', 8); + + // Inject promo + timeline.splice(3, 0, note); + timeline.pop(); +} diff --git a/src/server/api/endpoints/admin/promo/create.ts b/src/server/api/endpoints/admin/promo/create.ts new file mode 100644 index 0000000000..50fbb6563c --- /dev/null +++ b/src/server/api/endpoints/admin/promo/create.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { PromoNotes } from '../../../../../models'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + params: { + noteId: { + validator: $.type(ID), + }, + + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc' + }, + + alreadyPromoted: { + message: 'The note has already promoted.', + code: 'ALREADY_PROMOTED', + id: 'ae427aa2-7a41-484f-a18c-2c1104051604' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + // if already favorited + const exist = await PromoNotes.findOne(note.id); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); + } + + // Create favorite + await PromoNotes.save({ + noteId: note.id, + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); +}); diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 7475c8f078..0f69896de2 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -7,8 +7,8 @@ import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes } from '../../../../models'; import { generateMuteQuery } from '../../common/generate-mute-query'; import { activeUsersChart } from '../../../../services/chart'; -import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { injectPromo } from '../../common/inject-promo'; export const meta = { desc: { @@ -90,6 +90,8 @@ export default define(meta, async (ps, user) => { const timeline = await query.take(ps.limit!).getMany(); + await injectPromo(user, timeline); + process.nextTick(() => { if (user) { activeUsersChart.update(user); diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index 5aa18b2e91..f30fbab8a1 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query' import { generateMuteQuery } from '../../common/generate-mute-query'; import { activeUsersChart } from '../../../../services/chart'; import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { injectPromo } from '../../common/inject-promo'; export const meta = { desc: { @@ -169,6 +170,8 @@ export default define(meta, async (ps, user) => { const timeline = await query.take(ps.limit!).getMany(); + await injectPromo(user, timeline); + process.nextTick(() => { if (user) { activeUsersChart.update(user); diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index 06f00969ac..68558fb84b 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query' import { activeUsersChart } from '../../../../services/chart'; import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { injectPromo } from '../../common/inject-promo'; export const meta = { desc: { @@ -122,6 +123,8 @@ export default define(meta, async (ps, user) => { const timeline = await query.take(ps.limit!).getMany(); + await injectPromo(user, timeline); + process.nextTick(() => { if (user) { activeUsersChart.update(user); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index 2c25fbc968..8edf303e0d 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -8,6 +8,7 @@ import { generateMuteQuery } from '../../common/generate-mute-query'; import { activeUsersChart } from '../../../../services/chart'; import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { injectPromo } from '../../common/inject-promo'; export const meta = { desc: { @@ -155,6 +156,8 @@ export default define(meta, async (ps, user) => { const timeline = await query.take(ps.limit!).getMany(); + await injectPromo(user, timeline); + process.nextTick(() => { if (user) { activeUsersChart.update(user);