Merge branch 'develop' into feature/default-post-target-detect-from-path

This commit is contained in:
かっこかり 2024-05-27 18:16:40 +09:00 committed by GitHub
commit ae4c26badc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 506 additions and 120 deletions

View File

@ -25,6 +25,8 @@
### Client
- Feat: アップロードするファイルの名前をランダム文字列にできるように
- Feat: 個別のお知らせにリンクで飛べるように
(Cherry-picked from https://github.com/MisskeyIO/misskey)
- Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように
- Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように
- Enhance: リアクション・いいねの総数を表示するように
@ -47,6 +49,8 @@
- Enhance: AiScriptを0.18.0にバージョンアップ
- Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように
- Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように
- Enhance: コントロールパネルのクイックアクションからファイルを照会できるように
- Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように
- Enhance: どこで投稿フォームを開いてもお気に入りに登録したチャンネルにノートできるように
- Enhance: チャンネルのページを開いている間はデフォルトの公開範囲がそのチャンネルになるように
- Fix: 一部のページ内リンクが正しく動作しない問題を修正

6
locales/index.d.ts vendored
View File

@ -4113,9 +4113,13 @@ export interface Locale extends ILocale {
*/
"thisPostMayBeAnnoyingIgnore": string;
/**
*
*
*/
"collapseRenotes": string;
/**
*
*/
"collapseRenotesDescription": string;
/**
*
*/

View File

@ -1024,7 +1024,8 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります
thisPostMayBeAnnoyingHome: "ホームに投稿"
thisPostMayBeAnnoyingCancel: "やめる"
thisPostMayBeAnnoyingIgnore: "このまま投稿"
collapseRenotes: "見たことのあるリノートを省略して表示"
collapseRenotes: "リノートのスマート省略"
collapseRenotesDescription: "リアクションやリノートをしたことがあるノートをたたんで表示します。"
internalServerError: "サーバー内部エラー"
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
copyErrorInfo: "エラー情報をコピー"

View File

@ -4,13 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Brackets, EntityNotFoundError } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@ -29,6 +30,7 @@ export class AnnouncementService {
private idService: IdService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
) {
}
@ -79,7 +81,7 @@ export class AnnouncementService {
userId: values.userId,
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
const packed = (await this.packMany([announcement]))[0];
const packed = await this.announcementEntityService.pack(announcement);
if (values.userId) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
@ -177,6 +179,24 @@ export class AnnouncementService {
}
}
@bindThis
public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> {
const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId });
if (me) {
if (announcement.userId && announcement.userId !== me.id) {
throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId });
}
const read = await this.announcementReadsRepository.findOneBy({
announcementId: announcement.id,
userId: me.id,
});
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
} else {
return this.announcementEntityService.pack(announcement, null);
}
}
@bindThis
public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> {
try {
@ -193,29 +213,4 @@ export class AnnouncementService {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
}
}
@bindThis
public async packMany(
announcements: MiAnnouncement[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
reads?: MiAnnouncementRead[];
},
): Promise<Packed<'Announcement'>[]> {
const reads = me ? (options?.reads ?? await this.getReads(me.id)) : [];
return announcements.map(announcement => ({
id: announcement.id,
createdAt: this.idService.parse(announcement.id).date.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
text: announcement.text,
title: announcement.title,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
needConfirmationToRead: announcement.needConfirmationToRead,
silence: announcement.silence,
forYou: announcement.userId === me?.id,
isRead: reads.some(read => read.announcementId === announcement.id),
}));
}
}

View File

@ -84,6 +84,7 @@ import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
@ -223,6 +224,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
@ -363,6 +365,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@ -499,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
@ -635,6 +639,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@ -770,6 +775,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,

View File

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@Injectable()
export class AnnouncementEntityService {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,
private idService: IdService,
) {
}
@bindThis
public async pack(
src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null },
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'Announcement'>> {
const announcement = typeof src === 'object'
? src
: await this.announcementsRepository.findOneByOrFail({
id: src,
}) as MiAnnouncement & { isRead?: boolean | null };
if (me && announcement.isRead === undefined) {
announcement.isRead = await this.announcementReadsRepository
.countBy({
announcementId: announcement.id,
userId: me.id,
})
.then((count: number) => count > 0);
}
return {
id: announcement.id,
createdAt: this.idService.parse(announcement.id).date.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
title: announcement.title,
text: announcement.text,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
forYou: announcement.userId === me?.id,
needConfirmationToRead: announcement.needConfirmationToRead,
silence: announcement.silence,
isRead: announcement.isRead !== null ? announcement.isRead : undefined,
};
}
@bindThis
public async packMany(
announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[],
me?: { id: MiUser['id'] } | null | undefined,
) : Promise<Packed<'Announcement'>[]> {
return (await Promise.allSettled(announcements.map(x => this.pack(x, me))))
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value);
}
}

