This commit is contained in:
syuilo
2025-03-18 10:27:30 +09:00
parent 86f2ababd1
commit 6b5cf2e229
31 changed files with 1136 additions and 159 deletions

17
locales/index.d.ts vendored
View File

@@ -5354,6 +5354,23 @@ export interface Locale extends ILocale {
* チャット
*/
"chat": string;
/**
* 個人チャット
*/
"individualChat": string;
/**
* 特定ユーザーとの一対一のチャットができます。
*/
"individualChat_description": string;
/**
* ルームチャット
*/
"roomChat": string;
/**
* 複数人でのチャットができます。
* また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。
*/
"roomChat_description": string;
"_emojiPalette": {
/**
* パレット

View File

@@ -1334,6 +1334,10 @@ emojiPalette: "絵文字パレット"
postForm: "投稿フォーム"
textCount: "文字数"
chat: "チャット"
individualChat: "個人チャット"
individualChat_description: "特定ユーザーとの一対一のチャットができます。"
roomChat: "ルームチャット"
roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。"
_emojiPalette:
palettes: "パレット"

View File

@@ -186,8 +186,8 @@ export class ChatService {
const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser));
this.queueService.deliver(fromUser, activity, toUser.inbox);
}
}/* else if (message.roomId) {
this.globalEventService.publishRoomChatStream(message.roomId, 'deleted', message.id);
}/* else if (message.toRoomId) {
this.globalEventService.publishRoomChatStream(message.toRoomId, 'deleted', message.id);
}*/
}
@@ -245,7 +245,7 @@ export class ChatService {
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await this.chatMessagesRepository.createQueryBuilder('message')
.where('message.roomId = :roomId', { roomId: roomId })
.where('message.toRoomId = :roomId', { roomId: roomId })
.andWhere('message.userId != :userId', { userId: userId })
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
@@ -300,7 +300,7 @@ export class ChatService {
.where('message.fromUserId = :meId', { meId: meId })
.orWhere('message.toUserId = :meId', { meId: meId });
}))
.andWhere('message.roomId IS NULL')
.andWhere('message.toRoomId IS NULL')
.andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
@@ -325,6 +325,7 @@ export class ChatService {
@bindThis
public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
return [];
/*
const rooms = await this.userRoomJoiningsRepository.findBy({
userId: meId,
@@ -341,10 +342,10 @@ export class ChatService {
const query = this.chatMessagesRepository.createQueryBuilder('message')
.orderBy('message.id', 'DESC')
.where('message.roomId IN (:...rooms)', { rooms: rooms });
.where('message.toRoomId IN (:...rooms)', { rooms: rooms });
if (found.length > 0) {
query.andWhere('message.roomId NOT IN (:...found)', { found: found });
query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
}
const message = await query.getOne();

View File

@@ -84,6 +84,8 @@ export const DI = {
flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'),
chatMessagesRepository: Symbol('chatMessagesRepository'),
chatRoomsRepository: Symbol('chatRoomsRepository'),
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
//#endregion

View File

@@ -64,6 +64,7 @@ import {
import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js';
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -123,6 +124,7 @@ export const refs = {
AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
ChatMessage: packedChatMessageSchema,
ChatMessageLite: packedChatMessageLiteSchema,
ChatRoom: packedChatRoomSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;

View File

@@ -9,6 +9,7 @@ import { MiUser } from './User.js';
import { MiChatRoom } from './ChatRoom.js';
@Entity('chat_room_membership')
@Index(['userId', 'roomId'], { unique: true })
export class MiChatRoomMembership {
@PrimaryColumn(id())
public id: string;

View File

@@ -78,6 +78,9 @@ import {
MiUserPublickey,
MiUserSecurityKey,
MiWebhook,
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -490,6 +493,24 @@ const $userMemosRepository: Provider = {
inject: [DI.db],
};
const $chatMessagesRepository: Provider = {
provide: DI.chatMessagesRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository<MiChatMessage>),
inject: [DI.db],
};
const $chatRoomsRepository: Provider = {
provide: DI.chatRoomsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository<MiChatRoom>),
inject: [DI.db],
};
const $chatRoomMembershipsRepository: Provider = {
provide: DI.chatRoomMembershipsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository<MiChatRoomMembership>),
inject: [DI.db],
};
const $bubbleGameRecordsRepository: Provider = {
provide: DI.bubbleGameRecordsRepository,
useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository<MiBubbleGameRecord>),
@@ -573,6 +594,9 @@ const $reversiGamesRepository: Provider = {
$flashsRepository,
$flashLikesRepository,
$userMemosRepository,
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],
@@ -645,6 +669,9 @@ const $reversiGamesRepository: Provider = {
$flashsRepository,
$flashLikesRepository,
$userMemosRepository,
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],

View File

@@ -21,7 +21,7 @@ export const packedChatMessageSchema = {
},
fromUser: {
type: 'object',
optional: true, nullable: false,
optional: false, nullable: false,
ref: 'UserLite',
},
toUserId: {
@@ -33,6 +33,15 @@ export const packedChatMessageSchema = {
optional: true, nullable: true,
ref: 'UserLite',
},
toRoomId: {
type: 'string',
optional: true, nullable: true,
},
toRoom: {
type: 'object',
optional: true, nullable: true,
ref: 'ChatRoom',
},
text: {
type: 'string',
optional: true, nullable: true,
@@ -73,6 +82,10 @@ export const packedChatMessageLiteSchema = {
type: 'string',
optional: true, nullable: true,
},
toRoomId: {
type: 'string',
optional: true, nullable: true,
},
text: {
type: 'string',
optional: true, nullable: true,

View File

@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedChatRoomSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
createdAt: {
type: 'string',
format: 'date-time',
optional: false, nullable: false,
},
ownerId: {
type: 'string',
optional: false, nullable: false,
},
owner: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
name: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;

View File

@@ -44,6 +44,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 { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ChatChannelService } from './api/stream/channels/chat.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -84,6 +85,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
ChatChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,

View File

@@ -399,5 +399,5 @@ export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
export * as 'chat/messages/create' from './endpoints/chat/messages/create.js';
export * as 'chat/messages/timeline' from './endpoints/chat/messages/timeline.js';
export * as 'chat/messages/history' from './endpoints/chat/messages/history.js';
export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';

View File

@@ -82,7 +82,7 @@ export const paramDef = {
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
fileId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id', nullable: true },
toUserId: { type: 'string', format: 'misskey:id', nullable: true },
},
} as const;
@@ -113,13 +113,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.contentRequired);
}
if (ps.userId != null) {
if (ps.toUserId != null) {
// Myself
if (ps.userId === me.id) {
if (ps.toUserId === me.id) {
throw new ApiError(meta.errors.recipientIsYourself);
}
const toUser = await this.getterService.getUser(ps.userId).catch(err => {
const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
@@ -130,9 +130,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
text: ps.text,
file: file,
});
}/* else if (ps.roomId != null) {
}/* else if (ps.toRoomId != null) {
// Fetch recipient (room)
recipientRoom = await this.userRoomsRepository.findOneBy({ id: ps.roomId! });
recipientRoom = await this.userRoomsRepository.findOneBy({ id: ps.toRoomId! });
if (recipientRoom == null) {
throw new ApiError(meta.errors.noSuchRoom);

View File

@@ -94,17 +94,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.roomAccessDenied);
}
const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId)
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId)
.andWhere('message.roomId = :roomId', { roomId: recipientRoom.id });
const messages = await query.take(ps.limit).getMany();
// Mark all as read
if (ps.markAsRead) {
this.messagingService.readRoomMessagingMessage(me.id, recipientRoom.id, messages.map(x => x.id));
this.chatService.readRoomMessagingMessage(me.id, recipientRoom.id, messages.map(x => x.id));
}
return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, {
return await Promise.all(messages.map(message => this.chatMessageEntityService.pack(message, me, {
populateRoom: false,
})));
}*/

View File

@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tail === 'left' ? $style.left : $style.right,
negativeMargin === true && $style.negativeMargin,
shadow === true && $style.shadow,
accented === true && $style.accented
]"
>
<div :class="$style.bg">
@@ -30,10 +31,12 @@ withDefaults(defineProps<{
tail?: 'left' | 'right' | 'none';
negativeMargin?: boolean;
shadow?: boolean;
accented?: boolean;
}>(), {
tail: 'right',
negativeMargin: false,
shadow: false,
accented: false,
});
</script>
@@ -47,6 +50,10 @@ withDefaults(defineProps<{
min-height: calc(var(--fukidashi-radius) * 2);
padding-top: calc(var(--fukidashi-radius) * .13);
&.accented {
--fukidashi-bg: var(--MI_THEME-accent);
}
&.shadow {
filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow));
}

