From 5d56799070006923701dcdaaa61d69c00e034209 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Wed, 12 Apr 2023 11:40:08 +0900
Subject: [PATCH] feat: role timeline

Resolve #10581
---
 CHANGELOG.md                                  |   4 +-
 locales/ja-JP.yml                             |   1 +
 .../backend/src/core/GlobalEventService.ts    |   7 ++
 .../backend/src/core/NoteCreateService.ts     |   2 +
 packages/backend/src/core/RoleService.ts      |  23 ++++
 packages/backend/src/server/ServerModule.ts   |   2 +
 .../backend/src/server/api/EndpointsModule.ts |   4 +
 packages/backend/src/server/api/endpoints.ts  |   2 +
 .../server/api/endpoints/antennas/notes.ts    |   3 +-
 .../server/api/endpoints/i/notifications.ts   |   3 +-
 .../src/server/api/endpoints/roles/notes.ts   | 109 ++++++++++++++++++
 .../src/server/api/stream/ChannelsService.ts  |   3 +
 .../api/stream/channels/role-timeline.ts      |  75 ++++++++++++
 .../backend/src/server/api/stream/types.ts    |   8 ++
 .../frontend/src/components/MkTimeline.vue    |  10 ++
 packages/frontend/src/pages/explore.vue       |   2 +-
 packages/frontend/src/pages/role.vue          |  27 ++++-
 packages/frontend/src/ui/deck.vue             |   1 +
 packages/frontend/src/ui/deck/column-core.vue |   2 +
 packages/frontend/src/ui/deck/deck-store.ts   |   1 +
 .../src/ui/deck/role-timeline-column.vue      |  67 +++++++++++
 21 files changed, 348 insertions(+), 8 deletions(-)
 create mode 100644 packages/backend/src/server/api/endpoints/roles/notes.ts
 create mode 100644 packages/backend/src/server/api/stream/channels/role-timeline.ts
 create mode 100644 packages/frontend/src/ui/deck/role-timeline-column.vue

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1cd253a1c..bd195790fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,9 @@
 ## 13.x.x (unreleased)
 
 ### General
-- カスタム絵文字関連の変更
+- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加
+	- Deckのカラムとしても追加可能
+- カスタム絵文字関連の改善
   * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように
   * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように
 
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 4c5bb60e0c..092a4aed32 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1943,6 +1943,7 @@ _deck:
     channel: "チャンネル"
     mentions: "あなた宛て"
     direct: "ダイレクト"
+    roleTimeline: "ロールタイムライン"
 
 _dialog:
   charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 9f4de5f985..2c2687a90c 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -14,11 +14,13 @@ import type {
 	MainStreamTypes,
 	NoteStreamTypes,
 	UserListStreamTypes,
+	RoleTimelineStreamTypes,
 } from '@/server/api/stream/types.js';
 import type { Packed } from '@/misc/json-schema.js';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
 import { bindThis } from '@/decorators.js';
