Skip to content

Instantly share code, notes, and snippets.

@nakkaa
Last active August 17, 2023 04:45
Show Gist options
  • Save nakkaa/5ecd785edd9fe945fa31c51cb88de44a to your computer and use it in GitHub Desktop.
Save nakkaa/5ecd785edd9fe945fa31c51cb88de44a to your computer and use it in GitHub Desktop.
Misskey v13.14.2にメディアタイムラインを追加する
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,
);
}
}
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: {
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