Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: デッキにクリップのカラムを追加できるように

### Client
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
Expand Down
2 changes: 2 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ selectList: "リストを選択"
editList: "リストを編集"
selectChannel: "チャンネルを選択"
selectAntenna: "アンテナを選択"
selectClip: "クリップを選択"
editAntenna: "アンテナを編集"
createAntenna: "アンテナを作成"
selectWidget: "ウィジェットを選択"
Expand Down Expand Up @@ -2975,6 +2976,7 @@ _deck:
antenna: "アンテナ"
list: "リスト"
channel: "チャンネル"
clip: "クリップ"
mentions: "メンション"
direct: "指名"
roleTimeline: "ロールタイムライン"
Expand Down
45 changes: 45 additions & 0 deletions packages/backend/src/core/ClipService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { MiLocalUser } from '@/models/User.js';

@Injectable()
Expand All @@ -21,6 +22,11 @@ export class ClipService {
public static TooManyClipNotesError = class extends Error {};
public static TooManyClipsError = class extends Error {};

// クリップに同名多数のユーザーがカラム等で購読している場合、更新の度に全員へreload指示が飛びリクエストがスパイクしうるため、
// 短時間の連続更新はまとめて1回(先頭 + 静穏化後の末尾)だけ配信する
private static readonly UPDATED_THROTTLE_MS = 3000;
private readonly pendingUpdated = new Map<MiClip['id'], { timer: NodeJS.Timeout; trailing: boolean }>();

constructor(
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
Expand All @@ -33,9 +39,35 @@ export class ClipService {

private roleService: RoleService,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
}

@bindThis
private publishUpdated(clipId: MiClip['id']): void {
const pending = this.pendingUpdated.get(clipId);
if (pending == null) {
// バースト先頭は即時配信し、静穏化ウィンドウを開始する
this.globalEventService.publishClipStream(clipId, 'updated');
this.pendingUpdated.set(clipId, {
trailing: false,
timer: setTimeout(() => this.flushUpdated(clipId), ClipService.UPDATED_THROTTLE_MS),
});
} else {
// ウィンドウ内の追加更新は末尾でまとめて1回だけ配信する
pending.trailing = true;
}
}

@bindThis
private flushUpdated(clipId: MiClip['id']): void {
const pending = this.pendingUpdated.get(clipId);
this.pendingUpdated.delete(clipId);
if (pending?.trailing) {
this.globalEventService.publishClipStream(clipId, 'updated');
}
}

@bindThis
public async create(me: MiLocalUser, name: string, isPublic: boolean, description: string | null): Promise<MiClip> {
const currentCount = await this.clipsRepository.countBy({
Expand Down Expand Up @@ -72,6 +104,8 @@ export class ClipService {
description: description,
isPublic: isPublic,
});

this.publishUpdated(clip.id);
}

@bindThis
Expand All @@ -85,6 +119,13 @@ export class ClipService {
throw new ClipService.NoSuchClipError();
}

const pending = this.pendingUpdated.get(clip.id);
if (pending != null) {
clearTimeout(pending.timer);
this.pendingUpdated.delete(clip.id);
}
this.globalEventService.publishClipStream(clip.id, 'deleted');

await this.clipsRepository.delete(clip.id);
}

Expand Down Expand Up @@ -129,6 +170,8 @@ export class ClipService {
});

this.notesRepository.increment({ id: noteId }, 'clippedCount', 1);

this.publishUpdated(clip.id);
}

@bindThis
Expand All @@ -154,5 +197,7 @@ export class ClipService {
});

this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1);

this.publishUpdated(clip.id);
}
}
15 changes: 15 additions & 0 deletions packages/backend/src/core/GlobalEventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { MiAntenna } from '@/models/Antenna.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiClip } from '@/models/Clip.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
Expand Down Expand Up @@ -141,6 +142,11 @@ export interface UserListEventTypes {
userRemoved: Packed<'UserLite'>;
}

export interface ClipEventTypes {
updated: undefined;
deleted: undefined;
}

