summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-10-31 15:30:22 +0900
committerGitHub <noreply@github.com>2021-10-31 15:30:22 +0900
commitfc65190ef7b687650018cccfee2219bf00827f70 (patch)
treed2482c79dd095509b8b57dac28db460fb812196a /src
parentfix: Fix #7895 (#7937) (diff)
downloadsharkey-fc65190ef7b687650018cccfee2219bf00827f70.tar.gz
sharkey-fc65190ef7b687650018cccfee2219bf00827f70.tar.bz2
sharkey-fc65190ef7b687650018cccfee2219bf00827f70.zip
feat: thread mute (#7930)
* feat: thread mute * chore: fix comment * fix test * fix * refactor
Diffstat (limited to 'src')
-rw-r--r--src/client/components/note-detailed.vue15
-rw-r--r--src/client/components/note.vue15
-rw-r--r--src/db/postgre.ts2
-rw-r--r--src/models/entities/note-thread-muting.ts33
-rw-r--r--src/models/entities/note.ts6
-rw-r--r--src/models/index.ts2
-rw-r--r--src/server/api/common/generate-muted-note-thread-query.ts17
-rw-r--r--src/server/api/endpoints/notes/mentions.ts2
-rw-r--r--src/server/api/endpoints/notes/state.ts28
-rw-r--r--src/server/api/endpoints/notes/thread-muting/create.ts54
-rw-r--r--src/server/api/endpoints/notes/thread-muting/delete.ts40
-rw-r--r--src/services/note/create.ts29
-rw-r--r--src/services/note/unread.ts11
13 files changed, 241 insertions, 13 deletions
diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue
index 40b0a68c58..568a2360d1 100644
--- a/src/client/components/note-detailed.vue
+++ b/src/client/components/note-detailed.vue
@@ -601,6 +601,12 @@ export default defineComponent({
});
},
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
getMenu() {
let menu;
if (this.$i) {
@@ -657,6 +663,15 @@ export default defineComponent({
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 91a3e3b87d..681e819a22 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -576,6 +576,12 @@ export default defineComponent({
});
},
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
getMenu() {
let menu;
if (this.$i) {
@@ -632,6 +638,15 @@ export default defineComponent({
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 4f4047b613..f52c2ab722 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -17,6 +17,7 @@ import { PollVote } from '@/models/entities/poll-vote';
import { Note } from '@/models/entities/note';
import { NoteReaction } from '@/models/entities/note-reaction';
import { NoteWatching } from '@/models/entities/note-watching';
+import { NoteThreadMuting } from '@/models/entities/note-thread-muting';
import { NoteUnread } from '@/models/entities/note-unread';
import { Notification } from '@/models/entities/notification';
import { Meta } from '@/models/entities/meta';
@@ -138,6 +139,7 @@ export const entities = [
NoteFavorite,
NoteReaction,
NoteWatching,
+ NoteThreadMuting,
NoteUnread,
Page,
PageLike,
diff --git a/src/models/entities/note-thread-muting.ts b/src/models/entities/note-thread-muting.ts
new file mode 100644
index 0000000000..b438522a4c
--- /dev/null
+++ b/src/models/entities/note-thread-muting.ts
@@ -0,0 +1,33 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { Note } from './note';
+import { id } from '../id';
+
+@Entity()
+@Index(['userId', 'threadId'], { unique: true })
+export class NoteThreadMuting {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('timestamp with time zone', {
+ })
+ public createdAt: Date;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public userId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE'
+ })
+ @JoinColumn()
+ public user: User | null;
+
+ @Index()
+ @Column('varchar', {
+ length: 256,
+ })
+ public threadId: string;
+}
diff --git a/src/models/entities/note.ts b/src/models/entities/note.ts
index 9a85532637..4a5411f93d 100644
--- a/src/models/entities/note.ts
+++ b/src/models/entities/note.ts
@@ -47,6 +47,12 @@ export class Note {
@JoinColumn()
public renote: Note | null;
+ @Index()
+ @Column('varchar', {
+ length: 256, nullable: true
+ })
+ public threadId: string | null;
+
@Column('varchar', {
length: 8192, nullable: true
})
diff --git a/src/models/index.ts b/src/models/index.ts
index 4c6f19eaff..7154cca550 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -7,6 +7,7 @@ import { PollVote } from './entities/poll-vote';
import { Meta } from './entities/meta';
import { SwSubscription } from './entities/sw-subscription';
import { NoteWatching } from './entities/note-watching';
+import { NoteThreadMuting } from './entities/note-thread-muting';
import { NoteUnread } from './entities/note-unread';
import { RegistrationTicket } from './entities/registration-tickets';
import { UserRepository } from './repositories/user';
@@ -69,6 +70,7 @@ export const Apps = getCustomRepository(AppRepository);
export const Notes = getCustomRepository(NoteRepository);
export const NoteFavorites = getCustomRepository(NoteFavoriteRepository);
export const NoteWatchings = getRepository(NoteWatching);
+export const NoteThreadMutings = getRepository(NoteThreadMuting);
export const NoteReactions = getCustomRepository(NoteReactionRepository);
export const NoteUnreads = getRepository(NoteUnread);
export const Polls = getRepository(Poll);
diff --git a/src/server/api/common/generate-muted-note-thread-query.ts b/src/server/api/common/generate-muted-note-thread-query.ts
new file mode 100644
index 0000000000..7e2cbd498b
--- /dev/null
+++ b/src/server/api/common/generate-muted-note-thread-query.ts
@@ -0,0 +1,17 @@
+import { User } from '@/models/entities/user';
+import { NoteThreadMutings } from '@/models/index';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted')
+ .select('threadMuted.threadId')
+ .where('threadMuted.userId = :userId', { userId: me.id });
+
+ q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
+ q.andWhere(new Brackets(qb => { qb
+ .where(`note.threadId IS NULL`)
+ .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
+ }));
+
+ q.setParameters(mutedQuery.getParameters());
+}
diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts
index 74f7911bfe..ffaebd6c95 100644
--- a/src/server/api/endpoints/notes/mentions.ts
+++ b/src/server/api/endpoints/notes/mentions.ts
@@ -8,6 +8,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { makePaginationQuery } from '../../common/make-pagination-query';
import { Brackets } from 'typeorm';
import { generateBlockedUserQuery } from '../../common/generate-block-query';
+import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query';
export const meta = {
tags: ['notes'],
@@ -67,6 +68,7 @@ export default define(meta, async (ps, user) => {
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
+ generateMutedNoteThreadQuery(query, user);
generateBlockedUserQuery(query, user);
if (ps.visibility) {
diff --git a/src/server/api/endpoints/notes/state.ts b/src/server/api/endpoints/notes/state.ts
index 489902435d..b3913a5e79 100644
--- a/src/server/api/endpoints/notes/state.ts
+++ b/src/server/api/endpoints/notes/state.ts
@@ -1,7 +1,7 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import define from '../../define';
-import { NoteFavorites, NoteWatchings } from '@/models/index';
+import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index';
export const meta = {
tags: ['notes'],
@@ -25,31 +25,45 @@ export const meta = {
isWatching: {
type: 'boolean' as const,
optional: false as const, nullable: false as const
- }
+ },
+ isMutedThread: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
}
}
};
export default define(meta, async (ps, user) => {
- const [favorite, watching] = await Promise.all([
+ const note = await Notes.findOneOrFail(ps.noteId);
+
+ const [favorite, watching, threadMuting] = await Promise.all([
NoteFavorites.count({
where: {
userId: user.id,
- noteId: ps.noteId
+ noteId: note.id,
},
take: 1
}),
NoteWatchings.count({
where: {
userId: user.id,
- noteId: ps.noteId
+ noteId: note.id,
},
take: 1
- })
+ }),
+ NoteThreadMutings.count({
+ where: {
+ userId: user.id,
+ threadId: note.threadId || note.id,
+ },
+ take: 1
+ }),
]);
return {
isFavorited: favorite !== 0,
- isWatching: watching !== 0
+ isWatching: watching !== 0,
+ isMutedThread: threadMuting !== 0,
};
});
diff --git a/src/server/api/endpoints/notes/thread-muting/create.ts b/src/server/api/endpoints/notes/thread-muting/create.ts
new file mode 100644
index 0000000000..2010d54331
--- /dev/null
+++ b/src/server/api/endpoints/notes/thread-muting/create.ts
@@ -0,0 +1,54 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+import { Notes, NoteThreadMutings } from '@/models';
+import { genId } from '@/misc/gen-id';
+import readNote from '@/services/note/read';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const mutedNotes = await Notes.find({
+ where: [{
+ id: note.threadId || note.id,
+ }, {
+ threadId: note.threadId || note.id,
+ }],
+ });
+
+ await readNote(user.id, mutedNotes);
+
+ await NoteThreadMutings.insert({
+ id: genId(),
+ createdAt: new Date(),
+ threadId: note.threadId || note.id,
+ userId: user.id,
+ });
+});
diff --git a/src/server/api/endpoints/notes/thread-muting/delete.ts b/src/server/api/endpoints/notes/thread-muting/delete.ts
new file mode 100644
index 0000000000..05d5691870
--- /dev/null
+++ b/src/server/api/endpoints/notes/thread-muting/delete.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+import { NoteThreadMutings } from '@/models';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'bddd57ac-ceb3-b29d-4334-86ea5fae481a'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ await NoteThreadMutings.delete({
+ threadId: note.threadId || note.id,
+ userId: user.id,
+ });
+});
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 8c996bdba6..69d854ab1a 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -10,13 +10,13 @@ import { resolveUser } from '@/remote/resolve-user';
import config from '@/config/index';
import { updateHashtags } from '../update-hashtag';
import { concat } from '@/prelude/array';
-import insertNoteUnread from './unread';
+import { insertNoteUnread } from '@/services/note/unread';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
import { extractMentions } from '@/misc/extract-mentions';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm';
import { extractHashtags } from '@/misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '@/models/entities/note';
-import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings } from '@/models/index';
+import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index';
import { DriveFile } from '@/models/entities/drive-file';
import { App } from '@/models/entities/app';
import { Not, getConnection, In } from 'typeorm';
@@ -344,8 +344,15 @@ export default async (user: { id: User['id']; username: User['username']; host:
// 通知
if (data.reply.userHost === null) {
- nm.push(data.reply.userId, 'reply');
- publishMainStream(data.reply.userId, 'reply', noteObj);
+ const threadMuted = await NoteThreadMutings.findOne({
+ userId: data.reply.userId,
+ threadId: data.reply.threadId || data.reply.id,
+ });
+
+ if (!threadMuted) {
+ nm.push(data.reply.userId, 'reply');
+ publishMainStream(data.reply.userId, 'reply', noteObj);
+ }
}
}
@@ -459,6 +466,11 @@ async function insertNote(user: { id: User['id']; host: User['host']; }, data: O
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
+ threadId: data.reply
+ ? data.reply.threadId
+ ? data.reply.threadId
+ : data.reply.id
+ : null,
name: data.name,
text: data.text,
hasPoll: data.poll != null,
@@ -581,6 +593,15 @@ async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; },
async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
+ const threadMuted = await NoteThreadMutings.findOne({
+ userId: u.id,
+ threadId: note.threadId || note.id,
+ });
+
+ if (threadMuted) {
+ continue;
+ }
+
const detailPackedNote = await Notes.pack(note, u, {
detail: true
});
diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts
index 4a9df6083c..29d2b54af8 100644
--- a/src/services/note/unread.ts
+++ b/src/services/note/unread.ts
@@ -1,10 +1,10 @@
import { Note } from '@/models/entities/note';
import { publishMainStream } from '@/services/stream';
import { User } from '@/models/entities/user';
-import { Mutings, NoteUnreads } from '@/models/index';
+import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index';
import { genId } from '@/misc/gen-id';
-export default async function(userId: User['id'], note: Note, params: {
+export async function insertNoteUnread(userId: User['id'], note: Note, params: {
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
isSpecified: boolean;
isMentioned: boolean;
@@ -17,6 +17,13 @@ export default async function(userId: User['id'], note: Note, params: {
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
+ // スレッドミュート
+ const threadMute = await NoteThreadMutings.findOne({
+ userId: userId,
+ threadId: note.threadId || note.id,
+ });
+ if (threadMute) return;
+
const unread = {
id: genId(),
noteId: note.id,