+import { Role } from '@/models';
 
 @Injectable()
 export class GlobalEventService {
@@ -81,6 +83,11 @@ export class GlobalEventService {
 		this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	@bindThis
+	public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
+		this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	@bindThis
 	public publishNotesStream(note: Packed<'Note'>): void {
 		this.publish('notesStream', null, note);
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 32e4fe7f8a..79629cb2a8 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -547,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
 
 			this.globalEventService.publishNotesStream(noteObj);
 
+			this.roleService.addNoteToRoleTimeline(noteObj);
+
 			this.webhookService.getActiveWebhooks().then(webhooks => {
 				webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
 				for (const webhook of webhooks) {
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 77645e3f06..2a4271aa98 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { StreamMessages } from '@/server/api/stream/types.js';
 import { IdService } from '@/core/IdService.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
+import type { Packed } from '@/misc/json-schema';
 import type { OnApplicationShutdown } from '@nestjs/common';
 
 export type RolePolicies = {
@@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
 	public static NotAssignedError = class extends Error {};
 
 	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
 		@Inject(DI.redisForSub)
 		private redisForSub: Redis.Redis,
 
@@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown {
 		this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
 	}
 
+	@bindThis
+	public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
+		const roles = await this.getUserRoles(note.userId);
+
+		const redisPipeline = this.redisClient.pipeline();
+
+		for (const role of roles) {
+			redisPipeline.xadd(
+				`roleTimeline:${role.id}`,
+				'MAXLEN', '~', '1000',
+				'*',
+				'note', note.id);
+
+			this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
+		}
+
+		redisPipeline.exec();
+	}
+
 	@bindThis
 	public onApplicationShutdown(signal?: string | undefined) {
 		this.redisForSub.off('message', this.onMessage);
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 6bae0bafda..c41e805504 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -34,6 +34,7 @@ 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';
 import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
 
 @Module({
 	imports: [
@@ -67,6 +68,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
 		DriveChannelService,
 		GlobalTimelineChannelService,
 		HashtagChannelService,
+		RoleTimelineChannelService,
 		HomeTimelineChannelService,
 		HybridTimelineChannelService,
 		LocalTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index ca89d82853..689f90287e 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
 import * as ep___roles_list from './endpoints/roles/list.js';
 import * as ep___roles_show from './endpoints/roles/show.js';
 import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r
 const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
 const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
 const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
+const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default };
 const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
 const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
 const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$roles_list,
 		$roles_show,
 		$roles_users,
+		$roles_notes,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
@@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$roles_list,
 		$roles_show,
 		$roles_users,
+		$roles_notes,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index dab897117d..d0fe6a57c1 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
 import * as ep___roles_list from './endpoints/roles/list.js';
 import * as ep___roles_show from './endpoints/roles/show.js';
 import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -626,6 +627,7 @@ const eps = [
 	['roles/list', ep___roles_list],
 	['roles/show', ep___roles_show],
 	['roles/users', ep___roles_users],
+	['roles/notes', ep___roles_notes],
 	['request-reset-password', ep___requestResetPassword],
 	['reset-db', ep___resetDb],
 	['reset-password', ep___resetPassword],
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index f08c20ae48..df83fe5f2a 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -76,11 +76,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				throw new ApiError(meta.errors.noSuchAntenna);
 			}
 
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
 			const noteIdsRes = await this.redisClient.xrevrange(
 				`antennaTimeline:${antenna.id}`,
 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
 				'-',
-				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+				'COUNT', limit);
 
 			if (noteIdsRes.length === 0) {
 				return [];
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index f27b4e86d4..ba0487f223 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 			const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
 			const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
 
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
 			const notificationsRes = await this.redisClient.xrevrange(
 				`notificationTimeline:${me.id}`,
 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
 				'-',
-				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+				'COUNT', limit);
 
 			if (notificationsRes.length === 0) {
 				return [];
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
new file mode 100644
index 0000000000..d79528593f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -0,0 +1,109 @@
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { NotesRepository, RolesRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['role', 'notes'],
+
+	requireCredential: true,
+
+	errors: {
+		noSuchRole: {
+			message: 'No such role.',
+			code: 'NO_SUCH_ROLE',
+			id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e',
+		},
+	},
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Note',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roleId: { type: 'string', format: 'misskey:id' },
+		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: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		private idService: IdService,
+		private noteEntityService: NoteEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const role = await this.rolesRepository.findOneBy({
+				id: ps.roleId,
+			});
+
+			if (role == null) {
+				throw new ApiError(meta.errors.noSuchRole);
+			}
+
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
+			const noteIdsRes = await this.redisClient.xrevrange(
+				`roleTimeline:${role.id}`,
+				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
+				'-',
+				'COUNT', limit);
+
+			if (noteIdsRes.length === 0) {
+				return [];
+			}
+
+			const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
+
+			if (noteIds.length === 0) {
+				return [];
+			}
+
+			const query = this.notesRepository.createQueryBuilder('note')
+				.where('note.id IN (:...noteIds)', { noteIds: noteIds })
+				.innerJoinAndSelect('note.user', 'user')
+				.leftJoinAndSelect('note.reply', 'reply')
+				.leftJoinAndSelect('note.renote', 'renote')
+				.leftJoinAndSelect('reply.user', 'replyUser')
+				.leftJoinAndSelect('renote.user', 'renoteUser');
+
+			this.queryService.generateVisibilityQuery(query, me);
+			this.queryService.generateMutedUserQuery(query, me);
+			this.queryService.generateBlockedUserQuery(query, me);
+
+			const notes = await query.getMany();
+			notes.sort((a, b) => a.id > b.id ? -1 : 1);
+
+			return await this.noteEntityService.packMany(notes, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index f9ef8218c1..c77ba66028 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
 import { AntennaChannelService } from './channels/antenna.js';
 import { DriveChannelService } from './channels/drive.js';
 import { HashtagChannelService } from './channels/hashtag.js';
+import { RoleTimelineChannelService } from './channels/role-timeline.js';
 
 @Injectable()
 export class ChannelsService {
@@ -24,6 +25,7 @@ export class ChannelsService {
 		private globalTimelineChannelService: GlobalTimelineChannelService,
 		private userListChannelService: UserListChannelService,
 		private hashtagChannelService: HashtagChannelService,
+		private roleTimelineChannelService: RoleTimelineChannelService,
 		private antennaChannelService: AntennaChannelService,
 		private channelChannelService: ChannelChannelService,
 		private driveChannelService: DriveChannelService,
@@ -43,6 +45,7 @@ export class ChannelsService {
 			case 'globalTimeline': return this.globalTimelineChannelService;
 			case 'userList': return this.userListChannelService;
 			case 'hashtag': return this.hashtagChannelService;
+			case 'roleTimeline': return this.roleTimelineChannelService;
 			case 'antenna': return this.antennaChannelService;
 			case 'channel': return this.channelChannelService;
 			case 'drive': return this.driveChannelService;
diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts
new file mode 100644
index 0000000000..9d106c8b2f
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts
@@ -0,0 +1,75 @@
+import { Injectable } from '@nestjs/common';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { bindThis } from '@/decorators.js';
+import Channel from '../channel.js';
+import { StreamMessages } from '../types.js';
+
+class RoleTimelineChannel extends Channel {
+	public readonly chName = 'roleTimeline';
+	public static shouldShare = false;
+	public static requireCredential = false;
+	private roleId: string;
+
+	constructor(
+		private noteEntityService: NoteEntityService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+		//this.onNote = this.onNote.bind(this);
+	}
+
+	@bindThis
+	public async init(params: any) {
+		this.roleId = params.roleId as string;
+
+		this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent);
+	}
+
+	@bindThis
+	private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
+		if (data.type === 'note') {
+			const note = data.body;
+
+			// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+			if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
+			// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
+			if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
+
+			if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
+
+			this.send('note', note);
+		} else {
+			this.send(data.type, data.body);
+		}
+	}
+
+	@bindThis
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
+	}
+}
+
+@Injectable()
+export class RoleTimelineChannelService {
+	public readonly shouldShare = RoleTimelineChannel.shouldShare;
+	public readonly requireCredential = RoleTimelineChannel.requireCredential;
+
+	constructor(
+		private noteEntityService: NoteEntityService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
+		return new RoleTimelineChannel(
+			this.noteEntityService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts
index ed73897e73..101f6bf261 100644
--- a/packages/backend/src/server/api/stream/types.ts
+++ b/packages/backend/src/server/api/stream/types.ts
@@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
 	note: Note;
 }
 
+export interface RoleTimelineStreamTypes {
+	note: Packed<'Note'>;
+}
+
 export interface AdminStreamTypes {
 	newAbuseUserReport: {
 		id: AbuseUserReport['id'];
@@ -209,6 +213,10 @@ export type StreamMessages = {
 		name: `userListStream:${UserList['id']}`;
 		payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
 	};
+	roleTimeline: {
+		name: `roleTimelineStream:${Role['id']}`;
+		payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
+	};
 	antenna: {
 		name: `antennaStream:${Antenna['id']}`;
 		payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 6741e7a18b..fb0a3a4b67 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -15,6 +15,7 @@ const props = defineProps<{
 	list?: string;
 	antenna?: string;
 	channel?: string;
+	role?: string;
 	sound?: boolean;
 }>();
 
@@ -121,6 +122,15 @@ if (props.src === 'antenna') {
 		channelId: props.channel,
 	});
 	connection.on('note', prepend);
+} else if (props.src === 'role') {
+	endpoint = 'roles/notes';
+	query = {
+		roleId: props.role,
+	};
+	connection = stream.useChannel('roleTimeline', {
+		roleId: props.role,
+	});
+	connection.on('note', prepend);
 }
 
 const pagination = {
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index 2131188dde..5f3728b677 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -1,7 +1,7 @@
 <template>
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<div class="lznhrdub">
+	<div>
 		<div v-if="tab === 'featured'">
 			<XFeatured/>
 		</div>
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index 2e9d3d6169..f2645394a2 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -1,13 +1,16 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
 
-	<MkSpacer :content-max="1200">
+	<MkSpacer v-if="tab === 'users'" :content-max="1200">
 		<div class="_gaps_s">
 			<div v-if="role">{{ role.description }}</div>
 			<MkUserList :pagination="users" :extractor="(item) => item.user"/>
 		</div>
 	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'timeline'" :content-max="700">
+		<MkTimeline ref="timeline" src="role" :role="props.role"/>
+	</MkSpacer>
 </MkStickyContainer>
 </template>
 
@@ -16,11 +19,17 @@ import { computed, watch } from 'vue';
 import * as os from '@/os';
 import MkUserList from '@/components/MkUserList.vue';
 import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import MkTimeline from '@/components/MkTimeline.vue';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
 	role: string;
-}>();
+	initialTab?: string;
+}>(), {
+	initialTab: 'users',
+});
 
+let tab = $ref(props.initialTab);
 let role = $ref();
 
 watch(() => props.role, () => {
@@ -39,6 +48,16 @@ const users = $computed(() => ({
 	},
 }));
 
+const headerTabs = $computed(() => [{
+	key: 'users',
+	icon: 'ti ti-users',
+	title: i18n.ts.users,
+}, {
+	key: 'timeline',
+	icon: 'ti ti-pencil',
+	title: i18n.ts.timeline,
+}]);
+
 definePageMetadata(computed(() => ({
 	title: role?.name,
 	icon: 'ti ti-badge',
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 4db7c9413a..33e752513b 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -152,6 +152,7 @@ const addColumn = async (ev) => {
 		'channel',
 		'mentions',
 		'direct',
+		'roleTimeline',
 	];
 
 	const { canceled, result: column } = await os.select({
diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue
index 083e91bb03..8e7addf359 100644
--- a/packages/frontend/src/ui/deck/column-core.vue
+++ b/packages/frontend/src/ui/deck/column-core.vue
@@ -10,6 +10,7 @@
 <XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 <XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 <XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 </template>
 
 <script lang="ts" setup>
@@ -23,6 +24,7 @@ import XNotificationsColumn from './notifications-column.vue';
 import XWidgetsColumn from './widgets-column.vue';
 import XMentionsColumn from './mentions-column.vue';
 import XDirectColumn from './direct-column.vue';
+import XRoleTimelineColumn from './role-timeline-column.vue';
 import { Column } from './deck-store';
 
 defineProps<{
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 1420ad8b30..a6784e9849 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -22,6 +22,7 @@ export type Column = {
 	antennaId?: string;
 	listId?: string;
 	channelId?: string;
+	roleId?: string;
 	includingTypes?: typeof notificationTypes[number][];
 	tl?: 'home' | 'local' | 'social' | 'global';
 };
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
new file mode 100644
index 0000000000..5783b3f071
--- /dev/null
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -0,0 +1,67 @@
+<template>
+<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XColumn from './column.vue';
+import { updateColumn, Column } from './deck-store';
+import MkTimeline from '@/components/MkTimeline.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'loaded'): void;
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
+
+onMounted(() => {
+	if (props.column.roleId == null) {
+		setRole();
+	}
+});
+
+async function setRole() {
+	const roles = await os.api('roles/list');
+	const { canceled, result: role } = await os.select({
+		title: i18n.ts.role,
+		items: roles.map(x => ({
+			value: x, text: x.name,
+		})),
+		default: props.column.roleId,
+	});
+	if (canceled) return;
+	updateColumn(props.column.id, {
+		roleId: role.id,
+	});
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.role,
+	action: setRole,
+}];
+
+/*
+function focus() {
+	timeline.focus();
+}
+
+defineExpose({
+	focus,
+});
+*/
+</script>