View File

@ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
import * as ep___antennas_list from './endpoints/antennas/list.js';
@ -455,6 +456,7 @@ const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', us
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default };
@ -831,6 +833,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
$announcements_show,
$antennas_create,
$antennas_delete,
$antennas_list,
@ -1201,6 +1204,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
$announcements_show,
$antennas_create,
$antennas_delete,
$antennas_list,

View File

@ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
import * as ep___antennas_list from './endpoints/antennas/list.js';
@ -453,6 +454,7 @@ const eps = [
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['admin/roles/users', ep___admin_roles_users],
['announcements', ep___announcements],
['announcements/show', ep___announcements_show],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],
['antennas/list', ep___antennas_list],

View File

@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { DI } from '@/di-symbols.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js';
import type { AnnouncementsRepository } from '@/models/_.js';
export const meta = {
tags: ['meta'],
@ -44,11 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,
private queryService: QueryService,
private announcementService: AnnouncementService,
private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
@ -60,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const announcements = await query.limit(ps.limit).getMany();
return this.announcementService.packMany(announcements, me);
return this.announcementEntityService.packMany(announcements, me);
});
}
}

View File

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { EntityNotFoundError } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: false,
res: {
type: 'object',
optional: false, nullable: false,
ref: 'Announcement',
},
errors: {
noSuchAnnouncement: {
message: 'No such announcement.',
code: 'NO_SUCH_ANNOUNCEMENT',
id: 'b57b5e1d-4f49-404a-9edb-46b00268f121',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
announcementId: { type: 'string', format: 'misskey:id' },
},
required: ['announcementId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
try {
return await this.announcementService.getAnnouncement(ps.announcementId, me);
} catch (err) {
if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement);
throw err;
}
});
}
}

View File

@ -498,6 +498,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async verifyLink(url: string, user: MiLocalUser) {
if (!safeForSql(url)) return;
try {
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
@ -519,5 +520,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})
.execute();
}
window.close();
} catch (err) {
// なにもしない
}
}
}

View File

