Merge pull request #1028 from syuilo/mute

Mute
This commit is contained in:
syuilo 2017-12-22 17:42:03 +09:00 committed by GitHub
commit e06dd199a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 641 additions and 21 deletions

View file

@ -346,6 +346,9 @@ desktop:
failed: "Failed to setup. please ensure that the token is correct." failed: "Failed to setup. please ensure that the token is correct."
info: "From the next sign in, enter the token that is displayed on the device in addition to the password." info: "From the next sign in, enter the token that is displayed on the device in addition to the password."
mk-mute-setting:
no-users: "No muted users"
mk-post-form: mk-post-form:
post-placeholder: "What's happening?" post-placeholder: "What's happening?"
reply-placeholder: "Reply to this post..." reply-placeholder: "Reply to this post..."
@ -379,6 +382,7 @@ desktop:
mk-settings: mk-settings:
profile: "Profile" profile: "Profile"
mute: "Mute"
drive: "Drive" drive: "Drive"
security: "Security" security: "Security"
password: "Password" password: "Password"
@ -473,6 +477,11 @@ desktop:
mk-user: mk-user:
last-used-at: "Last used at" last-used-at: "Last used at"
follows-you: "Follows you"
mute: "Mute"
muted: "Muting"
unmute: "Unmute"
photos: photos:
title: "Photos" title: "Photos"
loading: "Loading" loading: "Loading"

View file

@ -346,6 +346,9 @@ desktop:
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。" failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。" info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
mk-mute-setting:
no-users: "ミュートしているユーザーはいません"
mk-post-form: mk-post-form:
post-placeholder: "いまどうしてる?" post-placeholder: "いまどうしてる?"
reply-placeholder: "この投稿への返信..." reply-placeholder: "この投稿への返信..."
@ -379,6 +382,7 @@ desktop:
mk-settings: mk-settings:
profile: "プロフィール" profile: "プロフィール"
mute: "ミュート"
drive: "ドライブ" drive: "ドライブ"
security: "セキュリティ" security: "セキュリティ"
password: "パスワード" password: "パスワード"
@ -473,6 +477,11 @@ desktop:
mk-user: mk-user:
last-used-at: "最終アクセス" last-used-at: "最終アクセス"
follows-you: "フォローされています"
mute: "ミュートする"
muted: "ミュートしています"
unmute: "ミュート解除"
photos: photos:
title: "フォト" title: "フォト"
loading: "読み込み中" loading: "読み込み中"

View file

@ -1,5 +1,6 @@
import * as mongo from 'mongodb'; import * as mongo from 'mongodb';
import Notification from '../models/notification'; import Notification from '../models/notification';
import Mute from '../models/mute';
import event from '../event'; import event from '../event';
import serialize from '../serializers/notification'; import serialize from '../serializers/notification';
@ -32,6 +33,17 @@ export default (
setTimeout(async () => { setTimeout(async () => {
const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true }); const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true });
if (!fresh.is_read) { if (!fresh.is_read) {
//#region ただしミュートしているユーザーからの通知なら無視
const mute = await Mute.find({
muter_id: notifiee,
deleted_at: { $exists: false }
});
const mutedUserIds = mute.map(m => m.mutee_id.toString());
if (mutedUserIds.indexOf(notifier.toString()) != -1) {
return;
}
//#endregion
event(notifiee, 'unread_notification', await serialize(notification)); event(notifiee, 'unread_notification', await serialize(notification));
} }
}, 3000); }, 3000);

View file

