Last active
August 17, 2023 04:45
-
-
Save nakkaa/5ecd785edd9fe945fa31c51cb88de44a to your computer and use it in GitHub Desktop.
Misskey v13.14.2にメディアタイムラインを追加する
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Injectable } from '@nestjs/common'; | |
import { checkWordMute } from '@/misc/check-word-mute.js'; | |
import { isUserRelated } from '@/misc/is-user-related.js'; | |
import type { Packed } from '@/misc/json-schema.js'; | |
import { MetaService } from '@/core/MetaService.js'; | |
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | |
import { bindThis } from '@/decorators.js'; | |
import { RoleService } from '@/core/RoleService.js'; | |
import Channel from '../channel.js'; | |
// packages/backend/src/server/api/stream/channels/media-timeline.ts | |
class MediaTimelineChannel extends Channel { | |
public readonly chName = 'mediaTimeline'; | |
public static shouldShare = true; | |
public static requireCredential = false; | |
constructor( | |
private metaService: MetaService, | |
private roleService: RoleService, | |
private noteEntityService: NoteEntityService, | |
id: string, | |
connection: Channel['connection'], | |
) { | |
super(id, connection); | |
//this.onNote = this.onNote.bind(this); | |
} | |
@bindThis | |
public async init(params: any) { | |
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); | |
if (!policies.ltlAvailable) return; | |
// Subscribe events | |
this.subscriber.on('notesStream', this.onNote); | |
} | |
@bindThis | |
private async onNote(note: Packed<'Note'>) { | |
if (note.user.host !== null) return; | |
if (note.visibility !== 'public') return; | |
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; | |
// リプライなら再pack | |
if (note.replyId != null) { | |
note.reply = await this.noteEntityService.pack(note.replyId, this.user, { | |
detail: true, | |
}); | |
} | |
// Renoteなら再pack | |
if (note.renoteId != null) { | |
note.renote = await this.noteEntityService.pack(note.renoteId, this.user, { | |
detail: true, | |
}); | |
} | |
// 関係ない返信は除外 | |
if (note.reply && this.user && !this.user.showTimelineReplies) { | |
const reply = note.reply; | |
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 | |
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; | |
} | |
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する | |
if (isUserRelated(note, this.userIdsWhoMeMuting)) return; | |
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する | |
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; | |
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; | |
// 流れてきたNoteがミュートすべきNoteだったら無視する | |
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) | |
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 | |
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 | |
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる | |
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; | |
this.connection.cacheNote(note); | |
this.send('note', note); | |
} | |
@bindThis | |
public dispose() { | |
// Unsubscribe events | |
this.subscriber.off('notesStream', this.onNote); | |
} | |
} | |
@Injectable() | |
export class MediaTimelineChannelService { | |
public readonly shouldShare = MediaTimelineChannel.shouldShare; | |
public readonly requireCredential = MediaTimelineChannel.requireCredential; | |
constructor( | |
private metaService: MetaService, | |
private roleService: RoleService, | |
private noteEntityService: NoteEntityService, | |
) { | |
} | |
@bindThis | |
public create(id: string, connection: Channel['connection']): MediaTimelineChannel { | |
return new MediaTimelineChannel( | |
this.metaService, | |
this.roleService, | |
this.noteEntityService, | |
id, | |
connection, | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/locales/en-US.yml b/locales/en-US.yml | |
index 097aba76b..565537155 100644 | |
--- a/locales/en-US.yml | |
+++ b/locales/en-US.yml | |
@@ -1882,6 +1882,7 @@ _instanceCharts: | |
_timelines: | |
home: "Home" | |
local: "Local" | |
+ media: "Media" | |
social: "Social" | |
global: "Global" | |
_play: | |
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml | |
index b2fa9c337..f36af0c64 100644 | |
--- a/locales/ja-JP.yml | |
+++ b/locales/ja-JP.yml | |
@@ -1928,6 +1928,7 @@ _instanceCharts: | |
_timelines: | |
home: "ホーム" | |
local: "ローカル" | |
+ media: "メディア" | |
social: "ソーシャル" | |
global: "グローバル" | |
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts | |
index da86b2c1d..ebe430d10 100644 | |
--- a/packages/backend/src/server/ServerModule.ts | |
+++ b/packages/backend/src/server/ServerModule.ts | |
@@ -30,6 +30,7 @@ import { HashtagChannelService } from './api/stream/channels/hashtag.js'; | |
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; | |
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; | |
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; | |
+import { MediaTimelineChannelService } from './api/stream/channels/media-timeline.js'; | |
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; | |
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; | |
import { UserListChannelService } from './api/stream/channels/user-list.js'; | |
@@ -74,6 +75,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. | |
HomeTimelineChannelService, | |
HybridTimelineChannelService, | |
LocalTimelineChannelService, | |
+ MediaTimelineChannelService, | |
QueueStatsChannelService, | |
ServerStatsChannelService, | |
UserListChannelService, | |
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts | |
index 4e6bc46e6..46641c986 100644 | |
--- a/packages/backend/src/server/api/EndpointsModule.ts | |
+++ b/packages/backend/src/server/api/EndpointsModule.ts | |
@@ -258,6 +258,7 @@ import * as ep___notes_featured from './endpoints/notes/featured.js'; | |
import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; | |
import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; | |
import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; | |
+import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js'; | |
import * as ep___notes_mentions from './endpoints/notes/mentions.js'; | |
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; | |
import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; | |
@@ -603,6 +604,7 @@ const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep__ | |
const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; | |
const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; | |
const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; | |
+const $notes_mediaTimeline: Provider = { provide: 'ep:notes/media-timeline', useClass: ep___notes_mediaTimeline.default }; | |
const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; | |
const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; | |
const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; | |
@@ -952,6 +954,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |
$notes_globalTimeline, | |
$notes_hybridTimeline, | |
$notes_localTimeline, | |
+ $notes_mediaTimeline, | |
$notes_mentions, | |
$notes_polls_recommendation, | |
$notes_polls_vote, | |
@@ -1295,6 +1298,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |
$notes_globalTimeline, | |
$notes_hybridTimeline, | |
$notes_localTimeline, | |
+ $notes_mediaTimeline, | |
$notes_mentions, | |
$notes_polls_recommendation, | |
$notes_polls_vote, | |
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts | |
index 41c3a29ee..d67ceb923 100644 | |
--- a/packages/backend/src/server/api/endpoints.ts | |
+++ b/packages/backend/src/server/api/endpoints.ts | |
@@ -258,6 +258,7 @@ import * as ep___notes_featured from './endpoints/notes/featured.js'; | |
import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; | |
import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; | |
import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; | |
+import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js'; | |
import * as ep___notes_mentions from './endpoints/notes/mentions.js'; | |
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; | |
import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; | |
@@ -601,6 +602,7 @@ const eps = [ | |
['notes/global-timeline', ep___notes_globalTimeline], | |
['notes/hybrid-timeline', ep___notes_hybridTimeline], | |
['notes/local-timeline', ep___notes_localTimeline], | |
+ ['notes/media-timeline', ep___notes_mediaTimeline], | |
['notes/mentions', ep___notes_mentions], | |
['notes/polls/recommendation', ep___notes_polls_recommendation], | |
['notes/polls/vote', ep___notes_polls_vote], | |
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts | |
index 4a544fadf..a6e90cf28 100644 | |
--- a/packages/backend/src/server/api/stream/ChannelsService.ts | |
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts | |
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; | |
import { bindThis } from '@/decorators.js'; | |
import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; | |
import { LocalTimelineChannelService } from './channels/local-timeline.js'; | |
+import { MediaTimelineChannelService } from './channels/media-timeline.js'; | |
import { HomeTimelineChannelService } from './channels/home-timeline.js'; | |
import { GlobalTimelineChannelService } from './channels/global-timeline.js'; | |
import { MainChannelService } from './channels/main.js'; | |
@@ -21,6 +22,7 @@ export class ChannelsService { | |
private mainChannelService: MainChannelService, | |
private homeTimelineChannelService: HomeTimelineChannelService, | |
private localTimelineChannelService: LocalTimelineChannelService, | |
+ private mediaTimelineChannelService: MediaTimelineChannelService, | |
private hybridTimelineChannelService: HybridTimelineChannelService, | |
private globalTimelineChannelService: GlobalTimelineChannelService, | |
private userListChannelService: UserListChannelService, | |
@@ -41,6 +43,7 @@ export class ChannelsService { | |
case 'main': return this.mainChannelService; | |
case 'homeTimeline': return this.homeTimelineChannelService; | |
case 'localTimeline': return this.localTimelineChannelService; | |
+ case 'mediaTimeline': return this.mediaTimelineChannelService; | |
case 'hybridTimeline': return this.hybridTimelineChannelService; | |
case 'globalTimeline': return this.globalTimelineChannelService; | |
case 'userList': return this.userListChannelService; | |
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue | |
index 062d0bd87..ccb88a3a4 100644 | |
--- a/packages/frontend/src/components/MkTimeline.vue | |
+++ b/packages/frontend/src/components/MkTimeline.vue | |
@@ -38,6 +38,18 @@ const prepend = note => { | |
} | |
}; | |
+const prependFilterdMedia = note => { | |
+ if (note.files !== null && note.files.length > 0) { | |
+ tlComponent.pagingComponent?.prepend(note); | |
+ } | |
+ | |
+ emit('note'); | |
+ | |
+ if (props.sound) { | |
+ sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); | |
+ } | |
+}; | |
+ | |
let endpoint; | |
let query; | |
let connection; | |
@@ -74,6 +86,15 @@ if (props.src === 'antenna') { | |
withReplies: defaultStore.state.showTimelineReplies, | |
}); | |
connection.on('note', prepend); | |
+} else if (props.src === 'media') { | |
+ endpoint = 'notes/media-timeline'; | |
+ query = { | |
+ withReplies: defaultStore.state.showTimelineReplies, | |
+ }; | |
+ connection = stream.useChannel('mediaTimeline', { | |
+ withReplies: defaultStore.state.showTimelineReplies, | |
+ }); | |
+ connection.on('note', prependFilterdMedia); | |
} else if (props.src === 'social') { | |
endpoint = 'notes/hybrid-timeline'; | |
query = { | |
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue | |
index a441c6f72..d08544329 100644 | |
--- a/packages/frontend/src/pages/timeline.vue | |
+++ b/packages/frontend/src/pages/timeline.vue | |
@@ -128,6 +128,11 @@ const headerTabs = $computed(() => [{ | |
title: i18n.ts._timelines.local, | |
icon: 'ti ti-planet', | |
iconOnly: true, | |
+}, { | |
+ key: 'media', | |
+ title: i18n.ts._timelines.media, | |
+ icon: 'ti ti-photo', | |
+ iconOnly: true, | |
}, { | |
key: 'social', | |
title: i18n.ts._timelines.social, | |
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts | |
index 460120785..0e5534b10 100644 | |
--- a/packages/frontend/src/ui/deck/deck-store.ts | |
+++ b/packages/frontend/src/ui/deck/deck-store.ts | |
@@ -24,7 +24,7 @@ export type Column = { | |
channelId?: string; | |
roleId?: string; | |
includingTypes?: typeof notificationTypes[number][]; | |
- tl?: 'home' | 'local' | 'social' | 'global'; | |
+ tl?: 'home' | 'local' | 'media' | 'social' | 'global'; | |
}; | |
export const deckStore = markRaw(new Storage('deck', { | |
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue | |
index 4844ad11f..5e16956e3 100644 | |
--- a/packages/frontend/src/ui/deck/tl-column.vue | |
+++ b/packages/frontend/src/ui/deck/tl-column.vue | |
@@ -4,6 +4,7 @@ | |
<i v-if="column.tl === 'home'" class="ti ti-home"></i> | |
<i v-else-if="column.tl === 'local'" class="ti ti-planet"></i> | |
<i v-else-if="column.tl === 'social'" class="ti ti-rocket"></i> | |
+ <i v-else-if="column.tl === 'media'" class="ti ti-photo"></i> | |
<i v-else-if="column.tl === 'global'" class="ti ti-whirl"></i> | |
<span style="margin-left: 8px;">{{ column.name }}</span> | |
</template> | |
@@ -56,6 +57,8 @@ async function setType() { | |
value: 'home' as const, text: i18n.ts._timelines.home, | |
}, { | |
value: 'local' as const, text: i18n.ts._timelines.local, | |
+ }, { | |
+ value: 'media' as const, text: i18n.ts._timelines.media, | |
}, { | |
value: 'social' as const, text: i18n.ts._timelines.social, | |
}, { | |
diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue | |
index 3d497c2e2..dea0a816a 100644 | |
--- a/packages/frontend/src/widgets/WidgetTimeline.vue | |
+++ b/packages/frontend/src/widgets/WidgetTimeline.vue | |
@@ -117,6 +117,10 @@ const choose = async (ev) => { | |
text: i18n.ts._timelines.local, | |
icon: 'ti ti-planet', | |
action: () => { setSrc('local'); }, | |
+ }, { | |
+ text: i18n.ts._timelines.media, | |
+ icon: 'ti ti-photo', | |
+ action: () => { setSrc('media'); }, | |
}, { | |
text: i18n.ts._timelines.social, | |
icon: 'ti ti-rocket', | |
diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts | |
index 96ac7787e..b7d928229 100644 | |
--- a/packages/misskey-js/src/streaming.types.ts | |
+++ b/packages/misskey-js/src/streaming.types.ts | |
@@ -56,6 +56,13 @@ export type Channels = { | |
}; | |
receives: null; | |
}; | |
+ mediaTimeline: { | |
+ params: null; | |
+ events: { | |
+ note: (payload: Note) => void; | |
+ }; | |
+ receives: null; | |
+ }; | |
hybridTimeline: { | |
params: null; | |
events: { |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Brackets } from 'typeorm'; | |
import { Inject, Injectable } from '@nestjs/common'; | |
import type { NotesRepository } from '@/models/index.js'; | |
import { Endpoint } from '@/server/api/endpoint-base.js'; | |
import { QueryService } from '@/core/QueryService.js'; | |
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | |
import { MetaService } from '@/core/MetaService.js'; | |
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; | |
import { DI } from '@/di-symbols.js'; | |
import { RoleService } from '@/core/RoleService.js'; | |
import { IdService } from '@/core/IdService.js'; | |
import { ApiError } from '../../error.js'; | |
// packages/backend/src/server/api/endpoints/notes/media-timeline.ts | |
export const meta = { | |
tags: ['notes'], | |
res: { | |
type: 'array', | |
optional: false, nullable: false, | |
items: { | |
type: 'object', | |
optional: false, nullable: false, | |
ref: 'Note', | |
}, | |
}, | |
errors: { | |
ltlDisabled: { | |
message: 'Media timeline has been disabled.', | |
code: 'MTL_DISABLED', | |
id: '45a6eb02-7695-4393-b023-dd4be9aaaefd', | |
}, | |
}, | |
} as const; | |
export const paramDef = { | |
type: 'object', | |
properties: { | |
withFiles: { | |
type: 'boolean', | |
default: false, | |
description: 'Only show notes that have attached files.', | |
}, | |
fileType: { type: 'array', items: { | |
type: 'string', | |
} }, | |
excludeNsfw: { type: 'boolean', default: false }, | |
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | |
sinceId: { type: 'string', format: 'misskey:id' }, | |
untilId: { type: 'string', format: 'misskey:id' }, | |
sinceDate: { type: 'integer' }, | |
untilDate: { type: 'integer' }, | |
}, | |
required: [], | |
} as const; | |
// eslint-disable-next-line import/no-default-export | |
@Injectable() | |
export default class extends Endpoint<typeof meta, typeof paramDef> { | |
constructor( | |
@Inject(DI.notesRepository) | |
private notesRepository: NotesRepository, | |
private noteEntityService: NoteEntityService, | |
private queryService: QueryService, | |
private metaService: MetaService, | |
private roleService: RoleService, | |
private activeUsersChart: ActiveUsersChart, | |
private idService: IdService, | |
) { | |
super(meta, paramDef, async (ps, me) => { | |
const policies = await this.roleService.getUserPolicies(me ? me.id : null); | |
if (!policies.ltlAvailable) { | |
throw new ApiError(meta.errors.ltlDisabled); | |
} | |
//#region Construct query | |
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), | |
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) | |
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで | |
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') | |
.andWhere('note.fileIds != \'{}\'') | |
.innerJoinAndSelect('note.user', 'user') | |
.leftJoinAndSelect('note.reply', 'reply') | |
.leftJoinAndSelect('note.renote', 'renote') | |
.leftJoinAndSelect('reply.user', 'replyUser') | |
.leftJoinAndSelect('renote.user', 'renoteUser'); | |
this.queryService.generateChannelQuery(query, me); | |
this.queryService.generateRepliesQuery(query, me); | |
this.queryService.generateVisibilityQuery(query, me); | |
if (me) this.queryService.generateMutedUserQuery(query, me); | |
if (me) this.queryService.generateMutedNoteQuery(query, me); | |
if (me) this.queryService.generateBlockedUserQuery(query, me); | |
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); | |
if (ps.withFiles) { | |
query.andWhere('note.fileIds != \'{}\''); | |
} | |
if (ps.fileType != null) { | |
query.andWhere('note.fileIds != \'{}\''); | |
query.andWhere(new Brackets(qb => { | |
for (const type of ps.fileType!) { | |
const i = ps.fileType!.indexOf(type); | |
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); | |
} | |
})); | |
if (ps.excludeNsfw) { | |
query.andWhere('note.cw IS NULL'); | |
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); | |
} | |
} | |
//#endregion | |
const timeline = await query.take(ps.limit).getMany(); | |
process.nextTick(() => { | |
if (me) { | |
this.activeUsersChart.read(me); | |
} | |
}); | |
return await this.noteEntityService.packMany(timeline, me); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment