Merge branch 'develop' into feat-12997

This commit is contained in:
かっこかり 2024-02-26 18:21:11 +09:00 committed by GitHub
commit f90b591235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 544 additions and 287 deletions

View File

@ -22,11 +22,16 @@
- Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正 - Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正
- Fix: チャートのラベルが消えている問題を修正 - Fix: チャートのラベルが消えている問題を修正
- Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正 - Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正
- Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正
### Server ### Server
- Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正 - Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正
- エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました - エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました
- Fix: 禁止キーワードを含むートがDelayed Queueに追加されて再処理される問題を修正 - Fix: 禁止キーワードを含むートがDelayed Queueに追加されて再処理される問題を修正
- エンドポイント`admin/emoji/update`の各種修正
- 必須パラメータを`id`または`name`のいずれかのみに
- `id`の代わりに`name`で絵文字を指定可能に(`id`・`name`両指定時は従来通り`name`を変更する挙動)
- `category`および`licence`が指定なしの時勝手にnullに上書きされる挙動を修正
## 2024.2.0 ## 2024.2.0

62
locales/index.d.ts vendored
View File

@ -4856,6 +4856,14 @@ export interface Locale extends ILocale {
* *
*/ */
"replaying": string; "replaying": string;
/**
*
*/
"endReplay": string;
/**
*
*/
"copyReplayData": string;
/** /**
* *
*/ */
@ -4884,6 +4892,18 @@ export interface Locale extends ILocale {
* *
*/ */
"enableHorizontalSwipe": string; "enableHorizontalSwipe": string;
/**
*
*/
"loading": string;
/**
*
*/
"surrender": string;
/**
*
*/
"gameRetry": string;
/** /**
* *
*/ */
@ -4897,6 +4917,40 @@ export interface Locale extends ILocale {
* *
*/ */
"howToPlay": string; "howToPlay": string;
/**
*
*/
"hold": string;
"_score": {
/**
*
*/
"score": string;
/**
*
*/
"scoreYen": string;
/**
*
*/
"highScore": string;
/**
*
*/
"maxChain": string;
/**
* {yen}
*/
"yen": ParameterizedString<"yen">;
/**
* {qty}
*/
"estimatedQty": ParameterizedString<"qty">;
/**
* {onigiriQtyWithUnit}
*/
"scoreSweets": ParameterizedString<"onigiriQtyWithUnit">;
};
"_howToPlay": { "_howToPlay": {
/** /**
* 調 * 調
@ -9700,6 +9754,14 @@ export interface Locale extends ILocale {
* *
*/ */
"disallowIrregularRules": string; "disallowIrregularRules": string;
/**
*
*/
"showBoardLabels": string;
/**
*
*/
"useAvatarAsStone": string;
}; };
"_offlineScreen": { "_offlineScreen": {
/** /**

View File

@ -1210,6 +1210,8 @@ soundWillBePlayed: "サウンドが再生されます"
showReplay: "リプレイを見る" showReplay: "リプレイを見る"
replay: "リプレイ" replay: "リプレイ"
replaying: "リプレイ中" replaying: "リプレイ中"
endReplay: "リプレイを終了"
copyReplayData: "リプレイデータをコピー"
ranking: "ランキング" ranking: "ランキング"
lastNDays: "直近{n}日" lastNDays: "直近{n}日"
backToTitle: "タイトルへ" backToTitle: "タイトルへ"
@ -1217,11 +1219,23 @@ hemisphere: "お住まいの地域"
withSensitive: "センシティブなファイルを含むノートを表示" withSensitive: "センシティブなファイルを含むノートを表示"
userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿" userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿"
enableHorizontalSwipe: "スワイプしてタブを切り替える" enableHorizontalSwipe: "スワイプしてタブを切り替える"
loading: "読み込み中"
surrender: "やめる"
gameRetry: "リトライ"
prohibitSkippingInitialTutorial: "チュートリアルをスキップできないようにする" prohibitSkippingInitialTutorial: "チュートリアルをスキップできないようにする"
prohibitSkippingInitialTutorialDescription: "新規登録したユーザーに表示されるチュートリアルをスキップできないようにします。チュートリアルを完了しなかったりチュートリアルページを回避したりした場合でも、強制的にリダイレクトされます。" prohibitSkippingInitialTutorialDescription: "新規登録したユーザーに表示されるチュートリアルをスキップできないようにします。チュートリアルを完了しなかったりチュートリアルページを回避したりした場合でも、強制的にリダイレクトされます。"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"
hold: "ホールド"
_score:
score: "スコア"
scoreYen: "稼いだ金額"
highScore: "ハイスコア"
maxChain: "最大チェーン数"
yen: "{yen}円"
estimatedQty: "{qty}個分"
scoreSweets: "おにぎり {onigiriQtyWithUnit}"
_howToPlay: _howToPlay:
section1: "位置を調整してハコにモノを落とします。" section1: "位置を調整してハコにモノを落とします。"
section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。" section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。"
@ -2583,6 +2597,8 @@ _reversi:
opponentHasSettingsChanged: "相手が設定を変更しました" opponentHasSettingsChanged: "相手が設定を変更しました"
allowIrregularRules: "変則許可 (完全フリー)" allowIrregularRules: "変則許可 (完全フリー)"
disallowIrregularRules: "変則なし" disallowIrregularRules: "変則なし"
showBoardLabels: "盤面に行・列番号を表示"
useAvatarAsStone: "石をアイコンにする"
_offlineScreen: _offlineScreen:
title: "オフライン - サーバーに接続できません" title: "オフライン - サーバーに接続できません"

View File

@ -20,7 +20,6 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
@ -60,7 +59,6 @@ export class AccountMoveService {
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private metaService: MetaService, private metaService: MetaService,
private relayService: RelayService, private relayService: RelayService,
private cacheService: CacheService,
private queueService: QueueService, private queueService: QueueService,
) { ) {
} }
@ -84,7 +82,7 @@ export class AccountMoveService {
Object.assign(src, update); Object.assign(src, update);
// Update cache // Update cache
this.cacheService.uriPersonCache.set(srcUri, src); this.globalEventService.publishInternalEvent('localUserUpdated', src);
const srcPerson = await this.apRendererService.renderPerson(src); const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));

View File

@ -128,10 +128,13 @@ export class CacheService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'userChangeSuspendedState': case 'userChangeSuspendedState':
case 'remoteUserUpdated': { case 'userChangeDeletedState':
case 'remoteUserUpdated':
case 'localUserUpdated': {
const user = await this.usersRepository.findOneBy({ id: body.id }); const user = await this.usersRepository.findOneBy({ id: body.id });
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);

View File

@ -393,6 +393,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
return this.emojisRepository.findOneBy({ id }); return this.emojisRepository.findOneBy({ id });
} }
@bindThis
public getEmojiByName(name: string): Promise<MiEmoji | null> {
return this.emojisRepository.findOneBy({ name, host: IsNull() });
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.cache.dispose();

View File

@ -9,6 +9,7 @@ import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@Injectable() @Injectable()
export class DeleteAccountService { export class DeleteAccountService {
@ -18,6 +19,7 @@ export class DeleteAccountService {
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private queueService: QueueService, private queueService: QueueService,
private globalEventService: GlobalEventService,
) { ) {
} }
@ -39,5 +41,7 @@ export class DeleteAccountService {
await this.usersRepository.update(user.id, { await this.usersRepository.update(user.id, {
isDeleted: true, isDeleted: true,
}); });
this.globalEventService.publishInternalEvent('userChangeDeletedState', { id: user.id, isDeleted: true });
} }
} }

View File

@ -209,8 +209,10 @@ type SerializedAll<T> = {
export interface InternalEventTypes { export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; }; remoteUserUpdated: { id: MiUser['id']; };
localUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };

View File

@ -115,12 +115,19 @@ export class PushNotificationService implements OnApplicationShutdown {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
auth: subscription.auth, auth: subscription.auth,
publickey: subscription.publickey, publickey: subscription.publickey,
}).then(() => {
this.refreshCache(userId);
}); });
} }
}); });
} }
} }
@bindThis
public refreshCache(userId: string): void {
this.subscriptionsCache.refresh(userId);
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.subscriptionsCache.dispose(); this.subscriptionsCache.dispose();

View File

@ -30,6 +30,7 @@ import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { ThinUser } from '@/queue/types.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -94,21 +95,35 @@ export class UserFollowingService implements OnModuleInit {
this.userBlockingService = this.moduleRef.get('UserBlockingService'); this.userBlockingService = this.moduleRef.get('UserBlockingService');
} }
@bindThis
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
@bindThis @bindThis
public async follow( public async follow(
_follower: { id: MiUser['id'] }, _follower: ThinUser,
_followee: { id: MiUser['id'] }, _followee: ThinUser,
{ requestId, silent = false, withReplies }: { { requestId, silent = false, withReplies }: {
requestId?: string, requestId?: string,
silent?: boolean, silent?: boolean,
withReplies?: boolean, withReplies?: boolean,
} = {}, } = {},
): Promise<void> { ): Promise<void> {
/**
*
*/
const [follower, followee] = await Promise.all([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }),
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser]; ]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) {
// What?
throw new Error('Remote user cannot follow remote user.');
}
// check blocking // check blocking
const [blocking, blocked] = await Promise.all([ const [blocking, blocked] = await Promise.all([
this.userBlockingService.checkBlocked(follower.id, followee.id), this.userBlockingService.checkBlocked(follower.id, followee.id),
@ -129,6 +144,24 @@ export class UserFollowingService implements OnModuleInit {
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
} }
if (await this.followingsRepository.exists({
where: {
followerId: follower.id,
followeeId: followee.id,
},
})) {
// すでにフォロー関係が存在している場合
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
// リモート → ローカル: acceptを送り返しておしまい
this.deliverAccept(follower, followee, requestId);
return;
}
if (this.userEntityService.isLocalUser(follower)) {
// ローカル → リモート/ローカル: 例外
throw new IdentifiableError('ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced', 'already following');
}
}
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id }); const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
// フォロー対象が鍵アカウントである or // フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
@ -189,8 +222,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, silent, withReplies); await this.insertFollowingDoc(followee, follower, silent, withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); this.deliverAccept(follower, followee, requestId);
this.queueService.deliver(followee, content, follower.inbox, false);
} }
} }
@ -571,8 +603,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, false, request.withReplies); await this.insertFollowingDoc(followee, follower, false, request.withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as MiPartialLocalUser, request.requestId!), followee)); this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined);
this.queueService.deliver(followee, content, follower.inbox, false);
} }
this.userEntityService.pack(followee.id, followee, { this.userEntityService.pack(followee.id, followee, {

View File

@ -85,7 +85,6 @@ export class ApInboxService {
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
private apQuestionService: ApQuestionService, private apQuestionService: ApQuestionService,
private queueService: QueueService, private queueService: QueueService,
private cacheService: CacheService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;

View File

@ -69,4 +69,19 @@ export class NoteReactionEntityService implements OnModuleInit {
} : {}), } : {}),
}; };
} }
@bindThis
public async packMany(
reactions: MiNoteReaction[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
withNote: boolean;
},
): Promise<Packed<'NoteReaction'>[]> {
const opts = Object.assign({
withNote: false,
}, options);
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts)));
}
} }

View File

@ -187,6 +187,10 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { export class MemoryKVCache<T> {
/**
*
* @deprecated
*/
public cache: Map<string, { date: number; value: T; }>; public cache: Map<string, { date: number; value: T; }>;
private lifetime: number; private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout; private gcIntervalHandle: NodeJS.Timeout;
@ -201,6 +205,10 @@ export class MemoryKVCache<T> {
} }
@bindThis @bindThis
/**
* Mapにキャッシュをセットします
* @deprecated InternalEventなどで変更を全てのプロセス/
*/
public set(key: string, value: T): void { public set(key: string, value: T): void {
this.cache.set(key, { this.cache.set(key, {
date: Date.now(), date: Date.now(),

View File

@ -57,7 +57,10 @@ export const paramDef = {
type: 'string', type: 'string',
} }, } },
}, },
required: ['id', 'name', 'aliases'], anyOf: [
{ required: ['id'] },
{ required: ['name'] },
],
} as const; } as const;
@Injectable() @Injectable()
@ -70,27 +73,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let driveFile; let driveFile;
if (ps.fileId) { if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
} }
let emojiId;
if (ps.id) {
emojiId = ps.id;
const emoji = await this.customEmojiService.getEmojiById(ps.id); const emoji = await this.customEmojiService.getEmojiById(ps.id);
if (emoji != null) { if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
if (ps.name !== emoji.name) { if (ps.name && (ps.name !== emoji.name)) {
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
} }
} else { } else {
throw new ApiError(meta.errors.noSuchEmoji); if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
const emoji = await this.customEmojiService.getEmojiByName(ps.name);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
emojiId = emoji.id;
} }
await this.customEmojiService.update(ps.id, { await this.customEmojiService.update(emojiId, {
driveFile, driveFile,
name: ps.name, name: ps.name,
category: ps.category ?? null, category: ps.category,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license ?? null, license: ps.license,
isSensitive: ps.isSensitive, isSensitive: ps.isSensitive,
localOnly: ps.localOnly, localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,

View File

@ -71,7 +71,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean' } withReplies: { type: 'boolean' },
}, },
required: ['userId'], required: ['userId'],
} as const; } as const;
@ -100,22 +100,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}); });
// Check if already following
const exist = await this.followingsRepository.exists({
where: {
followerId: follower.id,
followeeId: followee.id,
},
});
if (exist) {
throw new ApiError(meta.errors.alreadyFollowing);
}
try { try {
await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies }); await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies });
} catch (e) { } catch (e) {
if (e instanceof IdentifiableError) { if (e instanceof IdentifiableError) {
if (e.id === 'ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced') throw new ApiError(meta.errors.alreadyFollowing);
if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking);
if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked);
} }