@ -222,6 +222,23 @@ const endpoints: Endpoint[] = [
withCredential: true, withCredential: true,
kind: 'notification-read' kind: 'notification-read'
}, },
{
name: 'mute/create',
withCredential: true,
kind: 'account/write'
},
{
name: 'mute/delete',
withCredential: true,
kind: 'account/write'
},
{
name: 'mute/list',
withCredential: true,
kind: 'account/read'
},
{ {
name: 'notifications/get_unread_count', name: 'notifications/get_unread_count',
withCredential: true, withCredential: true,

View file

@ -3,6 +3,7 @@
*/ */
import $ from 'cafy'; import $ from 'cafy';
import Notification from '../../models/notification'; import Notification from '../../models/notification';
import Mute from '../../models/mute';
import serialize from '../../serializers/notification'; import serialize from '../../serializers/notification';
import getFriends from '../../common/get-friends'; import getFriends from '../../common/get-friends';
import read from '../../common/read-notification'; import read from '../../common/read-notification';
@ -45,8 +46,18 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return rej('cannot set since_id and until_id'); return rej('cannot set since_id and until_id');
} }
const mute = await Mute.find({
muter_id: user._id,
deleted_at: { $exists: false }
});
const query = { const query = {
notifiee_id: user._id notifiee_id: user._id,
$and: [{
notifier_id: {
$nin: mute.map(m => m.mutee_id)
}
}]
} as any; } as any;
const sort = { const sort = {
@ -54,12 +65,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
}; };
if (following) { if (following) {
// ID list of the user $self 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 getFriends(user._id);
query.notifier_id = { query.$and.push({
notifier_id: {
$in: followingIds $in: followingIds
}; }
});
} }
if (type) { if (type) {

View file

@ -3,6 +3,7 @@
*/ */
import $ from 'cafy'; import $ from 'cafy';
import History from '../../models/messaging-history'; import History from '../../models/messaging-history';
import Mute from '../../models/mute';
import serialize from '../../serializers/messaging-message'; import serialize from '../../serializers/messaging-message';
/** /**
@ -17,10 +18,18 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
if (limitErr) return rej('invalid limit param'); if (limitErr) return rej('invalid limit param');
const mute = await Mute.find({
muter_id: user._id,
deleted_at: { $exists: false }
});
// Get history // Get history
const history = await History const history = await History
.find({ .find({
user_id: user._id user_id: user._id,
partner: {
$nin: mute.map(m => m.mutee_id)
}
}, { }, {
limit: limit, limit: limit,
sort: { sort: {

View file

@ -6,6 +6,7 @@ import Message from '../../../models/messaging-message';
import { isValidText } from '../../../models/messaging-message'; import { isValidText } from '../../../models/messaging-message';
import History from '../../../models/messaging-history'; import History from '../../../models/messaging-history';
import User from '../../../models/user'; import User from '../../../models/user';
import Mute from '../../../models/mute';
import DriveFile from '../../../models/drive-file'; import DriveFile from '../../../models/drive-file';
import serialize from '../../../serializers/messaging-message'; import serialize from '../../../serializers/messaging-message';
import publishUserStream from '../../../event'; import publishUserStream from '../../../event';
@ -97,6 +98,17 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
setTimeout(async () => { setTimeout(async () => {
const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true });
if (!freshMessage.is_read) { if (!freshMessage.is_read) {
//#region ただしミュートされているなら発行しない
const mute = await Mute.find({
muter_id: recipient._id,
deleted_at: { $exists: false }
});
const mutedUserIds = mute.map(m => m.mutee_id.toString());
if (mutedUserIds.indexOf(user._id.toString()) != -1) {
return;
}
//#endregion
publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj);
pushSw(message.recipient_id, 'unread_messaging_message', messageObj); pushSw(message.recipient_id, 'unread_messaging_message', messageObj);
} }

View file

@ -2,6 +2,7 @@
* Module dependencies * Module dependencies
*/ */
import Message from '../../models/messaging-message'; import Message from '../../models/messaging-message';
import Mute from '../../models/mute';
/** /**
* Get count of unread messages * Get count of unread messages
@ -11,8 +12,17 @@ import Message from '../../models/messaging-message';
* @return {Promise<any>} * @return {Promise<any>}
*/ */
module.exports = (params, user) => new Promise(async (res, rej) => { module.exports = (params, user) => new Promise(async (res, rej) => {
const mute = await Mute.find({
muter_id: user._id,
deleted_at: { $exists: false }
});
const mutedUserIds = mute.map(m => m.mutee_id);
const count = await Message const count = await Message
.count({ .count({
user_id: {
$nin: mutedUserIds
},
recipient_id: user._id, recipient_id: user._id,
is_read: false is_read: false
}); });

View file

@ -0,0 +1,61 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import User from '../../models/user';
import Mute from '../../models/mute';
/**
* Mute a user
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const muter = user;
// Get 'user_id' parameter
const [userId, userIdErr] = $(params.user_id).id().$;
if (userIdErr) return rej('invalid user_id param');
// 自分自身
if (user._id.equals(userId)) {
return rej('mutee is yourself');
}
// Get mutee
const mutee = await User.findOne({
_id: userId
}, {
fields: {
data: false,
profile: false
}
});
if (mutee === null) {
return rej('user not found');
}
// Check if already muting
const exist = await Mute.findOne({
muter_id: muter._id,
mutee_id: mutee._id,
deleted_at: { $exists: false }
});
if (exist !== null) {
return rej('already muting');
}
// Create mute
await Mute.insert({
created_at: new Date(),
muter_id: muter._id,
mutee_id: mutee._id,
});
// Send response
res();
});

View file

@ -0,0 +1,63 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import User from '../../models/user';
import Mute from '../../models/mute';
/**
* Unmute a user
*
* @param {any} params
* @param {any} user
* @return {Promise<any>}
*/
module.exports = (params, user) => new Promise(async (res, rej) => {
const muter = user;
// Get 'user_id' parameter
const [userId, userIdErr] = $(params.user_id).id().$;
if (userIdErr) return rej('invalid user_id param');
// Check if the mutee is yourself
if (user._id.equals(userId)) {
return rej('mutee is yourself');
}
// Get mutee
const mutee = await User.findOne({
_id: userId
}, {
fields: {
data: false,
profile: false
}
});
if (mutee === null) {
return rej('user not found');
}
// Check not muting
const exist = await Mute.findOne({
muter_id: muter._id,
mutee_id: mutee._id,
deleted_at: { $exists: false }
});
if (exist === null) {
return rej('already not muting');
}
// Delete mute
await Mute.update({
_id: exist._id
}, {
$set: {
deleted_at: new Date()
}
});
// Send response
res();
});

View file

@ -0,0 +1,73 @@
/**
* Module dependencies
*/
import $ from 'cafy';
import Mute from '../../models/mute';
import serialize from '../../serializers/user';
import getFriends from '../../common/get-friends';
/**
* Get muted users of a user
*
* @param {any} params
* @param {any} me
* @return {Promise<any>}
*/
module.exports = (params, me) => new Promise(async (res, rej) => {
// Get 'iknow' parameter
const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$;
if (iknowErr) return rej('invalid iknow param');
// Get 'limit' parameter
const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$;
if (limitErr) return rej('invalid limit param');
// Get 'cursor' parameter
const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
if (cursorErr) return rej('invalid cursor param');
// Construct query
const query = {
muter_id: me._id,
deleted_at: { $exists: false }
} as any;
if (iknow) {
// Get my friends
const myFriends = await getFriends(me._id);
query.mutee_id = {
$in: myFriends
};
}
// カーソルが指定されている場合
if (cursor) {
query._id = {
$lt: cursor
};
}
// Get mutes
const mutes = await Mute
.find(query, {
limit: limit + 1,
sort: { _id: -1 }
});
// 「次のページ」があるかどうか
const inStock = mutes.length === limit + 1;
if (inStock) {
mutes.pop();
}
// Serialize
const users = await Promise.all(mutes.map(async m =>
await serialize(m.mutee_id, me, { detail: true })));
// Response
res({
users: users,
next: inStock ? mutes[mutes.length - 1]._id : null,
});
});

View file

@ -2,6 +2,7 @@
* Module dependencies * Module dependencies
*/ */
import Notification from '../../models/notification'; import Notification from '../../models/notification';
import Mute from '../../models/mute';
/** /**
* Get count of unread notifications * Get count of unread notifications
@ -11,9 +12,18 @@ import Notification from '../../models/notification';
* @return {Promise<any>} * @return {Promise<any>}
*/ */
module.exports = (params, user) => new Promise(async (res, rej) => { module.exports = (params, user) => new Promise(async (res, rej) => {
const mute = await Mute.find({
muter_id: user._id,
deleted_at: { $exists: false }
});
const mutedUserIds = mute.map(m => m.mutee_id);
const count = await Notification const count = await Notification
.count({ .count({
notifiee_id: user._id, notifiee_id: user._id,
notifier_id: {
$nin: mutedUserIds
},
is_read: false is_read: false
}); });

View file

@ -8,6 +8,7 @@ import { default as Post, IPost, isValidText } from '../../models/post';
import { default as User, IUser } from '../../models/user'; import { default as User, IUser } from '../../models/user';
import { default as Channel, IChannel } from '../../models/channel'; import { default as Channel, IChannel } from '../../models/channel';
import Following from '../../models/following'; import Following from '../../models/following';
import Mute from '../../models/mute';
import DriveFile from '../../models/drive-file'; import DriveFile from '../../models/drive-file';
import Watching from '../../models/post-watching'; import Watching from '../../models/post-watching';
import ChannelWatching from '../../models/channel-watching'; import ChannelWatching from '../../models/channel-watching';
@ -215,7 +216,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
poll: poll, poll: poll,
text: text, text: text,
user_id: user._id, user_id: user._id,
app_id: app ? app._id : null app_id: app ? app._id : null,
// 以下非正規化データ
_reply: reply ? { user_id: reply.user_id } : undefined,
_repost: repost ? { user_id: repost.user_id } : undefined,
}); });
// Serialize // Serialize
@ -236,7 +241,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
const mentions = []; const mentions = [];
function addMention(mentionee, reason) { async function addMention(mentionee, reason) {
// Reject if already added // Reject if already added
if (mentions.some(x => x.equals(mentionee))) return; if (mentions.some(x => x.equals(mentionee))) return;
@ -245,10 +250,17 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Publish event // Publish event
if (!user._id.equals(mentionee)) { if (!user._id.equals(mentionee)) {
const mentioneeMutes = await Mute.find({
muter_id: mentionee,
deleted_at: { $exists: false }
});
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.mutee_id.toString());
if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) {
event(mentionee, reason, postObj); event(mentionee, reason, postObj);
pushSw(mentionee, reason, postObj); pushSw(mentionee, reason, postObj);
} }
} }
}
// タイムラインへの投稿 // タイムラインへの投稿
if (!channel) { if (!channel) {

View file

@ -6,6 +6,7 @@ import $ from 'cafy';
const escapeRegexp = require('escape-regexp'); const escapeRegexp = require('escape-regexp');
import Post from '../../models/post'; import Post from '../../models/post';
import User from '../../models/user'; import User from '../../models/user';
import Mute from '../../models/mute';
import getFriends from '../../common/get-friends'; import getFriends from '../../common/get-friends';
import serialize from '../../serializers/post'; import serialize from '../../serializers/post';
import config from '../../../conf'; import config from '../../../conf';
@ -34,6 +35,10 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$; const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$;
if (followingErr) return rej('invalid following param'); 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 // Get 'reply' parameter
const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$; const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$;
if (replyErr) return rej('invalid reply param'); if (replyErr) return rej('invalid reply param');
@ -80,11 +85,11 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// If Elasticsearch is available, search by it // If Elasticsearch is available, search by it
// If not, search by MongoDB // If not, search by MongoDB
(config.elasticsearch.enable ? byElasticsearch : byNative) (config.elasticsearch.enable ? byElasticsearch : byNative)
(res, rej, me, text, user, following, reply, repost, media, poll, sinceDate, untilDate, offset, limit); (res, rej, me, text, user, following, mute, reply, repost, media, poll, sinceDate, untilDate, offset, limit);
}); });
// Search by MongoDB // Search by MongoDB
async function byNative(res, rej, me, text, userId, following, reply, repost, media, poll, sinceDate, untilDate, offset, max) { async function byNative(res, rej, me, text, userId, following, mute, reply, repost, media, poll, sinceDate, untilDate, offset, max) {
let q: any = { let q: any = {
$and: [] $and: []
}; };
@ -116,6 +121,84 @@ async function byNative(res, rej, me, text, userId, following, reply, repost, me
}); });
} }
if (me != null) {
const mutes = await Mute.find({
muter_id: me._id,
deleted_at: { $exists: false }
});
const mutedUserIds = mutes.map(m => m.mutee_id);
switch (mute) {
case 'mute_all':
push({
user_id: {
$nin: mutedUserIds
},
'_reply.user_id': {
$nin: mutedUserIds
},
'_repost.user_id': {
$nin: mutedUserIds
}
});
break;
case 'mute_related':
push({
'_reply.user_id': {
$nin: mutedUserIds
},
'_repost.user_id': {
$nin: mutedUserIds
}
});
break;
case 'mute_direct':
push({
user_id: {
$nin: mutedUserIds
}
});
break;
case 'direct_only':
push({
user_id: {
$in: mutedUserIds
}
});
break;
case 'related_only':
push({
$or: [{
'_reply.user_id': {
$in: mutedUserIds
}
}, {
'_repost.user_id': {
$in: mutedUserIds
}
}]
});
break;
case 'all_only':
push({
$or: [{
user_id: {
$in: mutedUserIds
}
}, {
'_reply.user_id': {
$in: mutedUserIds
}
}, {
'_repost.user_id': {
$in: mutedUserIds
}
}]
});
break;
}
}
if (reply != null) { if (reply != null) {
if (reply) { if (reply) {
push({ push({
@ -236,7 +319,7 @@ async function byNative(res, rej, me, text, userId, following, reply, repost, me
} }
// Search by Elasticsearch // Search by Elasticsearch
async function byElasticsearch(res, rej, me, text, userId, following, reply, repost, media, poll, sinceDate, untilDate, offset, max) { async function byElasticsearch(res, rej, me, text, userId, following, mute, reply, repost, media, poll, sinceDate, untilDate, offset, max) {
const es = require('../../db/elasticsearch'); const es = require('../../db/elasticsearch');
es.search({ es.search({

View file

@ -4,6 +4,7 @@
import $ from 'cafy'; import $ from 'cafy';
import rap from '@prezzemolo/rap'; import rap from '@prezzemolo/rap';
import Post from '../../models/post'; import Post from '../../models/post';
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 serialize from '../../serializers/post'; import serialize from '../../serializers/post';
@ -42,15 +43,23 @@ module.exports = async (params, user, app) => {
throw 'only one of since_id, until_id, since_date, until_date can be specified'; throw 'only one of since_id, until_id, since_date, until_date can be specified';
} }
const { followingIds, watchingChannelIds } = await rap({ const { followingIds, watchingChannelIds, mutedUserIds } = await rap({
// 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
followingIds: getFriends(user._id), followingIds: getFriends(user._id),
// Watchしているチャンネルを取得 // Watchしているチャンネルを取得
watchingChannelIds: ChannelWatching.find({ watchingChannelIds: ChannelWatching.find({
user_id: user._id, user_id: user._id,
// 削除されたドキュメントは除く // 削除されたドキュメントは除く
deleted_at: { $exists: false } deleted_at: { $exists: false }
}).then(watches => watches.map(w => w.channel_id)) }).then(watches => watches.map(w => w.channel_id)),
// ミュートしているユーザーを取得
mutedUserIds: Mute.find({
muter_id: user._id,
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
}).then(ms => ms.map(m => m.mutee_id))
}); });
//#region Construct query //#region Construct query
@ -77,7 +86,17 @@ module.exports = async (params, user, app) => {
channel_id: { channel_id: {
$in: watchingChannelIds $in: watchingChannelIds
} }
}] }],
// mute
user_id: {
$nin: mutedUserIds
},
'_reply.user_id': {
$nin: mutedUserIds
},
'_repost.user_id': {
$nin: mutedUserIds
},
} as any; } as any;
if (sinceId) { if (sinceId) {

3
src/api/models/mute.ts Normal file
View file

@ -0,0 +1,3 @@
import db from '../../db/mongodb';
export default db.get('mute') as any; // fuck type definition

View file

@ -6,6 +6,7 @@ import deepcopy = require('deepcopy');
import { default as User, IUser } from '../models/user'; import { default as User, IUser } from '../models/user';
import serializePost from './post'; import serializePost from './post';
import Following from '../models/following'; import Following from '../models/following';
import Mute from '../models/mute';
import getFriends from '../common/get-friends'; import getFriends from '../common/get-friends';
import config from '../../conf'; import config from '../../conf';
import rap from '@prezzemolo/rap'; import rap from '@prezzemolo/rap';
@ -113,7 +114,7 @@ export default (
} }
if (meId && !meId.equals(_user.id)) { if (meId && !meId.equals(_user.id)) {
// If the user is following // Whether the user is following
_user.is_following = (async () => { _user.is_following = (async () => {
const follow = await Following.findOne({ const follow = await Following.findOne({
follower_id: meId, follower_id: meId,
@ -123,7 +124,7 @@ export default (
return follow !== null; return follow !== null;
})(); })();
// If the user is followed // Whether the user is followed
_user.is_followed = (async () => { _user.is_followed = (async () => {
const follow2 = await Following.findOne({ const follow2 = await Following.findOne({
follower_id: _user.id, follower_id: _user.id,
@ -132,6 +133,16 @@ export default (
}); });
return follow2 !== null; return follow2 !== null;
})(); })();
// Whether the user is muted
_user.is_muted = (async () => {
const mute = await Mute.findOne({
muter_id: meId,
mutee_id: _user.id,
deleted_at: { $exists: false }
});
return mute !== null;
})();
} }
if (opts.detail) { if (opts.detail) {

View file

@ -3,19 +3,48 @@ import * as redis from 'redis';
import * as debug from 'debug'; import * as debug from 'debug';
import User from '../models/user'; import User from '../models/user';
import Mute from '../models/mute';
import serializePost from '../serializers/post'; import serializePost from '../serializers/post';
import readNotification from '../common/read-notification'; import readNotification from '../common/read-notification';
const log = debug('misskey'); const log = debug('misskey');
export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { export default async function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any) {
// Subscribe Home stream channel // Subscribe Home stream channel
subscriber.subscribe(`misskey:user-stream:${user._id}`); subscriber.subscribe(`misskey:user-stream:${user._id}`);
const mute = await Mute.find({
muter_id: user._id,
deleted_at: { $exists: false }
});
const mutedUserIds = mute.map(m => m.mutee_id.toString());
subscriber.on('message', async (channel, data) => { subscriber.on('message', async (channel, data) => {
switch (channel.split(':')[1]) { switch (channel.split(':')[1]) {
case 'user-stream': case 'user-stream':
try {
const x = JSON.parse(data);
if (x.type == 'post') {
if (mutedUserIds.indexOf(x.body.user_id) != -1) {
return;
}
if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.user_id) != -1) {
return;
}
if (x.body.repost != null && mutedUserIds.indexOf(x.body.repost.user_id) != -1) {
return;
}
} else if (x.type == 'notification') {
if (mutedUserIds.indexOf(x.body.user_id) != -1) {
return;
}
}
connection.send(data); connection.send(data);
} catch (e) {
connection.send(data);
}
break; break;
case 'post-stream': case 'post-stream':
const postId = channel.split(':')[2]; const postId = channel.split(':')[2];

View file

@ -4,6 +4,7 @@
<p class={ active: page == 'web' } onmousedown={ setPage.bind(null, 'web') }>%fa:desktop .fw%Web</p> <p class={ active: page == 'web' } onmousedown={ setPage.bind(null, 'web') }>%fa:desktop .fw%Web</p>
<p class={ active: page == 'notification' } onmousedown={ setPage.bind(null, 'notification') }>%fa:R bell .fw%通知</p> <p class={ active: page == 'notification' } onmousedown={ setPage.bind(null, 'notification') }>%fa:R bell .fw%通知</p>
<p class={ active: page == 'drive' } onmousedown={ setPage.bind(null, 'drive') }>%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> <p class={ active: page == 'drive' } onmousedown={ setPage.bind(null, 'drive') }>%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p>
<p class={ active: page == 'mute' } onmousedown={ setPage.bind(null, 'mute') }>%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p>
<p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p> <p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }>%fa:puzzle-piece .fw%アプリ</p>
<p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p> <p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }>%fa:B twitter .fw%Twitter</p>
<p class={ active: page == 'security' } onmousedown={ setPage.bind(null, 'security') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> <p class={ active: page == 'security' } onmousedown={ setPage.bind(null, 'security') }>%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p>
@ -26,6 +27,11 @@
<mk-drive-setting/> <mk-drive-setting/>
</section> </section>
<section class="mute" show={ page == 'mute' }>
<h1>%i18n:desktop.tags.mk-settings.mute%</h1>
<mk-mute-setting/>
</section>
<section class="apps" show={ page == 'apps' }> <section class="apps" show={ page == 'apps' }>
<h1>アプリケーション</h1> <h1>アプリケーション</h1>
<mk-authorized-apps/> <mk-authorized-apps/>
@ -386,3 +392,35 @@
}); });
</script> </script>
</mk-drive-setting> </mk-drive-setting>
<mk-mute-setting>
<div class="none ui info" if={ !fetching && users.length == 0 }>
<p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p>
</div>
<div class="users" if={ users.length != 0 }>
<div each={ user in users }>
<p><b>{ user.name }</b> @{ user.username }</p>
</div>
</div>
<style>
:scope
display block
</style>
<script>
this.mixin('api');
this.apps = [];
this.fetching = true;
this.on('mount', () => {
this.api('mute/list').then(x => {
this.update({
fetching: false,
users: x.users
});
});
});
</script>
</mk-mute-setting>

View file

@ -226,7 +226,9 @@
<mk-user-profile> <mk-user-profile>
<div class="friend-form" if={ SIGNIN && I.id != user.id }> <div class="friend-form" if={ SIGNIN && I.id != user.id }>
<mk-big-follow-button user={ user }/> <mk-big-follow-button user={ user }/>
<p class="followed" if={ user.is_followed }>フォローされています</p> <p class="followed" if={ user.is_followed }>%i18n:desktop.tags.mk-user.follows-you%</p>
<p if={ user.is_muted }>%i18n:desktop.tags.mk-user.muted% <a onclick={ unmute }>%i18n:desktop.tags.mk-user.unmute%</a></p>
<p if={ !user.is_muted }><a onclick={ mute }>%i18n:desktop.tags.mk-user.mute%</a></p>
</div> </div>
<div class="description" if={ user.description }>{ user.description }</div> <div class="description" if={ user.description }>{ user.description }</div>
<div class="birthday" if={ user.profile.birthday }> <div class="birthday" if={ user.profile.birthday }>
@ -311,6 +313,7 @@
this.age = require('s-age'); this.age = require('s-age');
this.mixin('i'); this.mixin('i');
this.mixin('api');
this.user = this.opts.user; this.user = this.opts.user;
@ -325,6 +328,28 @@
user: this.user user: this.user
}); });
}; };
this.mute = () => {
this.api('mute/create', {
user_id: this.user.id
}).then(() => {
this.user.is_muted = true;
this.update();
}, e => {
alert('error');
});
};
this.unmute = () => {
this.api('mute/delete', {
user_id: this.user.id
}).then(() => {
this.user.is_muted = false;
this.update();
}, e => {
alert('error');
});
};
</script> </script>
</mk-user-profile> </mk-user-profile>

View file

@ -75,6 +75,12 @@ props:
optional: true optional: true
desc: desc:
ja: "自分がこのユーザーにフォローされているか" ja: "自分がこのユーザーにフォローされているか"
- name: "is_muted"
type: "boolean"
optional: true
desc:
ja: "自分がこのユーザーをミュートしているか"
en: "Whether you muted this user"
- name: "last_used_at" - name: "last_used_at"
type: "date" type: "date"
optional: false optional: false

13
src/web/docs/mute.ja.pug Normal file
View file

@ -0,0 +1,13 @@
h1 ミュート
p ユーザーページから、そのユーザーをミュートすることができます。
p ユーザーをミュートすると、そのユーザーに関する次のコンテンツがMisskeyに表示されなくなります:
ul
li タイムラインや投稿の検索結果内の、そのユーザーの投稿(およびそれらの投稿に対する返信やRepost)
li そのユーザーからの通知
li メッセージ履歴一覧内の、そのユーザーとのメッセージ履歴
p ミュートを行ったことは相手に通知されず、ミュートされていることを知ることもできません。
p 設定>ミュート から、自分がミュートしているユーザー一覧を確認することができます。

View file

@ -29,6 +29,22 @@ section
| false ... フォローしていないユーザーに限定。 | false ... フォローしていないユーザーに限定。
br br
| null ... 特に限定しない(デフォルト) | null ... 特に限定しない(デフォルト)
tr
td mute
td
| mute_all ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostを除外する(デフォルト)
br
| mute_related ... ミュートしているユーザーの投稿に対する返信やRepostだけ除外する
br
| mute_direct ... ミュートしているユーザーの投稿だけ除外する
br
| disabled ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostも含める
br
| direct_only ... ミュートしているユーザーの投稿だけに限定
br
| related_only ... ミュートしているユーザーの投稿に対する返信やRepostだけに限定
br
| all_only ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostに限定
tr tr
td reply td reply
td td

View file

@ -0,0 +1,67 @@
// for Node.js interpret
const { default: Post } = require('../../built/api/models/post')
const { default: zip } = require('@prezzemolo/zip')
const migrate = async (post) => {
const x = {};
if (post.reply_id != null) {
const reply = await Post.findOne({
_id: post.reply_id
});
x['_reply.user_id'] = reply.user_id;
}
if (post.repost_id != null) {
const repost = await Post.findOne({
_id: post.repost_id
});
x['_repost.user_id'] = repost.user_id;
}
if (post.reply_id != null || post.repost_id != null) {
const result = await Post.update(post._id, {
$set: x,
});
return result.ok === 1;
} else {
return true;
}
}
async function main() {
const query = {
$or: [{
reply_id: {
$exists: true,
$ne: null
}
}, {
repost_id: {
$exists: true,
$ne: null
}
}]
}
const count = await Post.count(query);
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 Post.find(query, {
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)