@ -466,7 +466,9 @@ export class ClientServerService {
};
// Atom
fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {
@ -479,7 +481,9 @@ export class ClientServerService {
});
// RSS
fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {
@ -492,7 +496,9 @@ export class ClientServerService {
});
// JSON
fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => {
fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
if (feed) {

View File

@ -10,6 +10,7 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import type {
AnnouncementReadsRepository,
AnnouncementsRepository,
@ -67,6 +68,7 @@ describe('AnnouncementService', () => {
],
providers: [
AnnouncementService,
AnnouncementEntityService,
CacheService,
IdService,
],

View File

@ -11,3 +11,4 @@ export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, ()
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
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'));
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));

View File

@ -42,7 +42,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { lookupFile } from '@/scripts/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@ -73,33 +73,10 @@ function clear() {
});
}
function show(file) {
os.pageWindow(`/admin/file/${file.id}`);
}
async function find() {
const { canceled, result: q } = await os.inputText({
title: i18n.ts.fileIdOrUrl,
minLength: 1,
});
if (canceled) return;
misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
show(file);
}).catch(err => {
if (err.code === 'NO_SUCH_FILE') {
os.alert({
type: 'error',
text: i18n.ts.notFound,
});
}
});
}
const headerActions = computed(() => [{
text: i18n.ts.lookup,
icon: 'ti ti-search',
handler: find,
handler: lookupFile,
}, {
text: i18n.ts.clearCachedFiles,
icon: 'ti ti-trash',

View File

@ -33,9 +33,10 @@ import { i18n } from '@/i18n.js';
import MkSuperMenu from '@/components/MkSuperMenu.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instance } from '@/instance.js';
import { lookup } from '@/scripts/lookup.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js';
import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js';
import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router/supplier.js';
@ -82,7 +83,7 @@ const menuDef = computed(() => [{
type: 'button',
icon: 'ti ti-search',
text: i18n.ts.lookup,
action: lookup,
action: adminLookup,
}, ...(instance.disableRegistration ? [{
type: 'button',
icon: 'ti ti-user-plus',
@ -282,7 +283,7 @@ function invite() {
});
}
function lookup(ev: MouseEvent) {
function adminLookup(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.user,
icon: 'ti ti-user',
@ -295,23 +296,17 @@ function lookup(ev: MouseEvent) {
action: () => {
lookupUserByEmail();
},
}, {
text: i18n.ts.note,
icon: 'ti ti-pencil',
action: () => {
alert('TODO');
},
}, {
text: i18n.ts.file,
icon: 'ti ti-cloud',
action: () => {
alert('TODO');
lookupFile();
},
}, {
text: i18n.ts.instance,
icon: 'ti ti-planet',
text: i18n.ts.lookup,
icon: 'ti ti-world-search',
action: () => {
alert('TODO');
lookup();
},
}], ev.currentTarget ?? ev.target);
}

View File

@ -63,7 +63,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js';
import { lookupUser } from '@/scripts/lookup-user.js';
import { lookupUser } from '@/scripts/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';

View File

@ -0,0 +1,142 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<Transition
:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
mode="out-in"
>
<div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement">
<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
<div :class="$style.header">
<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
<span style="margin-right: 0.5em;">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
<Mfm :text="announcement.title"/>
</div>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
</div>
<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
</div>
</div>
<div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer">
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
</div>
</div>
<MkError v-else-if="error" @retry="fetch()"/>
<MkLoading v-else/>
</Transition>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i, updateAccount } from '@/account.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{
announcementId: string;
}>();
const announcement = ref<Misskey.entities.Announcement | null>(null);
const error = ref<any>(null);
const path = computed(() => props.announcementId);
function fetch() {
announcement.value = null;
misskeyApi('announcements/show', {
announcementId: props.announcementId,
}).then(async _announcement => {
announcement.value = _announcement;
}).catch(err => {
error.value = err;
});
}
async function read(target: Misskey.entities.Announcement): Promise<void> {
if (target.needConfirmationToRead) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
});
if (confirm.canceled) return;
}
target.isRead = true;
await misskeyApi('i/read-announcement', { announcementId: target.id });
if ($i) {
updateAccount({
unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
});
}
}
watch(() => path.value, fetch, { immediate: true });
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements,
icon: 'ti ti-speakerphone',
}));
</script>
<style lang="scss" module>
.announcement {
padding: 16px;
}
.forYou {
display: flex;
align-items: center;
line-height: 24px;
font-size: 90%;
white-space: pre;
color: #d28a3f;
}
.header {
margin-bottom: 16px;
font-weight: bold;
font-size: 120%;
}
.content {
> img {
display: block;
max-height: 300px;
max-width: 100%;
}
}
.footer {
margin-top: 16px;
}
</style>

View File

@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
<span>{{ announcement.title }}</span>
<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
</div>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<div style="opacity: 0.7; font-size: 85%;">
<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
<MkA :to="`/announcements/${announcement.id}`">
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
</div>
<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
</div>
</MkA>
</div>
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
@ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>();
const tab = ref('current');
async function read(announcement) {
if (announcement.needConfirmationToRead) {
async function read(target) {
if (target.needConfirmationToRead) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
});
if (confirm.canceled) return;
}
if (!paginationEl.value) return;
paginationEl.value.updateItem(announcement.id, a => {
paginationEl.value.updateItem(target.id, a => {
a.isRead = true;
return a;
});
misskeyApi('i/read-announcement', { announcementId: announcement.id });
misskeyApi('i/read-announcement', { announcementId: target.id });
updateAccount({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id),
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
});
}

View File

@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config.js';
import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store.js';
@ -153,6 +154,7 @@ function favorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = true;
favoritedChannelsCache.delete();
});
}
@ -168,6 +170,7 @@ async function unfavorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = false;
favoritedChannelsCache.delete();
});
}

View File

@ -50,9 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<div class="_gaps_s">
<MkSwitch v-model="collapseRenotes">
<template #label>{{ i18n.ts.collapseRenotes }}</template>
<template #caption>{{ i18n.ts.collapseRenotesDescription }}</template>
</MkSwitch>
<MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
<MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
<MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch>

View File

