ストーキング実装

Closes #1511
This commit is contained in:
syuilo 2018-04-19 12:43:25 +09:00
parent d403869945
commit 0d5bc3be66
22 changed files with 310 additions and 80 deletions

View File

@ -419,6 +419,9 @@ desktop/views/pages/user/user.photos.vue:
desktop/views/pages/user/user.profile.vue: desktop/views/pages/user/user.profile.vue:
follows-you: "Follows you" follows-you: "Follows you"
stalk: "Stalk"
stalking: "Stalking"
unstalk: "Unstalk"
mute: "Mute" mute: "Mute"
muted: "Muting" muted: "Muting"
unmute: "Unmute" unmute: "Unmute"

View File

@ -419,6 +419,9 @@ desktop/views/pages/user/user.photos.vue:
desktop/views/pages/user/user.profile.vue: desktop/views/pages/user/user.profile.vue:
follows-you: "Vous suis" follows-you: "Vous suis"
stalk: "ストークする"
stalking: "ストーキングしています"
unstalk: "ストーク解除"
mute: "Mettre en sourdine" mute: "Mettre en sourdine"
muted: "Muting" muted: "Muting"
unmute: "Enlever la sourdine" unmute: "Enlever la sourdine"

View File

@ -419,6 +419,9 @@ desktop/views/pages/user/user.photos.vue:
desktop/views/pages/user/user.profile.vue: desktop/views/pages/user/user.profile.vue:
follows-you: "フォローされています" follows-you: "フォローされています"
stalk: "ストークする"
stalking: "ストーキングしています"
unstalk: "ストーク解除"
mute: "ミュートする" mute: "ミュートする"
muted: "ミュートしています" muted: "ミュートしています"
unmute: "ミュート解除" unmute: "ミュート解除"

49
migration/2018-04-19.js Normal file
View File

@ -0,0 +1,49 @@
// for Node.js interpret
const { default: User } = require('../built/models/user');
const { default: Following } = require('../built/models/following');
const { default: zip } = require('@prezzemolo/zip')
const migrate = async (following) => {
const follower = await User.findOne({ _id: following.followerId });
const followee = await User.findOne({ _id: following.followeeId });
const result = await Following.update(following._id, {
$set: {
stalk: true,
_follower: {
host: follower.host,
inbox: follower.host != null ? follower.inbox : undefined
},
_followee: {
host: followee.host,
inbox: followee.host != null ? followee.inbox : undefined
}
}
});
return result.ok === 1;
}
async function main() {
const count = await Following.count({});
const dop = Number.parseInt(process.argv[2]) || 5
const idop = ((count - (count % dop)) / dop) + 1
return zip(
1,
async (time) => {
console.log(`${time} / ${idop}`)
const doc = await Following.find({}, {
limit: dop, skip: time * dop
})
return Promise.all(doc.map(migrate))
},
idop
).then(a => {
const rv = []
a.forEach(e => rv.push(...e))
return rv
})
}
main().then(console.dir).catch(console.error)

View File

