From 380749051d1bdb63c667dd055f949f339c356e35 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 17 May 2019 19:56:47 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AB=E3=81=84?= =?UTF-8?q?=E3=81=84=E3=81=AD=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/ja-JP.yml | 4 + migration/1558072954435-PageLike.ts | 23 +++ .../app/common/views/pages/page/page.vue | 32 +++- src/client/app/common/views/pages/pages.vue | 138 ++++++++++++++++++ src/client/app/desktop/script.ts | 2 +- src/client/app/desktop/views/home/pages.vue | 92 ------------ src/client/app/mobile/views/pages/pages.vue | 79 +--------- src/db/postgre.ts | 2 + src/models/entities/page-like.ts | 33 +++++ src/models/entities/page.ts | 5 + src/models/index.ts | 2 + src/models/repositories/page-like.ts | 26 ++++ src/models/repositories/page.ts | 52 ++++--- src/server/api/endpoints/i/page-likes.ts | 45 ++++++ src/server/api/endpoints/pages/like.ts | 79 ++++++++++ src/server/api/endpoints/pages/show.ts | 2 +- src/server/api/endpoints/pages/unlike.ts | 62 ++++++++ src/server/api/kinds.ts | 2 + 18 files changed, 489 insertions(+), 191 deletions(-) create mode 100644 migration/1558072954435-PageLike.ts create mode 100644 src/client/app/common/views/pages/pages.vue delete mode 100644 src/client/app/desktop/views/home/pages.vue create mode 100644 src/models/entities/page-like.ts create mode 100644 src/models/repositories/page-like.ts create mode 100644 src/server/api/endpoints/i/page-likes.ts create mode 100644 src/server/api/endpoints/pages/like.ts create mode 100644 src/server/api/endpoints/pages/unlike.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f34b015639..dc0692e4b9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1874,6 +1874,10 @@ pages: edit-this-page: "このページを編集" view-source: "ソースを表示" view-page: "ページを見る" + like: "いいね" + unlike: "いいね解除" + liked-pages: "いいねしたページ" + my-pages: "自分のページ" inspector: "インスペクター" content: "ページブロック" variables: "変数" diff --git a/migration/1558072954435-PageLike.ts b/migration/1558072954435-PageLike.ts new file mode 100644 index 0000000000..93cdb8afeb --- /dev/null +++ b/migration/1558072954435-PageLike.ts @@ -0,0 +1,23 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class PageLike1558072954435 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "page_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "pageId" character varying(32) NOT NULL, CONSTRAINT "PK_813f034843af992d3ae0f43c64c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0e61efab7f88dbb79c9166dbb4" ON "page_like" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa" ON "page_like" ("userId", "pageId") `); + await queryRunner.query(`ALTER TABLE "page" ADD "likedCount" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "page_like" ADD CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "page_like" ADD CONSTRAINT "FK_cf8782626dced3176038176a847" FOREIGN KEY ("pageId") REFERENCES "page"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "page_like" DROP CONSTRAINT "FK_cf8782626dced3176038176a847"`); + await queryRunner.query(`ALTER TABLE "page_like" DROP CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48"`); + await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "likedCount"`); + await queryRunner.query(`DROP INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa"`); + await queryRunner.query(`DROP INDEX "IDX_0e61efab7f88dbb79c9166dbb4"`); + await queryRunner.query(`DROP TABLE "page_like"`); + } + +} diff --git a/src/client/app/common/views/pages/page/page.vue b/src/client/app/common/views/pages/page/page.vue index 29580fab64..d3fb948c85 100644 --- a/src/client/app/common/views/pages/page/page.vue +++ b/src/client/app/common/views/pages/page/page.vue @@ -12,6 +12,11 @@ @{{ page.user.username }} {{ $t('edit-this-page') }} {{ $t('view-source') }} + @@ -19,8 +24,8 @@ @@ -161,4 +184,7 @@ export default Vue.extend({ > a + a margin-left 8px + > .like + margin-top 16px + diff --git a/src/client/app/common/views/pages/pages.vue b/src/client/app/common/views/pages/pages.vue new file mode 100644 index 0000000000..751ea72374 --- /dev/null +++ b/src/client/app/common/views/pages/pages.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index e8da235263..464f7d3ce9 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -156,7 +156,7 @@ init(async (launch, os) => { { path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, - { path: '/i/pages', component: () => import('./views/home/pages.vue').then(m => m.default) }, + { path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) }, ]}, { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, { path: '/@:user/pages/:pageName/view-source', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, diff --git a/src/client/app/desktop/views/home/pages.vue b/src/client/app/desktop/views/home/pages.vue deleted file mode 100644 index 9f7fb65159..0000000000 --- a/src/client/app/desktop/views/home/pages.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue index 100c814ad9..2fd134fcd2 100644 --- a/src/client/app/mobile/views/pages/pages.vue +++ b/src/client/app/mobile/views/pages/pages.vue @@ -3,92 +3,27 @@
- - - - - {{ $t('@.load-more') }} +
+ - - diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 18283836aa..f488af03ca 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -41,6 +41,7 @@ import { UserKeypair } from '../models/entities/user-keypair'; import { UserPublickey } from '../models/entities/user-publickey'; import { UserProfile } from '../models/entities/user-profile'; import { Page } from '../models/entities/page'; +import { PageLike } from '../models/entities/page-like'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -116,6 +117,7 @@ export function initDb(justBorrow = false, sync = false, log = false) { NoteWatching, NoteUnread, Page, + PageLike, Log, DriveFile, DriveFolder, diff --git a/src/models/entities/page-like.ts b/src/models/entities/page-like.ts new file mode 100644 index 0000000000..ca84ece8fd --- /dev/null +++ b/src/models/entities/page-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { Page } from './page'; + +@Entity() +@Index(['userId', 'pageId'], { unique: true }) +export class PageLike { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public pageId: Page['id']; + + @ManyToOne(type => Page, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public page: Page | null; +} diff --git a/src/models/entities/page.ts b/src/models/entities/page.ts index f57ca8c7c3..05015ba175 100644 --- a/src/models/entities/page.ts +++ b/src/models/entities/page.ts @@ -95,6 +95,11 @@ export class Page { }) public visibleUserIds: User['id'][]; + @Column('integer', { + default: 0 + }) + public likedCount: number; + constructor(data: Partial) { if (data == null) return; diff --git a/src/models/index.ts b/src/models/index.ts index e402d6723d..a63bb2c2b5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -36,6 +36,7 @@ import { AuthSessionRepository } from './repositories/auth-session'; import { UserProfile } from './entities/user-profile'; import { HashtagRepository } from './repositories/hashtag'; import { PageRepository } from './repositories/page'; +import { PageLikeRepository } from './repositories/page-like'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -74,3 +75,4 @@ export const ReversiGames = getCustomRepository(ReversiGameRepository); export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); export const Pages = getCustomRepository(PageRepository); +export const PageLikes = getCustomRepository(PageLikeRepository); diff --git a/src/models/repositories/page-like.ts b/src/models/repositories/page-like.ts new file mode 100644 index 0000000000..3e7e803fdb --- /dev/null +++ b/src/models/repositories/page-like.ts @@ -0,0 +1,26 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { PageLike } from '../entities/page-like'; +import { Pages } from '..'; +import { ensure } from '../../prelude/ensure'; + +@EntityRepository(PageLike) +export class PageLikeRepository extends Repository { + public async pack( + src: PageLike['id'] | PageLike, + me?: any + ) { + const like = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + + return { + id: like.id, + page: await Pages.pack(like.page || like.pageId, me), + }; + } + + public packMany( + likes: any[], + me: any + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts index 2293edbc0d..3b41420025 100644 --- a/src/models/repositories/page.ts +++ b/src/models/repositories/page.ts @@ -1,24 +1,30 @@ import { EntityRepository, Repository } from 'typeorm'; import { Page } from '../entities/page'; import { SchemaType, types, bool } from '../../misc/schema'; -import { Users, DriveFiles } from '..'; +import { Users, DriveFiles, PageLikes } from '..'; import { awaitAll } from '../../prelude/await-all'; import { DriveFile } from '../entities/drive-file'; +import { User } from '../entities/user'; +import { ensure } from '../../prelude/ensure'; export type PackedPage = SchemaType; @EntityRepository(Page) export class PageRepository extends Repository { public async pack( - src: Page, + src: Page['id'] | Page, + me?: User['id'] | User | null | undefined, ): Promise { + const meId = me ? typeof me === 'string' ? me : me.id : null; + const page = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const attachedFiles: Promise[] = []; const collectFile = (xs: any[]) => { for (const x of xs) { if (x.type === 'image') { attachedFiles.push(DriveFiles.findOne({ id: x.fileId, - userId: src.userId + userId: page.userId })); } if (x.children) { @@ -26,7 +32,7 @@ export class PageRepository extends Repository { } } }; - collectFile(src.content); + collectFile(page.content); // 後方互換性のため let migrated = false; @@ -47,29 +53,31 @@ export class PageRepository extends Repository { } } }; - migrate(src.content); + migrate(page.content); if (migrated) { - this.update(src.id, { - content: src.content + this.update(page.id, { + content: page.content }); } return await awaitAll({ - id: src.id, - createdAt: src.createdAt.toISOString(), - updatedAt: src.updatedAt.toISOString(), - userId: src.userId, - user: Users.pack(src.user || src.userId), - content: src.content, - variables: src.variables, - title: src.title, - name: src.name, - summary: src.summary, - alignCenter: src.alignCenter, - font: src.font, - eyeCatchingImageId: src.eyeCatchingImageId, - eyeCatchingImage: src.eyeCatchingImageId ? await DriveFiles.pack(src.eyeCatchingImageId) : null, - attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)) + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: Users.pack(page.user || page.userId), + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + alignCenter: page.alignCenter, + font: page.font, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await DriveFiles.pack(page.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)), + likedCount: page.likedCount, + isLiked: meId ? await PageLikes.findOne({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, }); } diff --git a/src/server/api/endpoints/i/page-likes.ts b/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000000..23bde74c99 --- /dev/null +++ b/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { PageLikes } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + desc: { + 'ja-JP': '「いいね」したページ一覧を取得します。', + 'en-US': 'Get liked pages' + }, + + tags: ['account', 'pages'], + + requireCredential: true, + + kind: 'read:page-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await PageLikes.packMany(likes, user); +}); diff --git a/src/server/api/endpoints/pages/like.ts b/src/server/api/endpoints/pages/like.ts new file mode 100644 index 0000000000..5a50bd6c6c --- /dev/null +++ b/src/server/api/endpoints/pages/like.ts @@ -0,0 +1,79 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '../../../../models'; +import { genId } from '../../../../misc/gen-id'; + +export const meta = { + desc: { + 'ja-JP': '指定したページを「いいね」します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + + yourPage: { + message: 'You cannot like your page.', + code: 'YOUR_PAGE', + id: '28800466-e6db-40f2-8fae-bf9e82aa92b8' + }, + + alreadyLiked: { + message: 'The page has already been liked.', + code: 'ALREADY_LIKED', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (page.userId === user.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.save({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id + }); + + Pages.increment({ id: page.id }, 'likedCount', 1); +}); diff --git a/src/server/api/endpoints/pages/show.ts b/src/server/api/endpoints/pages/show.ts index dd1dc9f255..e3d6e6a15f 100644 --- a/src/server/api/endpoints/pages/show.ts +++ b/src/server/api/endpoints/pages/show.ts @@ -70,5 +70,5 @@ export default define(meta, async (ps, user) => { throw new ApiError(meta.errors.noSuchPage); } - return await Pages.pack(page); + return await Pages.pack(page, user); }); diff --git a/src/server/api/endpoints/pages/unlike.ts b/src/server/api/endpoints/pages/unlike.ts new file mode 100644 index 0000000000..49ad999b31 --- /dev/null +++ b/src/server/api/endpoints/pages/unlike.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したページの「いいね」を解除します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'a0d41e20-1993-40bd-890e-f6e560ae648e' + }, + + notLiked: { + message: 'You have not liked that page.', + code: 'NOT_LIKED', + id: 'f5e586b0-ce93-4050-b0e3-7f31af5259ee' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(exist.id); + + Pages.decrement({ id: page.id }, 'likedCount', 1); +}); diff --git a/src/server/api/kinds.ts b/src/server/api/kinds.ts index 99c3795589..76d5a8a61a 100644 --- a/src/server/api/kinds.ts +++ b/src/server/api/kinds.ts @@ -21,4 +21,6 @@ export const kinds = [ 'write:votes', 'read:pages', 'write:pages', + 'write:page-likes', + 'read:page-likes', ];