View File

@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const hashtags = await this.hashtagsRepository.createQueryBuilder('tag') const hashtags = await this.hashtagsRepository.createQueryBuilder('tag')
.where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' }) .where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' })
.orderBy('tag.count', 'DESC') .orderBy('tag.mentionedLocalUsersCount', 'DESC')
.groupBy('tag.id') .groupBy('tag.id')
.limit(ps.limit) .limit(ps.limit)
.offset(ps.offset) .offset(ps.offset)

View File

@ -456,9 +456,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.hashtagService.updateUsertags(user, tags); this.hashtagService.updateUsertags(user, tags);
//#endregion //#endregion
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); if (Object.keys(updates).length > 0) {
if (Object.keys(updates).includes('alsoKnownAs')) { await this.usersRepository.update(user.id, updates);
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
} }
await this.userProfilesRepository.update(user.id, { await this.userProfilesRepository.update(user.id, {

View File

@ -9,6 +9,7 @@ import type { SwSubscriptionsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
private metaService: MetaService, private metaService: MetaService,
private pushNotificationService: PushNotificationService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// if already subscribed // if already subscribed
@ -97,6 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
sendReadMessage: ps.sendReadMessage, sendReadMessage: ps.sendReadMessage,
}); });
this.pushNotificationService.refreshCache(me.id);
return { return {
state: 'subscribed' as const, state: 'subscribed' as const,
key: instance.swPublicKey, key: instance.swPublicKey,

View File

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import type { SwSubscriptionsRepository } from '@/models/_.js'; import type { SwSubscriptionsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
@ -29,12 +30,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.swSubscriptionsRepository) @Inject(DI.swSubscriptionsRepository)
private swSubscriptionsRepository: SwSubscriptionsRepository, private swSubscriptionsRepository: SwSubscriptionsRepository,
private pushNotificationService: PushNotificationService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.swSubscriptionsRepository.delete({ await this.swSubscriptionsRepository.delete({
...(me ? { userId: me.id } : {}), ...(me ? { userId: me.id } : {}),
endpoint: ps.endpoint, endpoint: ps.endpoint,
}); });
if (me) {
this.pushNotificationService.refreshCache(me.id);
}
}); });
} }
} }

