diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts index e52660242..565c8bf1f 100644 --- a/src/client/app/mios.ts +++ b/src/client/app/mios.ts @@ -104,7 +104,7 @@ export default class MiOS extends EventEmitter { */ public streams: { localTimelineStream: LocalTimelineStreamManager; - hybridTimelineStreamManager: HybridTimelineStreamManager; + hybridTimelineStream: HybridTimelineStreamManager; globalTimelineStream: GlobalTimelineStreamManager; driveStream: DriveStreamManager; serverStatsStream: ServerStatsStreamManager; @@ -113,7 +113,7 @@ export default class MiOS extends EventEmitter { reversiStream: ReversiStreamManager; } = { localTimelineStream: null, - hybridTimelineStreamManager: null, + hybridTimelineStream: null, globalTimelineStream: null, driveStream: null, serverStatsStream: null, @@ -233,7 +233,7 @@ export default class MiOS extends EventEmitter { // Init other stream manager this.streams.localTimelineStream = new LocalTimelineStreamManager(this, this.store.state.i); - this.streams.hybridTimelineStreamManager = new HybridTimelineStreamManager(this, this.store.state.i); + this.streams.hybridTimelineStream = new HybridTimelineStreamManager(this, this.store.state.i); this.streams.globalTimelineStream = new GlobalTimelineStreamManager(this, this.store.state.i); this.streams.driveStream = new DriveStreamManager(this, this.store.state.i); this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.store.state.i); diff --git a/src/server/api/call.ts b/src/server/api/call.ts index 9257ed590..2b00f5df0 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -8,7 +8,7 @@ export default (endpoint: string | Endpoint, user: IUser, app: IApp, data: any, const ep = typeof endpoint == 'string' ? endpoints.find(e => e.name == endpoint) : endpoint; - if (ep.name.includes('.') { + if (ep.name.includes('.')) { return rej('INVALID_ENDPOINT'); } diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts index 50ba71ea9..e0b05f73d 100644 --- a/src/server/api/common/get-friends.ts +++ b/src/server/api/common/get-friends.ts @@ -23,12 +23,16 @@ export const getFriendIds = async (me: mongodb.ObjectID, includeMe = true) => { return myfollowingIds; }; -export const getFriends = async (me: mongodb.ObjectID, includeMe = true) => { +export const getFriends = async (me: mongodb.ObjectID, includeMe = true, remoteOnly = false) => { + const q: any = remoteOnly ? { + followerId: me, + '_followee.host': { $ne: null } + } : { + followerId: me + }; // Fetch relation to other users who the I follows const followings = await Following - .find({ - followerId: me - }); + .find(q); // ID list of other users who the I follows const myfollowings = followings.map(following => ({ diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 16237583b..58b251c92 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -543,6 +543,13 @@ const endpoints: Endpoint[] = [ max: 100 } }, + { + name: 'notes/hybrid-timeline', + limit: { + duration: ms('10minutes'), + max: 100 + } + }, { name: 'notes/global-timeline', limit: { diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts new file mode 100644 index 000000000..c9b352fe8 --- /dev/null +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -0,0 +1,216 @@ +import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { getFriends } from '../../common/get-friends'; +import { pack } from '../../../../models/note'; +import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; + +export const meta = { + name: 'notes/hybrid-timeline', + + desc: { + ja: 'ハイブリッドタイムラインを取得します。' + }, + + params: { + limit: $.num.optional.range(1, 100).note({ + default: 10, + desc: { + ja: '最大数' + } + }), + + sinceId: $.type(ID).optional.note({ + desc: { + ja: '指定すると、この投稿を基点としてより新しい投稿を取得します' + } + }), + + untilId: $.type(ID).optional.note({ + desc: { + ja: '指定すると、この投稿を基点としてより古い投稿を取得します' + } + }), + + sinceDate: $.num.optional.note({ + desc: { + ja: '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), + + untilDate: $.num.optional.note({ + desc: { + ja: '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), + + includeMyRenotes: $.bool.optional.note({ + default: true, + desc: { + ja: '自分の行ったRenoteを含めるかどうか' + } + }), + + includeRenotedMyNotes: $.bool.optional.note({ + default: true, + desc: { + ja: 'Renoteされた自分の投稿を含めるかどうか' + } + }), + + mediaOnly: $.bool.optional.note({ + desc: { + ja: 'true にすると、メディアが添付された投稿だけ取得します' + } + }), + } +}; + +/** + * Get hybrid timeline of myself + */ +export default async (params: any, user: ILocalUser) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + const [followings, mutedUserIds] = await Promise.all([ + // フォローを取得 + // Fetch following + getFriends(user._id, true, true), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + //#region Construct query + const sort = { + _id: -1 + }; + + const followQuery = followings.map(f => f.stalk ? { + userId: f.id + } : { + userId: f.id, + + // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + }); + + const query = { + $and: [{ + $or: [{ + // フォローしている人の投稿 + $or: followQuery + }, { + // local + '_user.host': null + }], + + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (ps.includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (ps.includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (ps.mediaOnly) { + query.$and.push({ + mediaIds: { $exists: true, $ne: [] } + }); + } + + if (ps.sinceId) { + sort._id = 1; + query._id = { + $gt: ps.sinceId + }; + } else if (ps.untilId) { + query._id = { + $lt: ps.untilId + }; + } else if (ps.sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(ps.sinceDate) + }; + } else if (ps.untilDate) { + query.createdAt = { + $lt: new Date(ps.untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: ps.limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +};