diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-10-31 15:30:22 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-10-31 15:30:22 +0900 |
| commit | fc65190ef7b687650018cccfee2219bf00827f70 (patch) | |
| tree | d2482c79dd095509b8b57dac28db460fb812196a /src | |
| parent | fix: Fix #7895 (#7937) (diff) | |
| download | sharkey-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.vue | 15 | ||||
| -rw-r--r-- | src/client/components/note.vue | 15 | ||||
| -rw-r--r-- | src/db/postgre.ts | 2 | ||||
| -rw-r--r-- | src/models/entities/note-thread-muting.ts | 33 | ||||
| -rw-r--r-- | src/models/entities/note.ts | 6 | ||||
| -rw-r--r-- | src/models/index.ts | 2 | ||||
| -rw-r--r-- | src/server/api/common/generate-muted-note-thread-query.ts | 17 | ||||
| -rw-r--r-- | src/server/api/endpoints/notes/mentions.ts | 2 | ||||
| -rw-r--r-- | src/server/api/endpoints/notes/state.ts | 28 | ||||
| -rw-r--r-- | src/server/api/endpoints/notes/thread-muting/create.ts | 54 | ||||
| -rw-r--r-- | src/server/api/endpoints/notes/thread-muting/delete.ts | 40 | ||||
| -rw-r--r-- | src/services/note/create.ts | 29 | ||||
| -rw-r--r-- | src/services/note/unread.ts | 11 |
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, |