View File

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import type { SwSubscriptionsRepository } from '@/models/_.js'; import type { SwSubscriptionsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -58,6 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
@Inject(DI.swSubscriptionsRepository) @Inject(DI.swSubscriptionsRepository)
private swSubscriptionsRepository: SwSubscriptionsRepository, private swSubscriptionsRepository: SwSubscriptionsRepository,
private pushNotificationService: PushNotificationService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const swSubscription = await this.swSubscriptionsRepository.findOneBy({ const swSubscription = await this.swSubscriptionsRepository.findOneBy({
@ -77,6 +80,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
sendReadMessage: swSubscription.sendReadMessage, sendReadMessage: swSubscription.sendReadMessage,
}); });
this.pushNotificationService.refreshCache(me.id);
return { return {
userId: swSubscription.userId, userId: swSubscription.userId,
endpoint: swSubscription.endpoint, endpoint: swSubscription.endpoint,

View File

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true }))); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
}); });
} }
} }

View File

@ -401,7 +401,8 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue') // glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => { (async () => {
const globs = await Promise.all([ const globs = await Promise.all([
glob('src/components/global/*.vue'), glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk{A,B}*.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkDigitalClock.vue'), glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'),

View File

@ -57,18 +57,7 @@ import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
type EmojiDef = {
emoji: string;
name: string;
url: string;
aliasOf?: string;
} | {
emoji: string;
name: string;
aliasOf?: string;
isCustomEmoji?: true;
};
const lib = emojilist.filter(x => x.category !== 'flags'); const lib = emojilist.filter(x => x.category !== 'flags');
@ -249,7 +238,7 @@ function exec() {
return; return;
} }
emojis.value = emojiAutoComplete(props.q, emojiDb.value); emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') { } else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') { if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS; mfmTags.value = MFM_TAGS;
@ -267,87 +256,6 @@ function exec() {
} }
} }
type EmojiScore = { emoji: EmojiDef, score: number };
function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
//
emojiDb.some(x => {
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
//
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
}
//
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
}
//
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
// 3
if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map<string, EmojiScore>();
for (const x of emojiDb) {
//
let pos = 0;
let hit = 0;
for (const c of queryChars) {
pos = x.name.indexOf(c, pos);
if (pos <= -1) break;
hit++;
}
//
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
// 66
[...hitEmojis.values()]
.sort((x, y) => y.score - x.score)
.slice(0, 6)
.forEach(it => matched.set(it.emoji.name, it));
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}
function onMousedown(event: Event) { function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
} }

View File