export interface AntennaEventTypes {
note: MiNote;
}
Expand Down Expand Up @@ -291,6 +297,10 @@ export type GlobalEvents = {
name: `userListStream:${MiUserList['id']}`;
payload: EventTypesToEventPayload<UserListEventTypes>;
};
clip: {
name: `clipStream:${MiClip['id']}`;
payload: EventTypesToEventPayload<ClipEventTypes>;
};
roleTimeline: {
name: `roleTimelineStream:${MiRole['id']}`;
payload: EventTypesToEventPayload<RoleTimelineEventTypes>;
Expand Down Expand Up @@ -396,6 +406,11 @@ export class GlobalEventService {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
}

@bindThis
public publishClipStream<K extends keyof ClipEventTypes>(clipId: MiClip['id'], type: K, value?: ClipEventTypes[K]): void {
this.publish(`clipStream:${clipId}`, type, typeof value === 'undefined' ? null : value);
}

@bindThis
public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/ServerModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
import { ChannelChannel } from './api/stream/channels/channel.js';
import { ClipChannel } from './api/stream/channels/clip.js';
import { DriveChannel } from './api/stream/channels/drive.js';
import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js';
import { HashtagChannel } from './api/stream/channels/hashtag.js';
Expand Down Expand Up @@ -85,6 +86,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
AdminChannel,
AntennaChannel,
ChannelChannel,
ClipChannel,
DriveChannel,
GlobalTimelineChannel,
HashtagChannel,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/stream/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timelin
import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js';
import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js';
import { UserListChannel } from '@/server/api/stream/channels/user-list.js';
import { ClipChannel } from '@/server/api/stream/channels/clip.js';
import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js';
import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js';
import { AntennaChannel } from '@/server/api/stream/channels/antenna.js';
Expand Down Expand Up @@ -326,6 +327,7 @@ export default class Connection {
case 'hybridTimeline': return HybridTimelineChannel;
case 'globalTimeline': return GlobalTimelineChannel;
case 'userList': return UserListChannel;
case 'clip': return ClipChannel;
case 'hashtag': return HashtagChannel;
case 'roleTimeline': return RoleTimelineChannel;
case 'antenna': return AntennaChannel;
Expand Down
51 changes: 51 additions & 0 deletions packages/backend/src/server/api/stream/channels/clip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable, Scope } from '@nestjs/common';
import type { ClipsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.TRANSIENT })
export class ClipChannel extends Channel {
public readonly chName = 'clip';
public static shouldShare = false;
public static requireCredential = false as const;
private clipId: string;

constructor(
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,

@Inject(REQUEST)
request: ChannelRequest,
) {
super(request);
}

@bindThis
public async init(params: JsonObject): Promise<boolean> {
if (typeof params.clipId !== 'string') return false;
this.clipId = params.clipId;

const clip = await this.clipsRepository.findOneBy({ id: this.clipId });
if (clip == null) return false;
if (!clip.isPublic && (this.user == null || clip.userId !== this.user.id)) return false;

// Subscribe stream
this.subscriber.on(`clipStream:${this.clipId}`, this.send);

return true;
}

@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`clipStream:${this.clipId}`, this.send);
}
}
1 change: 1 addition & 0 deletions packages/frontend/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/role
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list', { limit: 30 }));
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));
export const favoritedClipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/my-favorites', {}));
4 changes: 3 additions & 1 deletion packages/frontend/src/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const columnTypes = [
'antenna',
'list',
'channel',
'clip',
'mentions',
'direct',
'roleTimeline',
Expand All @@ -62,6 +63,7 @@ export type Column = {
antennaId?: string;
listId?: string;
channelId?: string;
clipId?: string;
roleId?: string;
excludeTypes?: typeof notificationTypes[number][];
tl?: BasicTimelineType;
Expand All @@ -70,7 +72,7 @@ export type Column = {
withSensitive?: boolean;
onlyFiles?: boolean;
soundSetting?: SoundStore;
// The cache for the name of the antenna, channel, list, or role
// The cache for the name of the antenna, channel, list, clip, or role
timelineNameCache?: string;
};

Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/ui/deck.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import XTlColumn from '@/ui/deck/tl-column.vue';
import XAntennaColumn from '@/ui/deck/antenna-column.vue';
import XListColumn from '@/ui/deck/list-column.vue';
import XChannelColumn from '@/ui/deck/channel-column.vue';
import XClipColumn from '@/ui/deck/clip-column.vue';
import XNotificationsColumn from '@/ui/deck/notifications-column.vue';
import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
import XMentionsColumn from '@/ui/deck/mentions-column.vue';
Expand All @@ -132,6 +133,7 @@ const columnComponents = {
tl: XTlColumn,
list: XListColumn,
channel: XChannelColumn,
clip: XClipColumn,
antenna: XAntennaColumn,
mentions: XMentionsColumn,
direct: XDirectColumn,
Expand Down
Loading
Loading