summaryrefslogtreecommitdiff
path: root/src/server/api/endpoints/notes
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2018-04-08 02:30:37 +0900
committersyuilo <syuilotan@yahoo.co.jp>2018-04-08 02:30:37 +0900
commita1b490afa756a71b9cef4afa424575bc223bc612 (patch)
tree06de4d839e17b1e08e0891542af7360c701a154a /src/server/api/endpoints/notes
parentMerge pull request #1392 from syuilo/greenkeeper/element-ui-2.3.3 (diff)
downloadsharkey-a1b490afa756a71b9cef4afa424575bc223bc612.tar.gz
sharkey-a1b490afa756a71b9cef4afa424575bc223bc612.tar.bz2
sharkey-a1b490afa756a71b9cef4afa424575bc223bc612.zip
Post --> Note
Closes #1411
Diffstat (limited to 'src/server/api/endpoints/notes')
-rw-r--r--src/server/api/endpoints/notes/context.ts63
-rw-r--r--src/server/api/endpoints/notes/create.ts251
-rw-r--r--src/server/api/endpoints/notes/favorites/create.ts48
-rw-r--r--src/server/api/endpoints/notes/favorites/delete.ts46
-rw-r--r--src/server/api/endpoints/notes/mentions.ts78
-rw-r--r--src/server/api/endpoints/notes/polls/recommendation.ts59
-rw-r--r--src/server/api/endpoints/notes/polls/vote.ts115
-rw-r--r--src/server/api/endpoints/notes/reactions.ts57
-rw-r--r--src/server/api/endpoints/notes/reactions/create.ts47
-rw-r--r--src/server/api/endpoints/notes/reactions/delete.ts60
-rw-r--r--src/server/api/endpoints/notes/replies.ts53
-rw-r--r--src/server/api/endpoints/notes/reposts.ts73
-rw-r--r--src/server/api/endpoints/notes/search.ts364
-rw-r--r--src/server/api/endpoints/notes/show.ts32
-rw-r--r--src/server/api/endpoints/notes/timeline.ts132
-rw-r--r--src/server/api/endpoints/notes/trend.ts79
16 files changed, 1557 insertions, 0 deletions
diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts
new file mode 100644
index 0000000000..2caf742d26
--- /dev/null
+++ b/src/server/api/endpoints/notes/context.ts
@@ -0,0 +1,63 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note, { pack } from '../../../../models/note';
+
+/**
+ * Show a context of a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Lookup note
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ const context = [];
+ let i = 0;
+
+ async function get(id) {
+ i++;
+ const p = await Note.findOne({ _id: id });
+
+ if (i > offset) {
+ context.push(p);
+ }
+
+ if (context.length == limit) {
+ return;
+ }
+
+ if (p.replyId) {
+ await get(p.replyId);
+ }
+ }
+
+ if (note.replyId) {
+ await get(note.replyId);
+ }
+
+ // Serialize
+ res(await Promise.all(context.map(async note =>
+ await pack(note, user))));
+});
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
new file mode 100644
index 0000000000..7e79912b1b
--- /dev/null
+++ b/src/server/api/endpoints/notes/create.ts
@@ -0,0 +1,251 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import deepEqual = require('deep-equal');
+import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note';
+import { ILocalUser } from '../../../../models/user';
+import Channel, { IChannel } from '../../../../models/channel';
+import DriveFile from '../../../../models/drive-file';
+import create from '../../../../services/note/create';
+import { IApp } from '../../../../models/app';
+
+/**
+ * Create a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @param {any} app
+ * @return {Promise<any>}
+ */
+module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => {
+ // Get 'visibility' parameter
+ const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$;
+ if (visibilityErr) return rej('invalid visibility');
+
+ // Get 'text' parameter
+ const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
+ if (textErr) return rej('invalid text');
+
+ // Get 'cw' parameter
+ const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$;
+ if (cwErr) return rej('invalid cw');
+
+ // Get 'viaMobile' parameter
+ const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$;
+ if (viaMobileErr) return rej('invalid viaMobile');
+
+ // Get 'tags' parameter
+ const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
+ if (tagsErr) return rej('invalid tags');
+
+ // Get 'geo' parameter
+ const [geo, geoErr] = $(params.geo).optional.nullable.strict.object()
+ .have('coordinates', $().array().length(2)
+ .item(0, $().number().range(-180, 180))
+ .item(1, $().number().range(-90, 90)))
+ .have('altitude', $().nullable.number())
+ .have('accuracy', $().nullable.number())
+ .have('altitudeAccuracy', $().nullable.number())
+ .have('heading', $().nullable.number().range(0, 360))
+ .have('speed', $().nullable.number())
+ .$;
+ if (geoErr) return rej('invalid geo');
+
+ // Get 'mediaIds' parameter
+ const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$;
+ if (mediaIdsErr) return rej('invalid mediaIds');
+
+ let files = [];
+ if (mediaIds !== undefined) {
+ // Fetch files
+ // forEach だと途中でエラーなどがあっても return できないので
+ // 敢えて for を使っています。
+ for (const mediaId of mediaIds) {
+ // Fetch file
+ // SELECT _id
+ const entity = await DriveFile.findOne({
+ _id: mediaId,
+ 'metadata.userId': user._id
+ });
+
+ if (entity === null) {
+ return rej('file not found');
+ } else {
+ files.push(entity);
+ }
+ }
+ } else {
+ files = null;
+ }
+
+ // Get 'renoteId' parameter
+ const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$;
+ if (renoteIdErr) return rej('invalid renoteId');
+
+ let renote: INote = null;
+ let isQuote = false;
+ if (renoteId !== undefined) {
+ // Fetch renote to note
+ renote = await Note.findOne({
+ _id: renoteId
+ });
+
+ if (renote == null) {
+ return rej('renoteee is not found');
+ } else if (renote.renoteId && !renote.text && !renote.mediaIds) {
+ return rej('cannot renote to renote');
+ }
+
+ // Fetch recently note
+ const latestNote = await Note.findOne({
+ userId: user._id
+ }, {
+ sort: {
+ _id: -1
+ }
+ });
+
+ isQuote = text != null || files != null;
+
+ // 直近と同じRenote対象かつ引用じゃなかったらエラー
+ if (latestNote &&
+ latestNote.renoteId &&
+ latestNote.renoteId.equals(renote._id) &&
+ !isQuote) {
+ return rej('cannot renote same note that already reposted in your latest note');
+ }
+
+ // 直近がRenote対象かつ引用じゃなかったらエラー
+ if (latestNote &&
+ latestNote._id.equals(renote._id) &&
+ !isQuote) {
+ return rej('cannot renote your latest note');
+ }
+ }
+
+ // Get 'replyId' parameter
+ const [replyId, replyIdErr] = $(params.replyId).optional.id().$;
+ if (replyIdErr) return rej('invalid replyId');
+
+ let reply: INote = null;
+ if (replyId !== undefined) {
+ // Fetch reply
+ reply = await Note.findOne({
+ _id: replyId
+ });
+
+ if (reply === null) {
+ return rej('in reply to note is not found');
+ }
+
+ // 返信対象が引用でないRenoteだったらエラー
+ if (reply.renoteId && !reply.text && !reply.mediaIds) {
+ return rej('cannot reply to renote');
+ }
+ }
+
+ // Get 'channelId' parameter
+ const [channelId, channelIdErr] = $(params.channelId).optional.id().$;
+ if (channelIdErr) return rej('invalid channelId');
+
+ let channel: IChannel = null;
+ if (channelId !== undefined) {
+ // Fetch channel
+ channel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+
+ // 返信対象の投稿がこのチャンネルじゃなかったらダメ
+ if (reply && !channelId.equals(reply.channelId)) {
+ return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
+ }
+
+ // Renote対象の投稿がこのチャンネルじゃなかったらダメ
+ if (renote && !channelId.equals(renote.channelId)) {
+ return rej('チャンネル内部からチャンネル外部の投稿をRenoteすることはできません');
+ }
+
+ // 引用ではないRenoteはダメ
+ if (renote && !isQuote) {
+ return rej('チャンネル内部では引用ではないRenoteをすることはできません');
+ }
+ } else {
+ // 返信対象の投稿がチャンネルへの投稿だったらダメ
+ if (reply && reply.channelId != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
+ }
+
+ // Renote対象の投稿がチャンネルへの投稿だったらダメ
+ if (renote && renote.channelId != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿をRenoteすることはできません');
+ }
+ }
+
+ // Get 'poll' parameter
+ const [poll, pollErr] = $(params.poll).optional.strict.object()
+ .have('choices', $().array('string')
+ .unique()
+ .range(2, 10)
+ .each(c => c.length > 0 && c.length < 50))
+ .$;
+ if (pollErr) return rej('invalid poll');
+
+ if (poll) {
+ (poll as any).choices = (poll as any).choices.map((choice, i) => ({
+ id: i, // IDを付与
+ text: choice.trim(),
+ votes: 0
+ }));
+ }
+
+ // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
+ if (text === undefined && files === null && renote === null && poll === undefined) {
+ return rej('text, mediaIds, renoteId or poll is required');
+ }
+
+ // 直近の投稿と重複してたらエラー
+ // TODO: 直近の投稿が一日前くらいなら重複とは見なさない
+ if (user.latestNote) {
+ if (deepEqual({
+ text: user.latestNote.text,
+ reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null,
+ renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null,
+ mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString())
+ }, {
+ text: text,
+ reply: reply ? reply._id.toString() : null,
+ renote: renote ? renote._id.toString() : null,
+ mediaIds: (files || []).map(file => file._id.toString())
+ })) {
+ return rej('duplicate');
+ }
+ }
+
+ // 投稿を作成
+ const note = await create(user, {
+ createdAt: new Date(),
+ media: files,
+ poll: poll,
+ text: text,
+ reply,
+ renote,
+ cw: cw,
+ tags: tags,
+ app: app,
+ viaMobile: viaMobile,
+ visibility,
+ geo
+ });
+
+ const noteObj = await pack(note, user);
+
+ // Reponse
+ res({
+ createdNote: noteObj
+ });
+});
diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts
new file mode 100644
index 0000000000..c8e7f52426
--- /dev/null
+++ b/src/server/api/endpoints/notes/favorites/create.ts
@@ -0,0 +1,48 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Favorite from '../../../../../models/favorite';
+import Note from '../../../../../models/note';
+
+/**
+ * Favorite a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get favoritee
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // if already favorited
+ const exist = await Favorite.findOne({
+ noteId: note._id,
+ userId: user._id
+ });
+
+ if (exist !== null) {
+ return rej('already favorited');
+ }
+
+ // Create favorite
+ await Favorite.insert({
+ createdAt: new Date(),
+ noteId: note._id,
+ userId: user._id
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts
new file mode 100644
index 0000000000..92aceb343b
--- /dev/null
+++ b/src/server/api/endpoints/notes/favorites/delete.ts
@@ -0,0 +1,46 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Favorite from '../../../../../models/favorite';
+import Note from '../../../../../models/note';
+
+/**
+ * Unfavorite a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get favoritee
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // if already favorited
+ const exist = await Favorite.findOne({
+ noteId: note._id,
+ userId: user._id
+ });
+
+ if (exist === null) {
+ return rej('already not favorited');
+ }
+
+ // Delete favorite
+ await Favorite.remove({
+ _id: exist._id
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts
new file mode 100644
index 0000000000..c507acbaec
--- /dev/null
+++ b/src/server/api/endpoints/notes/mentions.ts
@@ -0,0 +1,78 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note from '../../../../models/note';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../../../models/note';
+
+/**
+ * Get mentions of myself
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'following' parameter
+ const [following = false, followingError] =
+ $(params.following).optional.boolean().$;
+ if (followingError) return rej('invalid following param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ if (sinceIdErr) return rej('invalid sinceId param');
+
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ if (untilIdErr) return rej('invalid untilId param');
+
+ // Check if both of sinceId and untilId is specified
+ if (sinceId && untilId) {
+ return rej('cannot set sinceId and untilId');
+ }
+
+ // Construct query
+ const query = {
+ mentions: user._id
+ } as any;
+
+ const sort = {
+ _id: -1
+ };
+
+ if (following) {
+ const followingIds = await getFriends(user._id);
+
+ query.userId = {
+ $in: followingIds
+ };
+ }
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ }
+
+ // Issue query
+ const mentions = await Note
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(mentions.map(async mention =>
+ await pack(mention, user)
+ )));
+});
diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts
new file mode 100644
index 0000000000..cb530ea2cf
--- /dev/null
+++ b/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -0,0 +1,59 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Vote from '../../../../../models/poll-vote';
+import Note, { pack } from '../../../../../models/note';
+
+/**
+ * Get recommended polls
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get votes
+ const votes = await Vote.find({
+ userId: user._id
+ }, {
+ fields: {
+ _id: false,
+ noteId: true
+ }
+ });
+
+ const nin = votes && votes.length != 0 ? votes.map(v => v.noteId) : [];
+
+ const notes = await Note
+ .find({
+ _id: {
+ $nin: nin
+ },
+ userId: {
+ $ne: user._id
+ },
+ poll: {
+ $exists: true,
+ $ne: null
+ }
+ }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(notes.map(async note =>
+ await pack(note, user, { detail: true }))));
+});
diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts
new file mode 100644
index 0000000000..0e27f87ee2
--- /dev/null
+++ b/src/server/api/endpoints/notes/polls/vote.ts
@@ -0,0 +1,115 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Vote from '../../../../../models/poll-vote';
+import Note from '../../../../../models/note';
+import Watching from '../../../../../models/note-watching';
+import watch from '../../../../../note/watch';
+import { publishNoteStream } from '../../../../../publishers/stream';
+import notify from '../../../../../publishers/notify';
+
+/**
+ * Vote poll of a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get votee
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ if (note.poll == null) {
+ return rej('poll not found');
+ }
+
+ // Get 'choice' parameter
+ const [choice, choiceError] =
+ $(params.choice).number()
+ .pipe(c => note.poll.choices.some(x => x.id == c))
+ .$;
+ if (choiceError) return rej('invalid choice param');
+
+ // if already voted
+ const exist = await Vote.findOne({
+ noteId: note._id,
+ userId: user._id
+ });
+
+ if (exist !== null) {
+ return rej('already voted');
+ }
+
+ // Create vote
+ await Vote.insert({
+ createdAt: new Date(),
+ noteId: note._id,
+ userId: user._id,
+ choice: choice
+ });
+
+ // Send response
+ res();
+
+ const inc = {};
+ inc[`poll.choices.${findWithAttr(note.poll.choices, 'id', choice)}.votes`] = 1;
+
+ // Increment votes count
+ await Note.update({ _id: note._id }, {
+ $inc: inc
+ });
+
+ publishNoteStream(note._id, 'poll_voted');
+
+ // Notify
+ notify(note.userId, user._id, 'poll_vote', {
+ noteId: note._id,
+ choice: choice
+ });
+
+ // Fetch watchers
+ Watching
+ .find({
+ noteId: note._id,
+ userId: { $ne: user._id },
+ // 削除されたドキュメントは除く
+ deletedAt: { $exists: false }
+ }, {
+ fields: {
+ userId: true
+ }
+ })
+ .then(watchers => {
+ watchers.forEach(watcher => {
+ notify(watcher.userId, user._id, 'poll_vote', {
+ noteId: note._id,
+ choice: choice
+ });
+ });
+ });
+
+ // この投稿をWatchする
+ if (user.account.settings.autoWatch !== false) {
+ watch(user._id, note);
+ }
+});
+
+function findWithAttr(array, attr, value) {
+ for (let i = 0; i < array.length; i += 1) {
+ if (array[i][attr] === value) {
+ return i;
+ }
+ }
+ return -1;
+}
diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts
new file mode 100644
index 0000000000..bbff97bb0a
--- /dev/null
+++ b/src/server/api/endpoints/notes/reactions.ts
@@ -0,0 +1,57 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note from '../../../../models/note';
+import Reaction, { pack } from '../../../../models/note-reaction';
+
+/**
+ * Show reactions of a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'sort' parameter
+ const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ if (sortError) return rej('invalid sort param');
+
+ // Lookup note
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // Issue query
+ const reactions = await Reaction
+ .find({
+ noteId: note._id,
+ deletedAt: { $exists: false }
+ }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(reactions.map(async reaction =>
+ await pack(reaction, user))));
+});
diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts
new file mode 100644
index 0000000000..ffb7bcc35b
--- /dev/null
+++ b/src/server/api/endpoints/notes/reactions/create.ts
@@ -0,0 +1,47 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Reaction from '../../../../../models/note-reaction';
+import Note from '../../../../../models/note';
+import create from '../../../../../services/note/reaction/create';
+
+/**
+ * React to a note
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get 'reaction' parameter
+ const [reaction, reactionErr] = $(params.reaction).string().or([
+ 'like',
+ 'love',
+ 'laugh',
+ 'hmm',
+ 'surprise',
+ 'congrats',
+ 'angry',
+ 'confused',
+ 'pudding'
+ ]).$;
+ if (reactionErr) return rej('invalid reaction param');
+
+ // Fetch reactee
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ try {
+ await create(user, note, reaction);
+ } catch (e) {
+ rej(e);
+ }
+
+ res();
+});
diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts
new file mode 100644
index 0000000000..b5d738b8ff
--- /dev/null
+++ b/src/server/api/endpoints/notes/reactions/delete.ts
@@ -0,0 +1,60 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Reaction from '../../../../../models/note-reaction';
+import Note from '../../../../../models/note';
+// import event from '../../../publishers/stream';
+
+/**
+ * Unreact to a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Fetch unreactee
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // if already unreacted
+ const exist = await Reaction.findOne({
+ noteId: note._id,
+ userId: user._id,
+ deletedAt: { $exists: false }
+ });
+
+ if (exist === null) {
+ return rej('never reacted');
+ }
+
+ // Delete reaction
+ await Reaction.update({
+ _id: exist._id
+ }, {
+ $set: {
+ deletedAt: new Date()
+ }
+ });
+
+ // Send response
+ res();
+
+ const dec = {};
+ dec[`reactionCounts.${exist.reaction}`] = -1;
+
+ // Decrement reactions count
+ Note.update({ _id: note._id }, {
+ $inc: dec
+ });
+});
diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts
new file mode 100644
index 0000000000..88d9ff329a
--- /dev/null
+++ b/src/server/api/endpoints/notes/replies.ts
@@ -0,0 +1,53 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note, { pack } from '../../../../models/note';
+
+/**
+ * Show a replies of a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'sort' parameter
+ const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ if (sortError) return rej('invalid sort param');
+
+ // Lookup note
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // Issue query
+ const replies = await Note
+ .find({ replyId: note._id }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(replies.map(async note =>
+ await pack(note, user))));
+});
diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts
new file mode 100644
index 0000000000..9dfc2c3cb5
--- /dev/null
+++ b/src/server/api/endpoints/notes/reposts.ts
@@ -0,0 +1,73 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note, { pack } from '../../../../models/note';
+
+/**
+ * Show a renotes of a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ if (sinceIdErr) return rej('invalid sinceId param');
+
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ if (untilIdErr) return rej('invalid untilId param');
+
+ // Check if both of sinceId and untilId is specified
+ if (sinceId && untilId) {
+ return rej('cannot set sinceId and untilId');
+ }
+
+ // Lookup note
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {
+ renoteId: note._id
+ } as any;
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ }
+
+ // Issue query
+ const renotes = await Note
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(renotes.map(async note =>
+ await pack(note, user))));
+});
diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts
new file mode 100644
index 0000000000..bfa17b000e
--- /dev/null
+++ b/src/server/api/endpoints/notes/search.ts
@@ -0,0 +1,364 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+const escapeRegexp = require('escape-regexp');
+import Note from '../../../../models/note';
+import User from '../../../../models/user';
+import Mute from '../../../../models/mute';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../../../models/note';
+
+/**
+ * Search a note
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'text' parameter
+ const [text, textError] = $(params.text).optional.string().$;
+ if (textError) return rej('invalid text param');
+
+ // Get 'includeUserIds' parameter
+ const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$;
+ if (includeUserIdsErr) return rej('invalid includeUserIds param');
+
+ // Get 'excludeUserIds' parameter
+ const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$;
+ if (excludeUserIdsErr) return rej('invalid excludeUserIds param');
+
+ // Get 'includeUserUsernames' parameter
+ const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$;
+ if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param');
+
+ // Get 'excludeUserUsernames' parameter
+ const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$;
+ if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param');
+
+ // Get 'following' parameter
+ const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$;
+ if (followingErr) return rej('invalid following param');
+
+ // Get 'mute' parameter
+ const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$;
+ if (muteErr) return rej('invalid mute param');
+
+ // Get 'reply' parameter
+ const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$;
+ if (replyErr) return rej('invalid reply param');
+
+ // Get 'renote' parameter
+ const [renote = null, renoteErr] = $(params.renote).optional.nullable.boolean().$;
+ if (renoteErr) return rej('invalid renote param');
+
+ // Get 'media' parameter
+ const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$;
+ if (mediaErr) return rej('invalid media param');
+
+ // Get 'poll' parameter
+ const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$;
+ if (pollErr) return rej('invalid poll param');
+
+ // Get 'sinceDate' parameter
+ const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ if (sinceDateErr) throw 'invalid sinceDate param';
+
+ // Get 'untilDate' parameter
+ const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ if (untilDateErr) throw 'invalid untilDate param';
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$;
+ if (limitErr) return rej('invalid limit param');
+
+ let includeUsers = includeUserIds;
+ if (includeUserUsernames != null) {
+ const ids = (await Promise.all(includeUserUsernames.map(async (username) => {
+ const _user = await User.findOne({
+ usernameLower: username.toLowerCase()
+ });
+ return _user ? _user._id : null;
+ }))).filter(id => id != null);
+ includeUsers = includeUsers.concat(ids);
+ }
+
+ let excludeUsers = excludeUserIds;
+ if (excludeUserUsernames != null) {
+ const ids = (await Promise.all(excludeUserUsernames.map(async (username) => {
+ const _user = await User.findOne({
+ usernameLower: username.toLowerCase()
+ });
+ return _user ? _user._id : null;
+ }))).filter(id => id != null);
+ excludeUsers = excludeUsers.concat(ids);
+ }
+
+ search(res, rej, me, text, includeUsers, excludeUsers, following,
+ mute, reply, renote, media, poll, sinceDate, untilDate, offset, limit);
+});
+
+async function search(
+ res, rej, me, text, includeUserIds, excludeUserIds, following,
+ mute, reply, renote, media, poll, sinceDate, untilDate, offset, max) {
+
+ let q: any = {
+ $and: []
+ };
+
+ const push = x => q.$and.push(x);
+
+ if (text) {
+ // 完全一致検索
+ if (/"""(.+?)"""/.test(text)) {
+ const x = text.match(/"""(.+?)"""/)[1];
+ push({
+ text: x
+ });
+ } else {
+ const tags = text.split(' ').filter(x => x[0] == '#');
+ if (tags) {
+ push({
+ $and: tags.map(x => ({
+ tags: x
+ }))
+ });
+ }
+
+ push({
+ $and: text.split(' ').map(x => ({
+ // キーワードが-で始まる場合そのキーワードを除外する
+ text: x[0] == '-' ? {
+ $not: new RegExp(escapeRegexp(x.substr(1)))
+ } : new RegExp(escapeRegexp(x))
+ }))
+ });
+ }
+ }
+
+ if (includeUserIds && includeUserIds.length != 0) {
+ push({
+ userId: {
+ $in: includeUserIds
+ }
+ });
+ } else if (excludeUserIds && excludeUserIds.length != 0) {
+ push({
+ userId: {
+ $nin: excludeUserIds
+ }
+ });
+ }
+
+ if (following != null && me != null) {
+ const ids = await getFriends(me._id, false);
+ push({
+ userId: following ? {
+ $in: ids
+ } : {
+ $nin: ids.concat(me._id)
+ }
+ });
+ }
+
+ if (me != null) {
+ const mutes = await Mute.find({
+ muterId: me._id,
+ deletedAt: { $exists: false }
+ });
+ const mutedUserIds = mutes.map(m => m.muteeId);
+
+ switch (mute) {
+ case 'mute_all':
+ push({
+ userId: {
+ $nin: mutedUserIds
+ },
+ '_reply.userId': {
+ $nin: mutedUserIds
+ },
+ '_renote.userId': {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'mute_related':
+ push({
+ '_reply.userId': {
+ $nin: mutedUserIds
+ },
+ '_renote.userId': {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'mute_direct':
+ push({
+ userId: {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'direct_only':
+ push({
+ userId: {
+ $in: mutedUserIds
+ }
+ });
+ break;
+ case 'related_only':
+ push({
+ $or: [{
+ '_reply.userId': {
+ $in: mutedUserIds
+ }
+ }, {
+ '_renote.userId': {
+ $in: mutedUserIds
+ }
+ }]
+ });
+ break;
+ case 'all_only':
+ push({
+ $or: [{
+ userId: {
+ $in: mutedUserIds
+ }
+ }, {
+ '_reply.userId': {
+ $in: mutedUserIds
+ }
+ }, {
+ '_renote.userId': {
+ $in: mutedUserIds
+ }
+ }]
+ });
+ break;
+ }
+ }
+
+ if (reply != null) {
+ if (reply) {
+ push({
+ replyId: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ replyId: {
+ $exists: false
+ }
+ }, {
+ replyId: null
+ }]
+ });
+ }
+ }
+
+ if (renote != null) {
+ if (renote) {
+ push({
+ renoteId: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ renoteId: {
+ $exists: false
+ }
+ }, {
+ renoteId: null
+ }]
+ });
+ }
+ }
+
+ if (media != null) {
+ if (media) {
+ push({
+ mediaIds: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ mediaIds: {
+ $exists: false
+ }
+ }, {
+ mediaIds: null
+ }]
+ });
+ }
+ }
+
+ if (poll != null) {
+ if (poll) {
+ push({
+ poll: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ poll: {
+ $exists: false
+ }
+ }, {
+ poll: null
+ }]
+ });
+ }
+ }
+
+ if (sinceDate) {
+ push({
+ createdAt: {
+ $gt: new Date(sinceDate)
+ }
+ });
+ }
+
+ if (untilDate) {
+ push({
+ createdAt: {
+ $lt: new Date(untilDate)
+ }
+ });
+ }
+
+ if (q.$and.length == 0) {
+ q = {};
+ }
+
+ // Search notes
+ const notes = await Note
+ .find(q, {
+ sort: {
+ _id: -1
+ },
+ limit: max,
+ skip: offset
+ });
+
+ // Serialize
+ res(await Promise.all(notes.map(async note =>
+ await pack(note, me))));
+}
diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts
new file mode 100644
index 0000000000..67cdc3038b
--- /dev/null
+++ b/src/server/api/endpoints/notes/show.ts
@@ -0,0 +1,32 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Note, { pack } from '../../../../models/note';
+
+/**
+ * Show a note
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'noteId' parameter
+ const [noteId, noteIdErr] = $(params.noteId).id().$;
+ if (noteIdErr) return rej('invalid noteId param');
+
+ // Get note
+ const note = await Note.findOne({
+ _id: noteId
+ });
+
+ if (note === null) {
+ return rej('note not found');
+ }
+
+ // Serialize
+ res(await pack(note, user, {
+ detail: true
+ }));
+});
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
new file mode 100644
index 0000000000..5263cfb2aa
--- /dev/null
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -0,0 +1,132 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import rap from '@prezzemolo/rap';
+import Note from '../../../../models/note';
+import Mute from '../../../../models/mute';
+import ChannelWatching from '../../../../models/channel-watching';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../../../models/note';
+
+/**
+ * Get timeline of myself
+ *
+ * @param {any} params
+ * @param {any} user
+ * @param {any} app
+ * @return {Promise<any>}
+ */
+module.exports = async (params, user, app) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) throw 'invalid limit param';
+
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+ if (sinceIdErr) throw 'invalid sinceId param';
+
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+ if (untilIdErr) throw 'invalid untilId param';
+
+ // Get 'sinceDate' parameter
+ const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$;
+ if (sinceDateErr) throw 'invalid sinceDate param';
+
+ // Get 'untilDate' parameter
+ const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$;
+ if (untilDateErr) throw 'invalid untilDate param';
+
+ // Check if only one of sinceId, untilId, sinceDate, untilDate specified
+ if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+ throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
+ }
+
+ const { followingIds, watchingChannelIds, mutedUserIds } = await rap({
+ // ID list of the user itself and other users who the user follows
+ followingIds: getFriends(user._id),
+
+ // Watchしているチャンネルを取得
+ watchingChannelIds: ChannelWatching.find({
+ userId: user._id,
+ // 削除されたドキュメントは除く
+ deletedAt: { $exists: false }
+ }).then(watches => watches.map(w => w.channelId)),
+
+ // ミュートしているユーザーを取得
+ mutedUserIds: Mute.find({
+ muterId: user._id,
+ // 削除されたドキュメントは除く
+ deletedAt: { $exists: false }
+ }).then(ms => ms.map(m => m.muteeId))
+ });
+
+ //#region Construct query
+ const sort = {
+ _id: -1
+ };
+
+ const query = {
+ $or: [{
+ // フォローしている人のタイムラインへの投稿
+ userId: {
+ $in: followingIds
+ },
+ // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
+ $or: [{
+ channelId: {
+ $exists: false
+ }
+ }, {
+ channelId: null
+ }]
+ }, {
+ // Watchしているチャンネルへの投稿
+ channelId: {
+ $in: watchingChannelIds
+ }
+ }],
+ // mute
+ userId: {
+ $nin: mutedUserIds
+ },
+ '_reply.userId': {
+ $nin: mutedUserIds
+ },
+ '_renote.userId': {
+ $nin: mutedUserIds
+ },
+ } as any;
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ } else if (sinceDate) {
+ sort._id = 1;
+ query.createdAt = {
+ $gt: new Date(sinceDate)
+ };
+ } else if (untilDate) {
+ query.createdAt = {
+ $lt: new Date(untilDate)
+ };
+ }
+ //#endregion
+
+ // Issue query
+ const timeline = await Note
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ return await Promise.all(timeline.map(note => pack(note, user)));
+};
diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts
new file mode 100644
index 0000000000..48ecd5b843
--- /dev/null
+++ b/src/server/api/endpoints/notes/trend.ts
@@ -0,0 +1,79 @@
+/**
+ * Module dependencies
+ */
+const ms = require('ms');
+import $ from 'cafy';
+import Note, { pack } from '../../../../models/note';
+
+/**
+ * Get trend notes
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'reply' parameter
+ const [reply, replyErr] = $(params.reply).optional.boolean().$;
+ if (replyErr) return rej('invalid reply param');
+
+ // Get 'renote' parameter
+ const [renote, renoteErr] = $(params.renote).optional.boolean().$;
+ if (renoteErr) return rej('invalid renote param');
+
+ // Get 'media' parameter
+ const [media, mediaErr] = $(params.media).optional.boolean().$;
+ if (mediaErr) return rej('invalid media param');
+
+ // Get 'poll' parameter
+ const [poll, pollErr] = $(params.poll).optional.boolean().$;
+ if (pollErr) return rej('invalid poll param');
+
+ const query = {
+ createdAt: {
+ $gte: new Date(Date.now() - ms('1days'))
+ },
+ renoteCount: {
+ $gt: 0
+ }
+ } as any;
+
+ if (reply != undefined) {
+ query.replyId = reply ? { $exists: true, $ne: null } : null;
+ }
+
+ if (renote != undefined) {
+ query.renoteId = renote ? { $exists: true, $ne: null } : null;
+ }
+
+ if (media != undefined) {
+ query.mediaIds = media ? { $exists: true, $ne: null } : null;
+ }
+
+ if (poll != undefined) {
+ query.poll = poll ? { $exists: true, $ne: null } : null;
+ }
+
+ // Issue query
+ const notes = await Note
+ .find(query, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ renoteCount: -1,
+ _id: -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(notes.map(async note =>
+ await pack(note, user, { detail: true }))));
+});