@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji" :key="emoji"
:data-emoji="emoji" :data-emoji="emoji"
class="_button item" class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)" @click="emit('chosen', emoji, $event)"
> >
@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji" :key="emoji"
:data-emoji="emoji" :data-emoji="emoji"
class="_button item" class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)" @click="emit('chosen', emoji, $event)"
> >
@ -67,6 +69,7 @@ import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{ const props = defineProps<{
emojis: string[] | Ref<string[]>; emojis: string[] | Ref<string[]>;
disabledEmojis?: Ref<string[]>;
initialShown?: boolean; initialShown?: boolean;
hasChildSection?: boolean; hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[]; customEmojiTree?: CustomEmojiFolderTree[];

View File

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="emoji in searchResultCustom" v-for="emoji in searchResultCustom"
:key="emoji.name" :key="emoji.name"
class="_button item" class="_button item"
:disabled="!canReact(emoji)"
:title="emoji.name" :title="emoji.name"
tabindex="0" tabindex="0"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
@ -39,16 +40,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<section v-if="showPinned && (pinned && pinned.length > 0)"> <section v-if="showPinned && (pinned && pinned.length > 0)">
<div class="body"> <div class="body">
<button <button
v-for="emoji in pinned" v-for="emoji in pinnedEmojisDef"
:key="emoji" :key="getKey(emoji)"
:data-emoji="emoji" :data-emoji="getKey(emoji)"
class="_button item" class="_button item"
:disabled="!canReact(emoji)"
tabindex="0" tabindex="0"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button> </button>
</div> </div>
</section> </section>
@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header> <header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div class="body"> <div class="body">
<button <button
v-for="emoji in recentlyUsedEmojis" v-for="emoji in recentlyUsedEmojisDef"
:key="emoji" :key="getKey(emoji)"
class="_button item" class="_button item"
:data-emoji="emoji" :disabled="!canReact(emoji)"
:data-emoji="getKey(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button> </button>
</div> </div>
</section> </section>
@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="child in customEmojiFolderRoot.children" v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.value}`" :key="`custom:${child.value}`"
:initialShown="false" :initialShown="false"
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))" :emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))"
:disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0" :hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children" :customEmojiTree="child.children"
@chosen="chosen" @chosen="chosen"
@ -104,6 +108,7 @@ import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue'; import XSection from '@/components/MkEmojiPicker.section.vue';
import { import {
emojilist, emojilist,
unicodeEmojisMap,
emojiCharByCategory, emojiCharByCategory,
UnicodeEmojiDef, UnicodeEmojiDef,
unicodeEmojiCategories as categories, unicodeEmojiCategories as categories,
@ -146,6 +151,13 @@ const {
recentlyUsedEmojis, recentlyUsedEmojis,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const recentlyUsedEmojisDef = computed(() => {
return recentlyUsedEmojis.value.map(getDef);
});
const pinnedEmojisDef = computed(() => {
return pinned.value?.map(getDef);
});
const pinned = computed(() => props.pinnedEmojis); const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value); const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value); const width = computed(() => emojiPickerWidth.value);
@ -337,14 +349,18 @@ watch(q, () => {
return matches; return matches;
}; };
searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable); searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode()); searchResultUnicode.value = Array.from(searchUnicode());
}); });
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef): boolean {
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji); return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
} }
function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean {
return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category;
}
function focus() { function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({ searchEl.value?.focus({
@ -362,6 +378,14 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
} }
function getDef(emoji: string) {
if (emoji.includes(':')) {
return customEmojisMap.get(emoji.replace(/:/g, ''))!;
} else {
return unicodeEmojisMap.get(emoji)!;
}
}
/** @see MkEmojiPicker.section.vue */ /** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void { function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement; const elm = ev.target as HTMLElement;
@ -526,6 +550,18 @@ defineExpose({
width: auto; width: auto;
height: auto; height: auto;
min-width: 0; min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
} }
} }
} }
@ -548,6 +584,18 @@ defineExpose({
width: auto; width: auto;
height: auto; height: auto;
min-width: 0; min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
} }
} }
} }
@ -663,6 +711,18 @@ defineExpose({
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
} }
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
> .emoji { > .emoji {
height: 1.25em; height: 1.25em;
vertical-align: -.25em; vertical-align: -.25em;

View File

@ -33,7 +33,8 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojisMap } from '@/custom-emojis.js';
import { unicodeEmojisMap } from '@/scripts/emojilist.js';
const props = defineProps<{ const props = defineProps<{
reaction: string; reaction: string;
@ -50,13 +51,11 @@ const emit = defineEmits<{
const buttonEl = shallowRef<HTMLElement>(); const buttonEl = shallowRef<HTMLElement>();
const isCustomEmoji = computed(() => props.reaction.includes(':')); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? unicodeEmojisMap.get(props.reaction));
const canToggle = computed(() => { const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|| !isCustomEmoji.value;
}); });
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));

View File

@ -32,7 +32,8 @@ export const Default = {
async play({ canvasElement }) { async play({ canvasElement }) {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link'); const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/); // FIXME: 通るけどその後落ちるのでコメントアウト
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
await userEvent.pointer({ keys: '[MouseRight]', target: a }); await userEvent.pointer({ keys: '[MouseRight]', target: a });
await tick(); await tick();
const menu = canvas.getByRole('menu'); const menu = canvas.getByRole('menu');
@ -44,6 +45,7 @@ export const Default = {
}, },
args: { args: {
to: '#test', to: '#test',
behavior: 'browser',
}, },
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View File

@ -10,7 +10,7 @@ import MkTime from './MkTime.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { dateTimeFormat } from '@/scripts/intl-const.js'; import { dateTimeFormat } from '@/scripts/intl-const.js';
const now = new Date('2023-04-01T00:00:00.000Z'); const now = new Date('2023-04-01T00:00:00.000Z');
const future = new Date('3000-04-01T00:00:00.000Z'); const future = new Date('2024-04-01T00:00:00.000Z');
const oneHourAgo = new Date(now.getTime() - 3600000); const oneHourAgo = new Date(now.getTime() - 3600000);
const oneDayAgo = new Date(now.getTime() - 86400000); const oneDayAgo = new Date(now.getTime() - 86400000);
const oneWeekAgo = new Date(now.getTime() - 604800000); const oneWeekAgo = new Date(now.getTime() - 604800000);
@ -49,7 +49,7 @@ export const Empty = {
export const RelativeFuture = { export const RelativeFuture = {
...Empty, ...Empty,
async play({ canvasElement }) { async play({ canvasElement }) {
await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 977 })); await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 1 })); // n (1) = future (2024) - now (2023)
}, },
args: { args: {
...Empty.args, ...Empty.args,

View File

@ -7,9 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<div :class="$style.root"> <div :class="$style.root">
<div v-if="!gameLoaded" :class="$style.loadingScreen"> <div v-if="!gameLoaded" :class="$style.loadingScreen">
<div> <div>{{ i18n.ts.loading }}<MkEllipsis/></div>
Loading...
</div>
</div> </div>
<!-- に対してTransitionコンポーネントを使うと何故かkeyを指定していてもキャッシュが効かず様々なコンポーネントが都度再評価されてパフォーマンスが低下する --> <!-- に対してTransitionコンポーネントを使うと何故かkeyを指定していてもキャッシュが効かず様々なコンポーネントが都度再評価されてパフォーマンスが低下する -->
<div v-show="gameLoaded" class="_gaps_s"> <div v-show="gameLoaded" class="_gaps_s">
@ -32,18 +30,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition> </Transition>
<div :class="$style.header"> <div :class="$style.header">
<div :class="[$style.frame, $style.headerTitle]"> <div class="_woodenFrame" :class="[$style.headerTitle]">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<b>BUBBLE GAME</b> <b>{{ i18n.ts.bubbleGame }}</b>
<div>- {{ gameMode }} -</div> <div>- {{ gameMode.toUpperCase() }} -</div>
</div> </div>
</div> </div>
<div :class="[$style.frame, $style.frameH]"> <div class="_woodenFrame _woodenFrameH">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<MkButton inline small @click="hold">HOLD</MkButton> <MkButton inline small @click="hold">{{ i18n.ts._bubbleGame.hold }}</MkButton>
<img v-if="holdingStock" :src="getTextureImageUrl(holdingStock.mono)" style="width: 32px; margin-left: 8px; vertical-align: bottom;"/> <img v-if="holdingStock" :src="getTextureImageUrl(holdingStock.mono)" style="width: 32px; margin-left: 8px; vertical-align: bottom;"/>
</div> </div>
<div :class="[$style.frameInner, $style.stock]" style="text-align: center;"> <div class="_woodenFrameInner" :class="$style.stock" style="text-align: center;">
<TransitionGroup <TransitionGroup
:enterActiveClass="$style.transition_stock_enterActive" :enterActiveClass="$style.transition_stock_enterActive"
:leaveActiveClass="$style.transition_stock_leaveActive" :leaveActiveClass="$style.transition_stock_leaveActive"
@ -90,58 +88,74 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel"> <div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">
<div class="_gaps_s"> <div class="_gaps_s">
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/> <img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
<div>SCORE: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div> <div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
<div>MAX CHAIN: <MkNumber :value="maxCombo"/></div> <div>{{ i18n.ts._bubbleGame._score.maxChain }}: <MkNumber :value="maxCombo"/></div>
<div v-if="gameMode === 'yen'">TOTAL EARNINGS: <b><MkNumber :value="yenTotal ?? score"/></b></div> <div v-if="gameMode === 'yen'">
<div v-if="gameMode === 'sweets'"><b>おにぎり<MkNumber :value="score / 130"/>個分</b></div> {{ i18n.ts._bubbleGame._score.scoreYen }}:
<I18n :src="i18n.ts._bubbleGame._score.yen" tag="b">
<template #yen><MkNumber :value="yenTotal ?? score"/></template>
</I18n>
</div>
<I18n v-if="gameMode === 'sweets'" :src="i18n.ts._bubbleGame._score.scoreSweets" tag="div">
<template #onigiriQtyWithUnit>
<I18n :src="i18n.ts._bubbleGame._score.estimatedQty" tag="b">
<template #qty><MkNumber :value="score / 130"/></template>
</I18n>
</template>
</I18n>
</div> </div>
</div> </div>
<div v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div> <div v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div>
</div> </div>
<div v-if="replaying" :class="$style.frame"> <div v-if="replaying" class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div style="background: #0004;"> <div style="background: #0004;">
<div style="height: 10px; background: var(--accent); will-change: width;" :style="{ width: `${(currentFrame / endedAtFrame) * 100}%` }"></div> <div style="height: 10px; background: var(--accent); will-change: width;" :style="{ width: `${(currentFrame / endedAtFrame) * 100}%` }"></div>
</div> </div>
</div> </div>
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_buttonsCenter"> <div class="_buttonsCenter">
<MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END</MkButton> <MkButton @click="endReplay"><i class="ti ti-player-stop"></i> {{ i18n.ts.endReplay }}</MkButton>
<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton> <MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
<MkButton :primary="replayPlaybackRate === 16" @click="replayPlaybackRate = replayPlaybackRate === 16 ? 1 : 16"><i class="ti ti-player-track-next"></i> x16</MkButton> <MkButton :primary="replayPlaybackRate === 16" @click="replayPlaybackRate = replayPlaybackRate === 16 ? 1 : 16"><i class="ti ti-player-track-next"></i> x16</MkButton>
</div> </div>
</div> </div>
</div> </div>
<div v-if="isGameOver" :class="$style.frame"> <div v-if="isGameOver" class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_buttonsCenter"> <div class="_buttonsCenter">
<MkButton primary rounded @click="backToTitle">{{ i18n.ts.backToTitle }}</MkButton> <MkButton primary rounded @click="backToTitle">{{ i18n.ts.backToTitle }}</MkButton>
<MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</MkButton> <MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</MkButton>
<MkButton primary rounded @click="share">{{ i18n.ts.share }}</MkButton> <MkButton primary rounded @click="share">{{ i18n.ts.share }}</MkButton>
<MkButton rounded @click="exportLog">Copy replay data</MkButton> <MkButton rounded @click="exportLog">{{ i18n.ts.copyReplayData }}</MkButton>
</div> </div>
</div> </div>
</div> </div>
<div style="display: flex;"> <div style="display: flex;">
<div :class="$style.frame" style="flex: 1; margin-right: 10px;"> <div class="_woodenFrame" style="flex: 1; margin-right: 10px;">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div>SCORE: <b><MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</b></div> <div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
<div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/>{{ getScoreUnit(gameMode) }}</b><b v-else>-</b></div> <div>{{ i18n.ts._bubbleGame._score.highScore }}: <b v-if="highScore"><MkNumber :value="highScore"/>{{ getScoreUnit(gameMode) }}</b><b v-else>-</b></div>
<div v-if="gameMode === 'yen'">TOTAL EARNINGS: <b v-if="yenTotal"><MkNumber :value="yenTotal"/></b><b v-else>-</b></div> <div v-if="gameMode === 'yen'">
{{ i18n.ts._bubbleGame._score.scoreYen }}:
<I18n :src="i18n.ts._bubbleGame._score.yen" tag="b">
<template #yen><MkNumber :value="yenTotal ?? score"/></template>
</I18n>
</div> </div>
</div> </div>
<div :class="[$style.frame]" style="margin-left: auto;"> </div>
<div :class="$style.frameInner" style="text-align: center;"> <div class="_woodenFrame" style="margin-left: auto;">
<div class="_woodenFrameInner" style="text-align: center;">
<div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div> <div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="showConfig" :class="$style.frame"> <div v-if="showConfig" class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_gaps"> <div class="_gaps">
<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('bgmVolume', v)"> <MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('bgmVolume', v)">
<template #label>BGM {{ i18n.ts.volume }}</template> <template #label>BGM {{ i18n.ts.volume }}</template>
@ -153,8 +167,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div :class="$style.frame"> <div class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div>FUSION RECIPE</div> <div>FUSION RECIPE</div>
<div> <div>
<div v-for="(mono, i) in game.monoDefinitions.sort((a, b) => a.level - b.level)" :key="mono.id" style="display: inline-block;"> <div v-for="(mono, i) in game.monoDefinitions.sort((a, b) => a.level - b.level)" :key="mono.id" style="display: inline-block;">
@ -165,10 +179,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div :class="$style.frame"> <div class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">Surrender</MkButton> <MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">{{ i18n.ts.surrender }}</MkButton>
<MkButton v-else full @click="restart">Retry</MkButton> <MkButton v-else full @click="restart">{{ i18n.ts.gameRetry }}</MkButton>
</div> </div>
</div> </div>
</div> </div>
@ -1313,38 +1327,6 @@ definePageMetadata(() => ({
max-width: 100%; max-width: 100%;
} }
.frame {
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 10px;
}
.frameH {
display: flex;
gap: 6px;
}
.frameInner {
padding: 8px;
margin-top: 8px;
background: #F1E8DC;
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 6px;
color: #693410;
&:first-child {
margin-top: 0;
}
}
.frameDivider {
height: 0;
border: none;
border-top: 1px solid #693410;
border-bottom: 1px solid #ce8a5c;
}
.header { .header {
position: relative; position: relative;
z-index: 10; z-index: 10;

View File

@ -15,13 +15,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-if="!gameStarted" :contentMax="800"> <MkSpacer v-if="!gameStarted" :contentMax="800">
<div :class="$style.root"> <div :class="$style.root">
<div class="_gaps"> <div class="_gaps">
<div :class="$style.frame" style="text-align: center;"> <div class="_woodenFrame" style="text-align: center;">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div> </div>
</div> </div>
<div :class="$style.frame" style="text-align: center;"> <div class="_woodenFrame" style="text-align: center;">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;"> <div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode"> <MkSelect v-model="gameMode">
<option value="normal">NORMAL</option> <option value="normal">NORMAL</option>
@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton> <MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
</div> </div>
</div> </div>
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;"> <div class="_gaps" style="padding: 16px;">
<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div> <div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
<MkSwitch v-model="mute"> <MkSwitch v-model="mute">
@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<div :class="$style.frame"> <div class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_gaps_s" style="padding: 16px;"> <div class="_gaps_s" style="padding: 16px;">
<div><b>{{ i18n.tsx.lastNDays({ n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode }})</div> <div><b>{{ i18n.tsx.lastNDays({ n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode.toUpperCase() }})</div>
<div v-if="ranking" class="_gaps_s"> <div v-if="ranking" class="_gaps_s">
<div v-for="r in ranking" :key="r.id" :class="$style.rankingRecord"> <div v-for="r in ranking" :key="r.id" :class="$style.rankingRecord">
<MkAvatar :link="true" style="width: 24px; height: 24px; margin-right: 4px;" :user="r.user"/> <MkAvatar :link="true" style="width: 24px; height: 24px; margin-right: 4px;" :user="r.user"/>
@ -57,8 +57,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<div :class="$style.frame"> <div class="_woodenFrame">
<div :class="$style.frameInner" style="padding: 16px;"> <div class="_woodenFrameInner" style="padding: 16px;">
<div style="font-weight: bold;">{{ i18n.ts._bubbleGame.howToPlay }}</div> <div style="font-weight: bold;">{{ i18n.ts._bubbleGame.howToPlay }}</div>
<ol> <ol>
<li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li> <li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li>
@ -67,8 +67,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol> </ol>
</div> </div>
</div> </div>
<div :class="$style.frame"> <div class="_woodenFrame">
<div :class="$style.frameInner"> <div class="_woodenFrameInner">
<div class="_gaps_s" style="padding: 16px;"> <div class="_gaps_s" style="padding: 16px;">
<div><b>Credit</b></div> <div><b>Credit</b></div>
<div> <div>
@ -149,38 +149,6 @@ definePageMetadata(() => ({
} }
} }
.frame {
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 10px;
}
.frameH {
display: flex;
gap: 6px;
}
.frameInner {
padding: 8px;
margin-top: 8px;
background: #F1E8DC;
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 6px;
color: #693410;
&:first-child {
margin-top: 0;
}
}
.frameDivider {
height: 0;
border: none;
border-top: 1px solid #693410;
border-bottom: 1px solid #ce8a5c;
}
.rankingRecord { .rankingRecord {
display: flex; display: flex;
line-height: 24px; line-height: 24px;

View File

@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<div :class="$style.board"> <div class="_woodenFrame">
<div :class="$style.boardInner"> <div :class="$style.boardInner">
<div v-if="showBoardLabels" :class="$style.labelsX"> <div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :key="i" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span> <span v-for="i in game.map[0].length" :key="i" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
@ -124,8 +124,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts.options }}</template> <template #label>{{ i18n.ts.options }}</template>
<div class="_gaps_s" style="text-align: left;"> <div class="_gaps_s" style="text-align: left;">
<MkSwitch v-model="showBoardLabels">Show labels</MkSwitch> <MkSwitch v-model="showBoardLabels">{{ i18n.ts._reversi.showBoardLabels }}</MkSwitch>
<MkSwitch v-model="useAvatarAsStone">useAvatarAsStone</MkSwitch> <MkSwitch v-model="useAvatarAsStone">{{ i18n.ts._reversi.useAvatarAsStone }}</MkSwitch>
</div> </div>
</MkFolder> </MkFolder>
@ -500,17 +500,6 @@ $gap: 4px;
text-align: center; text-align: center;
} }
.board {
width: 100%;
box-sizing: border-box;
margin: 0 auto;
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 12px;
}
.boardInner { .boardInner {
padding: 32px; padding: 32px;

View File

@ -1,6 +1,10 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { UnicodeEmojiDef } from './emojilist.js';
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple): boolean { export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef): boolean {
if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
emoji = emoji as Misskey.entities.EmojiSimple;
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? []; const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
return !(emoji.localOnly && note.user.host !== me.host) return !(emoji.localOnly && note.user.host !== me.host)
&& !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote')) && !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))

View File

@ -20,6 +20,10 @@ export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
category: unicodeEmojiCategories[x[2]], category: unicodeEmojiCategories[x[2]],
})); }));
export const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>(
emojilist.map(x => [x.char, x])
);
const _indexByChar = new Map<string, number>(); const _indexByChar = new Map<string, number>();
const _charGroupByCategory = new Map<string, string[]>(); const _charGroupByCategory = new Map<string, string[]>();
for (let i = 0; i < emojilist.length; i++) { for (let i = 0; i < emojilist.length; i++) {

View File

@ -0,0 +1,101 @@
export type EmojiDef = {
emoji: string;
name: string;
url: string;
aliasOf?: string;
} | {
emoji: string;
name: string;
aliasOf?: string;
isCustomEmoji?: true;
};
type EmojiScore = { emoji: EmojiDef, score: number };
export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 完全一致(エイリアスなし)
emojiDb.some(x => {
if (x.name === query && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 3 });
}
return matched.size === max;
});
// 完全一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
}
// 前方一致(エイリアスなし)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
}
// 前方一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
}
// 部分一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
// 簡易あいまい検索3文字以上
if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map<string, EmojiScore>();
for (const x of emojiDb) {
// 文字列の位置を進めながら、クエリの文字を順番に探す
let pos = 0;
let hit = 0;
for (const c of queryChars) {
pos = x.name.indexOf(c, pos);
if (pos <= -1) break;
hit++;
}
// 半分以上の文字が含まれていればヒットとする
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく6件オートコンプリートのポップアップのサイズ分
[...hitEmojis.values()]
.sort((x, y) => y.score - x.score)
.slice(0, 6)
.forEach(it => matched.set(it.emoji.name, it));
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}

View File

@ -126,7 +126,7 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
*/ */
export function playMisskeySfx(operationType: OperationType) { export function playMisskeySfx(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`]; const sound = defaultStore.state[`sound_${operationType}`];
if (sound.type == null || !canPlay || !navigator.userActivation.hasBeenActive) return; if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
canPlay = false; canPlay = false;
playMisskeySfxFile(sound).finally(() => { playMisskeySfxFile(sound).finally(() => {

View File

@ -417,6 +417,39 @@ rt {
transition-timing-function: cubic-bezier(0,.5,.5,1); transition-timing-function: cubic-bezier(0,.5,.5,1);
} }
._woodenFrame {
padding: 7px;
background: #8C4F26;
box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
border-radius: 10px;
--bg: #F1E8DC;
--panel: #fff;
--fg: #693410;
--switchOffBg: rgba(0, 0, 0, 0.1);
--switchOffFg: rgb(255, 255, 255);
--switchOnBg: var(--accent);
--switchOnFg: rgb(255, 255, 255);
}
._woodenFrameH {
display: flex;
gap: 6px;
}
._woodenFrameInner {
padding: 8px;
margin-top: 8px;
background: var(--bg);
box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
border-radius: 6px;
color: var(--fg);
&:first-child {
margin-top: 0;
}
}
._transition_zoom-enter-active, ._transition_zoom-leave-active { ._transition_zoom-enter-active, ._transition_zoom-leave-active {
transition: opacity 0.5s, transform 0.5s !important; transition: opacity 0.5s, transform 0.5s !important;
} }

View File

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { assert, describe, test } from 'vitest';
import { searchEmoji } from '@/scripts/search-emoji.js';
describe('emoji autocomplete', () => {
test('名前の完全一致は名前の前方一致より優先される', async () => {
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
assert.equal(result[0].emoji, ':foooo:');
});
test('名前の前方一致は名前の部分一致より優先される', async () => {
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
assert.equal(result[0].emoji, ':baaar:');
});
test('名前の完全一致はタグの完全一致より優先される', async () => {
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
assert.equal(result[0].emoji, ':foooo:');
});
test('名前の前方一致はタグの前方一致より優先される', async () => {
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
assert.equal(result[0].emoji, ':foooo:');
});
test('名前の部分一致はタグの部分一致より優先される', async () => {
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
assert.equal(result[0].emoji, ':foooo:');
});
});

View File

@ -7091,13 +7091,13 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
id: string; id?: string;
name: string; name?: string;
/** Format: misskey:id */ /** Format: misskey:id */
fileId?: string; fileId?: string;
/** @description Use `null` to reset the category. */ /** @description Use `null` to reset the category. */
category?: string | null; category?: string | null;
aliases: string[]; aliases?: string[];
license?: string | null; license?: string | null;
isSensitive?: boolean; isSensitive?: boolean;
localOnly?: boolean; localOnly?: boolean;