@ -3,8 +3,14 @@
<div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id">
<mk-follow-button :user="user" size="big"/> <mk-follow-button :user="user" size="big"/>
<p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p> <p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p>
<p v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></p> <p class="stalk">
<p v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></p> <span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%i18n:@unstalk%</a></span>
<span v-if="!user.isStalking"><a @click="stalk">%i18n:@stalk%</a></span>
</p>
<p class="mute">
<span v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></span>
<span v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></span>
</p>
</div> </div>
<div class="description" v-if="user.description">{{ user.description }}</div> <div class="description" v-if="user.description">{{ user.description }}</div>
<div class="birthday" v-if="user.host === null && user.profile.birthday"> <div class="birthday" v-if="user.host === null && user.profile.birthday">
@ -47,6 +53,26 @@ export default Vue.extend({
}); });
}, },
stalk() {
(this as any).api('following/stalk', {
userId: this.user.id
}).then(() => {
this.user.isStalking = true;
}, () => {
alert('error');
});
},
unstalk() {
(this as any).api('following/unstalk', {
userId: this.user.id
}).then(() => {
this.user.isStalking = false;
}, () => {
alert('error');
});
},
mute() { mute() {
(this as any).api('mute/create', { (this as any).api('mute/create', {
userId: this.user.id userId: this.user.id

View File

@ -10,6 +10,17 @@ export type IFollowing = {
createdAt: Date; createdAt: Date;
followeeId: mongo.ObjectID; followeeId: mongo.ObjectID;
followerId: mongo.ObjectID; followerId: mongo.ObjectID;
stalk: boolean;
// 非正規化
_followee: {
host: string;
inbox?: string;
},
_follower: {
host: string;
inbox?: string;
}
}; };
/** /**

View File

@ -58,6 +58,7 @@ export type INote = {
}; };
uri: string; uri: string;
// 非正規化
_reply?: { _reply?: {
userId: mongo.ObjectID; userId: mongo.ObjectID;
}; };
@ -66,9 +67,7 @@ export type INote = {
}; };
_user: { _user: {
host: string; host: string;
account: { inbox?: string;
inbox?: string;
};
}; };
}; };

View File

@ -5,7 +5,7 @@ import db from '../db/mongodb';
import Note, { pack as packNote, deleteNote } from './note'; import Note, { pack as packNote, deleteNote } from './note';
import Following, { deleteFollowing } from './following'; import Following, { deleteFollowing } from './following';
import Mute, { deleteMute } from './mute'; import Mute, { deleteMute } from './mute';
import getFriends from '../server/api/common/get-friends'; import { getFriendIds } from '../server/api/common/get-friends';
import config from '../config'; import config from '../config';
import AccessToken, { deleteAccessToken } from './access-token'; import AccessToken, { deleteAccessToken } from './access-token';
import NoteWatching, { deleteNoteWatching } from './note-watching'; import NoteWatching, { deleteNoteWatching } from './note-watching';
@ -375,33 +375,30 @@ export const pack = (
} }
if (meId && !meId.equals(_user.id)) { if (meId && !meId.equals(_user.id)) {
// Whether the user is following const [following1, following2, mute] = await Promise.all([
_user.isFollowing = (async () => { Following.findOne({
const follow = await Following.findOne({
followerId: meId, followerId: meId,
followeeId: _user.id followeeId: _user.id
}); }),
return follow !== null; Following.findOne({
})();
// Whether the user is followed
_user.isFollowed = (async () => {
const follow2 = await Following.findOne({
followerId: _user.id, followerId: _user.id,
followeeId: meId followeeId: meId
}); }),
return follow2 !== null; Mute.findOne({
})(); muterId: meId,
muteeId: _user.id
})
]);
// Whether the user is following
_user.isFollowing = following1 !== null;
_user.isStalking = following1 && following1.stalk;
// Whether the user is followed
_user.isFollowed = following2 !== null;
// Whether the user is muted // Whether the user is muted
_user.isMuted = (async () => { _user.isMuted = mute !== null;
const mute = await Mute.findOne({
muterId: meId,
muteeId: _user.id,
deletedAt: { $exists: false }
});
return mute !== null;
})();
} }
if (opts.detail) { if (opts.detail) {
@ -413,7 +410,7 @@ export const pack = (
} }
if (meId && !meId.equals(_user.id)) { if (meId && !meId.equals(_user.id)) {
const myFollowingIds = await getFriends(meId); const myFollowingIds = await getFriendIds(meId);
// Get following you know count // Get following you know count
_user.followingYouKnowCount = Following.count({ _user.followingYouKnowCount = Following.count({

View File

@ -1,10 +1,10 @@
import * as mongodb from 'mongodb'; import * as mongodb from 'mongodb';
import Following from '../../../models/following'; import Following from '../../../models/following';
export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { export const getFriendIds = async (me: mongodb.ObjectID, includeMe = true) => {
// Fetch relation to other users who the I follows // Fetch relation to other users who the I follows
// SELECT followee // SELECT followee
const myfollowing = await Following const followings = await Following
.find({ .find({
followerId: me followerId: me
}, { }, {
@ -14,7 +14,7 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
}); });
// ID list of other users who the I follows // ID list of other users who the I follows
const myfollowingIds = myfollowing.map(follow => follow.followeeId); const myfollowingIds = followings.map(following => following.followeeId);
if (includeMe) { if (includeMe) {
myfollowingIds.push(me); myfollowingIds.push(me);
@ -22,3 +22,26 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => {
return myfollowingIds; return myfollowingIds;
}; };
export const getFriends = async (me: mongodb.ObjectID, includeMe = true) => {
// Fetch relation to other users who the I follows
const followings = await Following
.find({
followerId: me
});
// ID list of other users who the I follows
const myfollowings = followings.map(following => ({
id: following.followeeId,
stalk: following.stalk
}));
if (includeMe) {
myfollowings.push({
id: me,
stalk: true
});
}
return myfollowings;
};

View File

@ -426,6 +426,24 @@ const endpoints: Endpoint[] = [
}, },
kind: 'following-write' kind: 'following-write'
}, },
{
name: 'following/stalk',
withCredential: true,
limit: {
duration: ms('1hour'),
max: 100
},
kind: 'following-write'
},
{
name: 'following/unstalk',
withCredential: true,
limit: {
duration: ms('1hour'),
max: 100
},
kind: 'following-write'
},
{ {
name: 'notes' name: 'notes'

View File

@ -0,0 +1,36 @@
import $ from 'cafy';
import Following from '../../../../models/following';
import { isLocalUser } from '../../../../models/user';
/**
* Stalk a user
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const follower = user;
// Get 'userId' parameter
const [userId, userIdErr] = $(params.userId).id().$;
if (userIdErr) return rej('invalid userId param');
// Fetch following
const following = await Following.findOne({
followerId: follower._id,
followeeId: userId
});
if (following === null) {
return rej('following not found');
}
// Stalk
await Following.update({ _id: following._id }, {
$set: {
stalk: true
}
});
// Send response
res();
// TODO: イベント
});

View File

@ -0,0 +1,35 @@
import $ from 'cafy';
import Following from '../../../../models/following';
/**
* Unstalk a user
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const follower = user;
// Get 'userId' parameter
const [userId, userIdErr] = $(params.userId).id().$;
if (userIdErr) return rej('invalid userId param');
// Fetch following
const following = await Following.findOne({
followerId: follower._id,
followeeId: userId
});
if (following === null) {
return rej('following not found');
}
// Stalk
await Following.update({ _id: following._id }, {
$set: {
stalk: false
}
});
// Send response
res();
// TODO: イベント
});

View File

@ -5,7 +5,7 @@ import $ from 'cafy';
import Notification from '../../../../models/notification'; import Notification from '../../../../models/notification';
import Mute from '../../../../models/mute'; import Mute from '../../../../models/mute';
import { pack } from '../../../../models/notification'; import { pack } from '../../../../models/notification';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
import read from '../../common/read-notification'; import read from '../../common/read-notification';
/** /**
@ -62,7 +62,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (following) { if (following) {
// ID list of the user itself and other users who the user follows // ID list of the user itself and other users who the user follows
const followingIds = await getFriends(user._id); const followingIds = await getFriendIds(user._id);
query.$and.push({ query.$and.push({
notifierId: { notifierId: {

View File

@ -4,7 +4,7 @@
import $ from 'cafy'; import $ from 'cafy';
import Mute from '../../../../models/mute'; import Mute from '../../../../models/mute';
import { pack } from '../../../../models/user'; import { pack } from '../../../../models/user';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
/** /**
* Get muted users of a user * Get muted users of a user
@ -34,7 +34,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
if (iknow) { if (iknow) {
// Get my friends // Get my friends
const myFriends = await getFriends(me._id); const myFriends = await getFriendIds(me._id);
query.muteeId = { query.muteeId = {
$in: myFriends $in: myFriends

View File

@ -3,7 +3,7 @@
*/ */
import $ from 'cafy'; import $ from 'cafy';
import Note from '../../../../models/note'; import Note from '../../../../models/note';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
import { pack } from '../../../../models/note'; import { pack } from '../../../../models/note';
/** /**
@ -46,7 +46,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}; };
if (following) { if (following) {
const followingIds = await getFriends(user._id); const followingIds = await getFriendIds(user._id);
query.userId = { query.userId = {
$in: followingIds $in: followingIds

View File

@ -6,7 +6,7 @@ const escapeRegexp = require('escape-regexp');
import Note from '../../../../models/note'; import Note from '../../../../models/note';
import User from '../../../../models/user'; import User from '../../../../models/user';
import Mute from '../../../../models/mute'; import Mute from '../../../../models/mute';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
import { pack } from '../../../../models/note'; import { pack } from '../../../../models/note';
/** /**
@ -156,7 +156,7 @@ async function search(
} }
if (following != null && me != null) { if (following != null && me != null) {
const ids = await getFriends(me._id, false); const ids = await getFriendIds(me._id, false);
push({ push({
userId: following ? { userId: following ? {
$in: ids $in: ids

View File

@ -2,11 +2,10 @@
* Module dependencies * Module dependencies
*/ */
import $ from 'cafy'; import $ from 'cafy';
import rap from '@prezzemolo/rap';
import Note from '../../../../models/note'; import Note from '../../../../models/note';
import Mute from '../../../../models/mute'; import Mute from '../../../../models/mute';
import ChannelWatching from '../../../../models/channel-watching'; import ChannelWatching from '../../../../models/channel-watching';
import getFriends from '../../common/get-friends'; import { getFriends } from '../../common/get-friends';
import { pack } from '../../../../models/note'; import { pack } from '../../../../models/note';
/** /**
@ -38,41 +37,66 @@ module.exports = async (params, user, app) => {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
} }
const { followingIds, watchingChannelIds, mutedUserIds } = await rap({ const [followings, watchingChannelIds, mutedUserIds] = await Promise.all([
// ID list of the user itself and other users who the user follows // フォローを取得
followingIds: getFriends(user._id), // Fetch following
getFriends(user._id),
// Watchしているチャンネルを取得 // Watchしているチャンネルを取得
watchingChannelIds: ChannelWatching.find({ ChannelWatching.find({
userId: user._id, userId: user._id,
// 削除されたドキュメントは除く // 削除されたドキュメントは除く
deletedAt: { $exists: false } deletedAt: { $exists: false }
}).then(watches => watches.map(w => w.channelId)), }).then(watches => watches.map(w => w.channelId)),
// ミュートしているユーザーを取得 // ミュートしているユーザーを取得
mutedUserIds: Mute.find({ Mute.find({
muterId: user._id muterId: user._id
}).then(ms => ms.map(m => m.muteeId)) }).then(ms => ms.map(m => m.muteeId))
}); ]);
//#region Construct query //#region Construct query
const sort = { const sort = {
_id: -1 _id: -1
}; };
const followQuery = followings.map(f => f.stalk ? {
userId: f.id
} : {
userId: f.id,
// ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める)
$or: [{
// リプライでない
replyId: null
}, { // または
// リプライだが返信先が投稿者自身の投稿
$expr: {
'$_reply.userId': '$userId'
}
}, { // または
// リプライだが返信先が自分(フォロワー)の投稿
'_reply.userId': user._id
}, { // または
// 自分(フォロワー)が送信したリプライ
userId: user._id
}]
});
const query = { const query = {
$or: [{ $or: [{
// フォローしている人のタイムラインへの投稿 $and: [{
userId: { // フォローしている人のタイムラインへの投稿
$in: followingIds $or: followQuery
},
// 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
$or: [{
channelId: {
$exists: false
}
}, { }, {
channelId: null // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
$or: [{
channelId: {
$exists: false
}
}, {
channelId: null
}]
}] }]
}, { }, {
// Watchしているチャンネルへの投稿 // Watchしているチャンネルへの投稿

View File

@ -5,7 +5,7 @@ import $ from 'cafy';
import User from '../../../../models/user'; import User from '../../../../models/user';
import Following from '../../../../models/following'; import Following from '../../../../models/following';
import { pack } from '../../../../models/user'; import { pack } from '../../../../models/user';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
/** /**
* Get followers of a user * Get followers of a user
@ -52,7 +52,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// ログインしていてかつ iknow フラグがあるとき // ログインしていてかつ iknow フラグがあるとき
if (me && iknow) { if (me && iknow) {
// Get my friends // Get my friends
const myFriends = await getFriends(me._id); const myFriends = await getFriendIds(me._id);
query.followerId = { query.followerId = {
$in: myFriends $in: myFriends

View File

@ -5,7 +5,7 @@ import $ from 'cafy';
import User from '../../../../models/user'; import User from '../../../../models/user';
import Following from '../../../../models/following'; import Following from '../../../../models/following';
import { pack } from '../../../../models/user'; import { pack } from '../../../../models/user';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
/** /**
* Get following users of a user * Get following users of a user
@ -52,7 +52,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// ログインしていてかつ iknow フラグがあるとき // ログインしていてかつ iknow フラグがあるとき
if (me && iknow) { if (me && iknow) {
// Get my friends // Get my friends
const myFriends = await getFriends(me._id); const myFriends = await getFriendIds(me._id);
query.followeeId = { query.followeeId = {
$in: myFriends $in: myFriends

View File

@ -4,7 +4,7 @@
const ms = require('ms'); const ms = require('ms');
import $ from 'cafy'; import $ from 'cafy';
import User, { pack } from '../../../../models/user'; import User, { pack } from '../../../../models/user';
import getFriends from '../../common/get-friends'; import { getFriendIds } from '../../common/get-friends';
import Mute from '../../../../models/mute'; import Mute from '../../../../models/mute';
/** /**
@ -24,7 +24,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
if (offsetErr) return rej('invalid offset param'); if (offsetErr) return rej('invalid offset param');
// ID list of the user itself and other users who the user follows // ID list of the user itself and other users who the user follows
const followingIds = await getFriends(me._id); const followingIds = await getFriendIds(me._id);
// ミュートしているユーザーを取得 // ミュートしているユーザーを取得
const mutedUserIds = (await Mute.find({ const mutedUserIds = (await Mute.find({

View File

@ -13,7 +13,18 @@ export default async function(follower: IUser, followee: IUser, activity?) {
const following = await Following.insert({ const following = await Following.insert({
createdAt: new Date(), createdAt: new Date(),
followerId: follower._id, followerId: follower._id,
followeeId: followee._id followeeId: followee._id,
stalk: true,
// 非正規化
_follower: {
host: follower.host,
inbox: isRemoteUser(follower) ? follower.inbox : undefined
},
_followee: {
host: followee.host,
inbox: isRemoteUser(followee) ? followee.inbox : undefined
}
}); });
//#region Increment following count //#region Increment following count

View File

@ -124,19 +124,8 @@ export default async (user: IUser, data: {
publishGlobalTimelineStream(noteObj); publishGlobalTimelineStream(noteObj);
// Fetch all followers // Fetch all followers
const followers = await Following.aggregate([{ const followers = await Following.find({
$lookup: { followeeId: note.userId
from: 'users',
localField: 'followerId',
foreignField: '_id',
as: 'user'
}
}, {
$match: {
followeeId: note.userId
}
}], {
_id: false
}); });
if (!silent) { if (!silent) {
@ -157,12 +146,15 @@ export default async (user: IUser, data: {
deliver(user, await render(), data.renote._user.inbox); deliver(user, await render(), data.renote._user.inbox);
} }
Promise.all(followers.map(async follower => { Promise.all(followers.map(async following => {
follower = follower.user[0]; const follower = following._follower;
if (isLocalUser(follower)) { if (isLocalUser(follower)) {
// この投稿が返信かつstalkフォローでないならスキップ
if (note.replyId && !following.stalk) return;
// Publish event to followers stream // Publish event to followers stream
stream(follower._id, 'note', noteObj); stream(following.followerId, 'note', noteObj);
} else { } else {
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
if (isLocalUser(user)) { if (isLocalUser(user)) {