@ -48,7 +48,7 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache, userListsCache } from '@/cache.js';
import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js';
@ -173,9 +173,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
}
async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await misskeyApi('channels/my-favorites', {
limit: 100,
});
const channels = await favoritedChannelsCache.fetch();
const items: MenuItem[] = [
...channels.map(channel => {
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;

View File

@ -193,6 +193,9 @@ const routes: RouteDef[] = [{
}, {
path: '/announcements',
component: page(() => import('@/pages/announcements.vue')),
}, {
path: '/announcements/:announcementId',
component: page(() => import('@/pages/announcement.vue')),
}, {
path: '/about',
component: page(() => import('@/pages/about.vue')),

View File

@ -63,3 +63,26 @@ export async function lookupUserByEmail() {
}
}
}
export async function lookupFile() {
const { canceled, result: q } = await os.inputText({
title: i18n.ts.fileIdOrUrl,
minLength: 1,
});
if (canceled) return;
const show = (file) => {
os.pageWindow(`/admin/file/${file.id}`);
};
misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
show(file);
}).catch(err => {
if (err.code === 'NO_SUCH_FILE') {
os.alert({
type: 'error',
text: i18n.ts.notFound,
});
}
});
}

View File

@ -16,7 +16,7 @@ import { url } from '@/config.js';
import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache } from '@/cache.js';
import { clipsCache, favoritedChannelsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
@ -603,9 +603,7 @@ export function getRenoteMenu(props: {
icon: 'ti ti-repeat',
text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
children: async () => {
const channels = await misskeyApi('channels/my-favorites', {
limit: 30,
});
const channels = await favoritedChannelsCache.fetch();
return channels.filter((channel) => {
if (!appearNote.channelId) return true;
return channel.id !== appearNote.channelId;

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')"
:key="announcement.id"
:class="$style.item"
to="/announcements"
:to="`/announcements/${announcement.id}`"
>
<span :class="$style.icon">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>

View File

@ -26,6 +26,7 @@ import { updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
@ -42,20 +43,18 @@ if (props.column.channelId == null) {
}
async function setChannel() {
const channels = await misskeyApi('channels/my-favorites', {
limit: 100,
});
const { canceled, result: channel } = await os.select({
const channels = await favoritedChannelsCache.fetch();
const { canceled, result: chosenChannel } = await os.select({
title: i18n.ts.selectChannel,
items: channels.map(x => ({
value: x, text: x.name,
})),
default: props.column.channelId,
});
if (canceled) return;
if (canceled || chosenChannel == null) return;
updateColumn(props.column.id, {
channelId: channel.id,
name: channel.name,
channelId: chosenChannel.id,
name: chosenChannel.name,
});
}

View File

@ -336,6 +336,12 @@ type AnnouncementsRequest = operations['announcements']['requestBody']['content'
// @public (undocumented)
type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json'];
// @public (undocumented)
type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json'];
// @public (undocumented)
type Antenna = components['schemas']['Antenna'];
@ -1224,6 +1230,8 @@ declare namespace entities {
AdminRolesUsersResponse,
AnnouncementsRequest,
AnnouncementsResponse,
AnnouncementsShowRequest,
AnnouncementsShowResponse,
AntennasCreateRequest,
AntennasCreateResponse,
AntennasDeleteRequest,

View File

@ -851,6 +851,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'announcements/show', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -101,6 +101,8 @@ import type {
AdminRolesUsersResponse,
AnnouncementsRequest,
AnnouncementsResponse,
AnnouncementsShowRequest,
AnnouncementsShowResponse,
AntennasCreateRequest,
AntennasCreateResponse,
AntennasDeleteRequest,
@ -631,6 +633,7 @@ export type Endpoints = {
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse };
'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse };
'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse };
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };
'antennas/list': { req: EmptyRequest; res: AntennasListResponse };

View File

@ -104,6 +104,8 @@ export type AdminRolesUsersRequest = operations['admin___roles___users']['reques
export type AdminRolesUsersResponse = operations['admin___roles___users']['responses']['200']['content']['application/json'];
export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json'];
export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
export type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json'];
export type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json'];
export type AntennasCreateRequest = operations['antennas___create']['requestBody']['content']['application/json'];
export type AntennasCreateResponse = operations['antennas___create']['responses']['200']['content']['application/json'];
export type AntennasDeleteRequest = operations['antennas___delete']['requestBody']['content']['application/json'];

View File

@ -706,6 +706,15 @@ export type paths = {
*/
post: operations['announcements'];
};
'/announcements/show': {
/**
* announcements/show
* @description No description provided.
*
* **Credential required**: *No*
*/
post: operations['announcements___show'];
};
'/antennas/create': {
/**
* antennas/create
@ -9662,6 +9671,60 @@ export type operations = {
};
};
};
/**
* announcements/show
* @description No description provided.
*
* **Credential required**: *No*
*/
announcements___show: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
announcementId: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['Announcement'];
};
};
/** @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'];
};
};
};
};
/**
* antennas/create
* @description No description provided.