View File

@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.center]: align === 'center',
[$style.big]: big,
[$style.asDrawer]: asDrawer,
[$style.widthSpecified]: width != null,
}"
@focusin.passive.stop="() => {}"
>
@@ -29,15 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
</span>
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
<component :is="item.component" v-bind="item.props"/>
</div>
<MkA
v-else-if="item.type === 'link'"
role="menuitem"
@@ -51,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<div :class="$style.item_content_text">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</MkA>
<a
v-else-if="item.type === 'a'"
role="menuitem"
@@ -70,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<div :class="$style.item_content_text">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</a>
<button
v-else-if="item.type === 'user'"
role="menuitem"
@@ -88,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</button>
<button
v-else-if="item.type === 'switch'"
role="menuitemcheckbox"
@@ -101,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<div :class="$style.item_content">
<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
<div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
<button
v-else-if="item.type === 'radio'"
role="menuitem"
@@ -117,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<div :class="$style.item_content_text" style="pointer-events: none;">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button
v-else-if="item.type === 'radioOption'"
role="menuitemradio"
@@ -134,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
</div>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<div :class="$style.item_content_text">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
</div>
</button>
<button
v-else-if="item.type === 'parent'"
role="menuitem"
@@ -148,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<div :class="$style.item_content_text" style="pointer-events: none;">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button
v-else role="menuitem"
v-else
role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@@ -163,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<div :class="$style.item_content_text">
<div :class="$style.item_content_text_title">{{ item.text }}</div>
<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
</div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</button>
</template>
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
@@ -438,6 +473,12 @@ onBeforeUnmount(() => {
}
}
&:not(.widthSpecified) {
> .menu {
max-width: 400px;
}
}
&.big:not(.asDrawer) {
> .menu {
min-width: 230px;
@@ -607,10 +648,19 @@ onBeforeUnmount(() => {
.item_content_text {
max-width: calc(100vw - 4rem);
}
.item_content_text_title {
text-overflow: ellipsis;
overflow: hidden;
}
.item_content_text_caption {
text-wrap: auto;
font-size: 85%;
opacity: 0.7;
}
.switchButton {
margin-left: -2px;
--height: 1.35em;

View File

@@ -28,7 +28,7 @@ export type Keys = (
'theme' |
'themeId' |
'customCss' |
'message_drafts' |
'chatMessageDrafts' |
'scratchpad' |
'debug' |
'preferences' |

View File

@@ -4,6 +4,7 @@
*/
import { computed, reactive } from 'vue';
import { ui } from '@@/js/config.js';
import { clearCache } from './utility/clear-cache.js';
import { $i } from '@/i.js';
import { miLocalStorage } from '@/local-storage.js';
@@ -11,7 +12,6 @@ import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
import { lookup } from '@/utility/lookup.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { ui } from '@@/js/config.js';
import { unisonReload } from '@/utility/unison-reload.js';
export const navbarItemDef = reactive({
@@ -110,6 +110,11 @@ export const navbarItemDef = reactive({
icon: 'ti ti-device-tv',
to: '/channels',
},
chat: {
title: i18n.ts.chat,
icon: 'ti ti-message',
to: '/chat',
},
achievements: {
title: i18n.ts.achievements,
icon: 'ti ti-medal',

View File

@@ -16,27 +16,24 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.roomId ? `/chat/room/${item.message.roomId}` : `/chat/user/${item.other.id}`"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<div>
<MkAvatar :class="$style.avatar" :user="item.other" indicator link preview/>
<header v-if="item.message.roomId">
<span class="name">{{ item.message.room.name }}</span>
<MkTime :time="item.message.createdAt" class="time"/>
<MkAvatar v-if="item.other" :class="$style.avatar" :user="item.other" indicator link preview/>
<header v-if="item.message.room">
<span :class="$style.name">{{ item.message.room.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.time"/>
</header>
<header v-else>
<span class="name"><MkUserName :user="item.other"/></span>
<span class="username">@{{ acct(item.other) }}</span>
<MkTime :time="item.message.createdAt" class="time"/>
<MkUserName :class="$style.name" :user="item.other!"/>
<MkAcct :class="$style.username" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.time"/>
</header>
<div class="body">
<p class="text"><span v-if="item.isMe" :class="$style.iSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</p>
</div>
<div :class="$style.body">
<p :class="$style.text"><span v-if="item.isMe" :class="$style.iSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</p>
</div>
</MkA>
</div>
<div v-if="!fetching && history.length == 0" class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ i18n.ts.noHistory }}</div>
</div>
<MkLoading v-if="fetching"/>
@@ -51,12 +48,15 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { ensureSignin } from '@/i.js';
import { useRouter } from '@/router/supplier.js';
import * as os from '@/os.js';
const $i = ensureSignin();
const router = useRouter();
const fetching = ref(true);
const history = ref<{
id: string;
@@ -65,15 +65,58 @@ const history = ref<{
isMe: boolean;
}[]>([]);
function start(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.individualChat,
caption: i18n.ts.individualChat_description,
icon: 'ti ti-user',
action: () => { startUser(); },
}, {
text: i18n.ts.roomChat,
caption: i18n.ts.roomChat_description,
icon: 'ti ti-users',
action: () => { startRoom(); },
}], ev.currentTarget ?? ev.target);
}
async function startUser() {
os.selectUser().then(user => {
router.push(`/chat/user/${user.id}`);
});
}
async function startRoom() {
/*
const rooms1 = await os.api('users/rooms/owned');
const rooms2 = await os.api('users/rooms/joined');
if (rooms1.length === 0 && rooms2.length === 0) {
os.alert({
type: 'warning',
title: i18n.ts.youHaveNoGroups,
text: i18n.ts.joinOrCreateGroup,
});
return;
}
const { canceled, result: room } = await os.select({
title: i18n.ts.room,
items: rooms1.concat(rooms2).map(room => ({
value: room, text: room.name,
})),
});
if (canceled) return;
router.push(`/chat/room/${room.id}`);
*/
}
async function fetchHistory() {
fetching.value = true;
const [userMessages, groupMessages] = await Promise.all([
misskeyApi('messaging/history', { group: false }),
misskeyApi('messaging/history', { group: true }),
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...groupMessages]
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
@@ -100,23 +143,7 @@ definePage(() => ({
</script>
<style lang="scss" module>
.add {
margin: 0 auto 16px auto;
}
.antenna {
display: block;
padding: 16px;
border: solid 1px var(--MI_THEME-divider);
border-radius: 6px;
&:hover {
border: solid 1px var(--MI_THEME-accent);
text-decoration: none;
}
}
.name {
font-weight: bold;
.start {
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<div
:class="$style.root"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<textarea
ref="textEl"
v-model="text"
:class="$style.textarea"
class="_acrylic"
:placeholder="i18n.ts.inputMessageHere"
@keydown="onKeydown"
@paste="onPaste"
></textarea>
<footer :class="$style.footer">
<div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div>
<div :class="$style.buttons">
<button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
<button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
</button>
</div>
</footer>
<input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, watch, ref, shallowRef, computed } from 'vue';
import * as Misskey from 'misskey-js';
//import insertTextAtCursor from 'insert-text-at-cursor';
import { throttle } from 'throttle-debounce';
import { formatTimeString } from '@/utility/format-time-string.js';
import { selectFile } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
//import { Autocomplete } from '@/utility/autocomplete.js';
import { uploadFile } from '@/utility/upload.js';
import { miLocalStorage } from '@/local-storage.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
user?: Misskey.entities.UserDetailed | null;
room?: Misskey.entities.ChatRoom | null;
}>();
const textEl = shallowRef<HTMLTextAreaElement>();
const fileEl = shallowRef<HTMLInputElement>();
const text = ref<string>('');
const file = ref<Misskey.entities.DriveFile | null>(null);
const sending = ref(false);
const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null);
function getDraftKey() {
return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id;
}
watch([text, file], saveDraft);
async function onPaste(ev: ClipboardEvent) {
if (!ev.clipboardData) return;
const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
const clipboardData = ev.clipboardData;
const items = clipboardData.items;
if (items.length === 1) {
if (items[0].kind === 'file') {
const pastedFile = items[0].getAsFile();
if (!pastedFile) return;
const lio = pastedFile.name.lastIndexOf('.');
const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext;
if (formatted) upload(pastedFile, formatted);
}
} else {
if (items[0].kind === 'file') {
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached,
});
}
}
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
ev.preventDefault();
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
case 'copy':
case 'copyLink':
case 'copyMove':
ev.dataTransfer.dropEffect = 'copy';
break;
case 'linkMove':
case 'move':
ev.dataTransfer.dropEffect = 'move';
break;
default:
ev.dataTransfer.dropEffect = 'none';
break;
}
}
}
function onDrop(ev: DragEvent): void {
if (!ev.dataTransfer) return;
// ファイルだったら
if (ev.dataTransfer.files.length === 1) {
ev.preventDefault();
upload(ev.dataTransfer.files[0]);
return;
} else if (ev.dataTransfer.files.length > 1) {
ev.preventDefault();
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached,
});
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
file.value = JSON.parse(driveFile);
ev.preventDefault();
}
//#endregion
}
function onKeydown(ev: KeyboardEvent) {
if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) {
send();
}
}
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
file.value = selectedFile;
});
}
function onChangeFile() {
if (fileEl.value.files![0]) upload(fileEl.value.files[0]);
}
function upload(fileToUpload: File, name?: string) {
uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => {
file.value = res;
});
}
function send() {
if (!canSend.value) return;
sending.value = true;
misskeyApi('chat/messages/create', {
toUserId: props.user ? props.user.id : undefined,
toRoomId: props.room ? props.room.id : undefined,
text: text.value ? text.value : undefined,
fileId: file.value ? file.value.id : undefined,
}).then(message => {
clear();
}).catch(err => {
console.error(err);
}).then(() => {
sending.value = false;
});
}
function clear() {
text.value = '';
file.value = null;
deleteDraft();
}
function saveDraft() {
const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
drafts[getDraftKey()] = {
updatedAt: new Date(),
data: {
text: text.value,
file: file.value,
},
};
miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
}
function deleteDraft() {
const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
delete drafts[getDraftKey()];
miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
}
async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
}
onMounted(() => {
//autosize(textEl);
// TODO: detach when unmount
// TODO
//new Autocomplete(textEl, this, { model: 'text' });
// 書きかけの投稿を復元
const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()];
if (draft) {
text.value = draft.data.text;
file.value = draft.data.file;
}
});
</script>
<style lang="scss" module>
.root {
position: relative;
}
.textarea {
cursor: auto;
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 80px;
margin: 0;
padding: 16px 16px 0 16px;
resize: none;
font-size: 1em;
font-family: inherit;
outline: none;
border: none;
border-radius: 0;
box-shadow: none;
box-sizing: border-box;
color: var(--fg);
}
.footer {
position: sticky;
bottom: 0;
background: var(--panel);
}
.file {
padding: 8px;
color: var(--fg);
background: transparent;
cursor: pointer;
}
/*
.files {
display: block;
margin: 0;
padding: 0 8px;
list-style: none;
&:after {
content: '';
display: block;
clear: both;
}
> li {
display: block;
float: left;
margin: 4px;
padding: 0;
width: 64px;
height: 64px;
background-color: #eee;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
cursor: move;
&:hover {
> .remove {
display: block;
}
}
}
}
.file-remove {
display: none;
position: absolute;
right: -6px;
top: -6px;
margin: 0;
padding: 0;
background: transparent;
outline: none;
border: none;
border-radius: 0;
box-shadow: none;
cursor: pointer;
}
*/
.buttons {
display: flex;
}
.button {
margin: 0;
padding: 16px;
font-size: 1em;
font-weight: normal;
text-decoration: none;
transition: color 0.1s ease;
&:hover {
color: var(--accent);
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
}
.send {
margin-left: auto;
color: var(--accent);
&:hover {
color: var(--accentLighten);
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div :class="[$style.root, { [$style.isMe]: isMe }]">
<MkAvatar :class="$style.avatar" :user="user" indicator link preview/>
<div :class="$style.body">
<MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe">
<div v-if="!message.isDeleted" :class="$style.content">
<Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/>
<div v-if="message.file" :class="$style.file">
<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
<p v-else>{{ message.file.name }}</p>
</a>
</div>
</div>
<div v-else :class="$style.content">
<p>{{ i18n.ts.deleted }}</p>
</div>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<div>
<MkTime :class="$style.time" :time="message.createdAt"/>
</div>
</div>
<button v-if="isMe" :class="$style.delete" :title="i18n.ts.delete" @click="del">
<img src="/client-assets/remove.png" alt="Delete"/>
</button>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkFukidashi from '@/components/MkFukidashi.vue';
const $i = ensureSignin();
const props = defineProps<{
message: Misskey.entities.ChatMessageLite;
user: Misskey.entities.User;
isRoom?: boolean;
}>();
const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
function del(): void {
misskeyApi('chat/messages/delete', {
messageId: props.message.id,
});
}
</script>
<style lang="scss" module>
.root {
$me-balloon-color: var(--accent);
position: relative;
background-color: transparent;
display: flex;
&.isMe {
flex-direction: row-reverse;
text-align: right;
.content {
color: var(--MI_THEME-fgOnAccent);
}
}
}
.avatar {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
display: block;
width: 54px;
height: 54px;
transition: all 0.1s ease;
}
.body {
margin: 0 12px;
}
.content {
overflow: hidden;
overflow-wrap: break-word;
word-break: break-word;
}
.delete {
position: absolute;
top: 0;
right: 0;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
transition: all 0.1s ease;
&:hover {
transform: scale(1.1);
}
}
.time {
font-size: 75%;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,292 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div style="height: 100vh; overflow:auto; display:flex; flex-direction:column-reverse;">
<MkStickyContainer>
<template #header>
<MkPageHeader/>
</template>
<div ref="rootEl" :class="$style.root">
<MkSpacer :contentMax="700">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userId || roomId" :pagination="pagination" :disableAutoLoad="true">
<template #empty>
<div class="_fullinfo">
<div>{{ i18n.ts.noMessagesYet }}</div>
</div>
</template>
<template #default="{ items: messages, fetching: pFetching }">
<MkDateSeparatedList
v-if="messages.length > 0"
v-slot="{ item: message }"
:class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }"
:items="messages"
direction="up"
reversed
>
<XMessage :key="message.id" :message="message" :user="message.fromUserId === $i.id ? $i : user" :isRoom="room != null"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</MkSpacer>
</div>
<template #footer>
<MkSpacer :contentMax="700">
<div class="_gaps">
<Transition name="fade">
<div v-show="showIndicator" :class="$style.new">
<button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick">
<i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts.newMessageExists }}
</button>
</div>
</Transition>
<XForm v-if="!fetching" :user="user" :room="room" :class="$style.form"/>
</div>
</MkSpacer>
</template>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
import * as Misskey from 'misskey-js';
import { isBottomVisible } from '@@/js/scroll.js';
import XMessage from './room.message.vue';
import XForm from './room.form.vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/utility/sound.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
const $i = ensureSignin();
const props = defineProps<{
userId?: string;
roomId?: string;
}>();
const rootEl = shallowRef<HTMLDivElement>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const fetching = ref(true);
const user = ref<Misskey.entities.UserDetailed | null>(null);
const room = ref<Misskey.entities.ChatRoom | null>(null);
const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chat']> | null>(null);
const showIndicator = ref(false);
const pagination = ref<Paging | null>(null);
watch([() => props.userId, () => props.roomId], () => {
if (connection.value) connection.value.dispose();
fetch();
});
async function fetch() {
fetching.value = true;
if (props.userId) {
user.value = await misskeyApi('users/show', { userId: props.userId });
room.value = null;
pagination.value = {
endpoint: 'chat/messages/timeline',
limit: 20,
params: {
userId: user.value.id,
},
reversed: true,
pageEl: rootEl.value,
};
connection.value = useStream().useChannel('chat', {
other: user.value.id,
});
}/* else {
user = null;
room = await misskeyApi('users/rooms/show', { roomId: props.roomId });
pagination = {
endpoint: 'chat/messages',
limit: 20,
params: {
roomId: room?.id,
},
reversed: true,
pageEl: $$(rootEl).value,
};
connection = useStream().useChannel('chat', {
room: room?.id,
});
}*/
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
document.addEventListener('visibilitychange', onVisibilitychange);
fetching.value = false;
}
function onMessage(message) {
//sound.play('chat');
const _isBottom = isBottomVisible(rootEl, 64);
pagingComponent.value.prepend(message);
if (message.userId !== $i.id && !document.hidden) {
connection.value?.send('read', {
id: message.id,
});
}
if (_isBottom) {
// Scroll to bottom
nextTick(() => {
thisScrollToBottom();
});
} else if (message.userId !== $i.id) {
// Notify
notifyNewMessage();
}
}
function onDeleted(id) {
const msg = pagingComponent.value.items.find(m => m.id === id);
if (msg) {
pagingComponent.value.items = pagingComponent.value.items.filter(m => m.id !== msg.id);
}
}
function thisScrollToBottom() {
scrollToBottom(rootEl.value, { behavior: 'smooth' });
}
function onIndicatorClick() {
showIndicator.value = false;
thisScrollToBottom();
}
function notifyNewMessage() {
showIndicator.value = true;
scrollRemove.value = onScrollBottom(rootEl, () => {
showIndicator.value = false;
scrollRemove.value = null;
});
}
function onVisibilitychange() {
if (document.hidden) return;
for (const message of pagingComponent.value.items) {
if (message.userId !== $i.id && !message.isRead) {
connection.value?.send('read', {
id: message.id,
});
}
}
}
onMounted(() => {
fetch();
});
onBeforeUnmount(() => {
connection.value?.dispose();
document.removeEventListener('visibilitychange', onVisibilitychange);
});
definePage(computed(() => !fetching.value ? user.value ? {
userName: user,
avatar: user,
} : {
title: room.value?.name,
icon: 'ti ti-users',
} : null));
</script>
<style lang="scss" module>
.root {
display: content;
}
.more {
display: block;
margin: 16px auto;
padding: 0 12px;
line-height: 24px;
color: #fff;
background: rgba(#000, 0.3);
border-radius: 12px;
&:hover {
background: rgba(#000, 0.4);
}
&:active {
background: rgba(#000, 0.5);
}
}
.fetching {
cursor: wait;
}
.messages {
padding: 16px 0 0;
}
.footer {
width: 100%;
position: sticky;
z-index: 2;
padding-top: 8px;
bottom: var(--minBottomSpacing);
}
.new {
width: 100%;
padding-bottom: 8px;
text-align: center;
}
.newButton {
display: inline-block;
margin: 0;
padding: 0 12px;
line-height: 32px;
font-size: 12px;
border-radius: 16px;
}
.newIcon {
display: inline-block;
margin-right: 8px;
}
.form {
max-height: 12em;
overflow-y: scroll;
border-top: solid 0.5px var(--divider);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.1s;
}
.fade-enter-from, .fade-leave-to {
transition: opacity 0.5s;
opacity: 0;
}
</style>

View File

@@ -41,6 +41,12 @@ const routes: RouteDef[] = [{
}, {
path: '/clips/:clipId',
component: page(() => import('@/pages/clip.vue')),
}, {
path: '/chat',
component: page(() => import('@/pages/chat/home.vue')),
}, {
path: '/chat/user/:userId',
component: page(() => import('@/pages/chat/room.vue')),
}, {
path: '/instance-info/:host',
component: page(() => import('@/pages/instance-info.vue')),

View File

@@ -15,16 +15,16 @@ export type MenuAction = (ev: MouseEvent) => void;
export type MenuDivider = { type: 'divider' };
export type MenuNull = undefined;
export type MenuLabel = { type: 'label', text: string };
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
export type MenuLabel = { type: 'label', text: string, caption?: string };
export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean };
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> };
export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> };
export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
export type MenuPending = { type: 'pending' };

View File

@@ -32,7 +32,7 @@ const mimeTypeMap = {
export function uploadFile(
file: File,
folder?: string | Misskey.entities.DriveFolder,
folder?: string | Misskey.entities.DriveFolder | null,
name?: string,
keepOriginal: boolean = prefer.s.keepOriginalUploading,
): Promise<Misskey.entities.DriveFile> {

View File

@@ -951,6 +951,12 @@ type ChartsUsersRequest = operations['charts___users']['requestBody']['content']
// @public (undocumented)
type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessage = components['schemas']['ChatMessage'];
@@ -963,18 +969,15 @@ type ChatMessagesCreateRequest = operations['chat___messages___create']['request
// @public (undocumented)
type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesHistoryRequest = operations['chat___messages___history']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesHistoryResponse = operations['chat___messages___history']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatRoom = components['schemas']['ChatRoom'];
// @public (undocumented)
type Clip = components['schemas']['Clip'];
@@ -1472,10 +1475,10 @@ declare namespace entities {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
ChatHistoryRequest,
ChatHistoryResponse,
ChatMessagesCreateRequest,
ChatMessagesCreateResponse,
ChatMessagesHistoryRequest,
ChatMessagesHistoryResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,
@@ -1912,7 +1915,8 @@ declare namespace entities {
SystemWebhook,
AbuseReportNotificationRecipient,
ChatMessage,
ChatMessageLite
ChatMessageLite,
ChatRoom
}
}
export { entities }

View File

@@ -1537,9 +1537,9 @@ declare module '../api.js' {
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:chat*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
request<E extends 'chat/messages/create', P extends Endpoints[E]['req']>(
request<E extends 'chat/history', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
@@ -1548,9 +1548,9 @@ declare module '../api.js' {
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
* **Credential required**: *Yes* / **Permission**: *write:chat*
*/
request<E extends 'chat/messages/history', P extends Endpoints[E]['req']>(
request<E extends 'chat/messages/create', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,

View File

@@ -207,10 +207,10 @@ import type {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
ChatHistoryRequest,
ChatHistoryResponse,
ChatMessagesCreateRequest,
ChatMessagesCreateResponse,
ChatMessagesHistoryRequest,
ChatMessagesHistoryResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,
@@ -732,8 +732,8 @@ export type Endpoints = {
'charts/user/pv': { req: ChartsUserPvRequest; res: ChartsUserPvResponse };
'charts/user/reactions': { req: ChartsUserReactionsRequest; res: ChartsUserReactionsResponse };
'charts/users': { req: ChartsUsersRequest; res: ChartsUsersResponse };
'chat/history': { req: ChatHistoryRequest; res: ChatHistoryResponse };
'chat/messages/create': { req: ChatMessagesCreateRequest; res: ChatMessagesCreateResponse };
'chat/messages/history': { req: ChatMessagesHistoryRequest; res: ChatMessagesHistoryResponse };
'chat/messages/timeline': { req: ChatMessagesTimelineRequest; res: ChatMessagesTimelineResponse };
'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse };
'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse };

View File

@@ -210,10 +210,10 @@ export type ChartsUserReactionsRequest = operations['charts___user___reactions']
export type ChartsUserReactionsResponse = operations['charts___user___reactions']['responses']['200']['content']['application/json'];
export type ChartsUsersRequest = operations['charts___users']['requestBody']['content']['application/json'];
export type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
export type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
export type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
export type ChatMessagesCreateRequest = operations['chat___messages___create']['requestBody']['content']['application/json'];
export type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json'];
export type ChatMessagesHistoryRequest = operations['chat___messages___history']['requestBody']['content']['application/json'];
export type ChatMessagesHistoryResponse = operations['chat___messages___history']['responses']['200']['content']['application/json'];
export type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
export type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json'];
export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json'];

View File

@@ -56,3 +56,4 @@ export type SystemWebhook = components['schemas']['SystemWebhook'];
export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient'];
export type ChatMessage = components['schemas']['ChatMessage'];
export type ChatMessageLite = components['schemas']['ChatMessageLite'];
export type ChatRoom = components['schemas']['ChatRoom'];

View File

@@ -1358,6 +1358,15 @@ export type paths = {
*/
post: operations['charts___users'];
};
'/chat/history': {
/**
* chat/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
post: operations['chat___history'];
};
'/chat/messages/create': {
/**
* chat/messages/create
@@ -1367,15 +1376,6 @@ export type paths = {
*/
post: operations['chat___messages___create'];
};
'/chat/messages/history': {
/**
* chat/messages/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
post: operations['chat___messages___history'];
};
'/chat/messages/timeline': {
/**
* chat/messages/timeline
@@ -5178,12 +5178,15 @@ export type components = {
/** Format: date-time */
createdAt: string;
fromUserId: string;
fromUser?: components['schemas']['UserLite'];
fromUser: components['schemas']['UserLite'];
toUserId?: string | null;
toUser?: components['schemas']['UserLite'] | null;
toRoomId?: string | null;
toRoom?: components['schemas']['ChatRoom'] | null;
text?: string | null;
fileId?: string | null;
file?: components['schemas']['DriveFile'] | null;
isRead?: boolean;
};
ChatMessageLite: {
id: string;
@@ -5191,10 +5194,19 @@ export type components = {
createdAt: string;
fromUserId: string;
toUserId?: string | null;
toRoomId?: string | null;
text?: string | null;
fileId?: string | null;
file?: components['schemas']['DriveFile'] | null;
};
ChatRoom: {
id: string;
/** Format: date-time */
createdAt: string;
ownerId: string;
owner: components['schemas']['UserLite'];
name: string;
};
};
responses: never;
parameters: never;
@@ -13718,6 +13730,62 @@ export type operations = {
};
};
};
/**
* chat/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
chat___history: {
requestBody: {
content: {
'application/json': {
/** @default 10 */
limit?: number;
/** @default false */
room?: boolean;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessage'][];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* chat/messages/create
* @description No description provided.
@@ -13781,62 +13849,6 @@ export type operations = {
};
};
};
/**
* chat/messages/history
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
chat___messages___history: {
requestBody: {
content: {
'application/json': {
/** @default 10 */
limit?: number;
/** @default false */
room?: boolean;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessage'][];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* chat/messages/timeline
* @description No description provided.