@@ -6,22 +6,35 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js' ;
import type { DriveFile } from '@/models/entities/DriveFile.js' ;
import type { Emoji } from '@/models/entities/Emoji.js' ;
import type { EmojisRepository } from '@/models/index.js' ;
import type { EmojisRepository , Note } from '@/models/index.js' ;
import { bindThis } from '@/decorators.js' ;
import { Cache } from '@/misc/cache.js' ;
import { UtilityService } from '@/core/UtilityService.js' ;
import type { Config } from '@/config.js' ;
import { ReactionService } from '@/core/ReactionService.js' ;
import { query } from '@/misc/prelude/url.js' ;
@Injectable ( )
export class CustomEmojiService {
private cache : Cache < Emoji | null > ;
constructor (
@Inject ( DI . config )
private config : Config ,
@Inject ( DI . db )
private db : DataSource ,
@Inject ( DI . emojisRepository )
private emojisRepository : EmojisRepository ,
private utilityService : UtilityService ,
private idService : IdService ,
private emojiEntityService : EmojiEntityService ,
private globalEventService : GlobalEventService ,
private reactionService : ReactionService ,
) {
this . cache = new Cache < Emoji | null > ( 1000 * 60 * 60 * 12 ) ;
}
@bindThis
@@ -54,4 +67,121 @@ export class CustomEmojiService {
return emoji ;
}
@bindThis
private normalizeHost ( src : string | undefined , noteUserHost : string | null ) : string | null {
// クエリに使うホスト
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
: this . utilityService . isSelfHost ( src ) ? null // 自ホスト指定
: ( src || noteUserHost ) ; // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
host = this . utilityService . toPunyNullable ( host ) ;
return host ;
}
@bindThis
private parseEmojiStr ( emojiName : string , noteUserHost : string | null ) {
const match = emojiName . match ( /^(\w+)(?:@([\w.-]+))?$/ ) ;
if ( ! match ) return { name : null , host : null } ;
const name = match [ 1 ] ;
// ホスト正規化
const host = this . utilityService . toPunyNullable ( this . normalizeHost ( match [ 2 ] , noteUserHost ) ) ;
return { name , host } ;
}
/**
* 添付用(リモート)カスタム絵文字URLを解決する
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
* @returns URL, nullは未マッチを意味する
*/
@bindThis
public async populateEmoji ( emojiName : string , noteUserHost : string | null ) : Promise < string | null > {
const { name , host } = this . parseEmojiStr ( emojiName , noteUserHost ) ;
if ( name == null ) return null ;
if ( host == null ) return null ;
const queryOrNull = async ( ) = > ( await this . emojisRepository . findOneBy ( {
name ,
host : host ? ? IsNull ( ) ,
} ) ) ? ? null ;
const emoji = await this . cache . fetch ( ` ${ name } ${ host } ` , queryOrNull ) ;
if ( emoji == null ) return null ;
const isLocal = emoji . host == null ;
const emojiUrl = emoji . publicUrl || emoji . originalUrl ; // || emoji.originalUrl してるのは後方互換性のため( publicUrlはstringなので??はだめ)
const url = isLocal ? emojiUrl : ` ${ this . config . url } /proxy/ ${ encodeURIComponent ( ( new URL ( emojiUrl ) ) . pathname ) } ? ${ query ( { url : emojiUrl } )} ` ;
return url ;
}
/**
* 複数の添付用(リモート)カスタム絵文字URLを解決する (キャシュ付き, 存在しないものは結果から除外される)
*/
@bindThis
public async populateEmojis ( emojiNames : string [ ] , noteUserHost : string | null ) : Promise < Record < string , string > > {
const emojis = await Promise . all ( emojiNames . map ( x = > this . populateEmoji ( x , noteUserHost ) ) ) ;
const res = { } as any ;
for ( let i = 0 ; i < emojiNames . length ; i ++ ) {
if ( emojis [ i ] != null ) {
res [ emojiNames [ i ] ] = emojis [ i ] ;
}
}
return res ;
}
@bindThis
public aggregateNoteEmojis ( notes : Note [ ] ) {
let emojis : { name : string | null ; host : string | null ; } [ ] = [ ] ;
for ( const note of notes ) {
emojis = emojis . concat ( note . emojis
. map ( e = > this . parseEmojiStr ( e , note . userHost ) ) ) ;
if ( note . renote ) {
emojis = emojis . concat ( note . renote . emojis
. map ( e = > this . parseEmojiStr ( e , note . renote ! . userHost ) ) ) ;
if ( note . renote . user ) {
emojis = emojis . concat ( note . renote . user . emojis
. map ( e = > this . parseEmojiStr ( e , note . renote ! . userHost ) ) ) ;
}
}
const customReactions = Object . keys ( note . reactions ) . map ( x = > this . reactionService . decodeReaction ( x ) ) . filter ( x = > x . name != null ) as typeof emojis ;
emojis = emojis . concat ( customReactions ) ;
if ( note . user ) {
emojis = emojis . concat ( note . user . emojis
. map ( e = > this . parseEmojiStr ( e , note . userHost ) ) ) ;
}
}
return emojis . filter ( x = > x . name != null && x . host != null ) as { name : string ; host : string ; } [ ] ;
}
/**
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
*/
@bindThis
public async prefetchEmojis ( emojis : { name : string ; host : string | null ; } [ ] ) : Promise < void > {
const notCachedEmojis = emojis . filter ( emoji = > this . cache . get ( ` ${ emoji . name } ${ emoji . host } ` ) == null ) ;
const emojisQuery : any [ ] = [ ] ;
const hosts = new Set ( notCachedEmojis . map ( e = > e . host ) ) ;
for ( const host of hosts ) {
if ( host == null ) continue ;
emojisQuery . push ( {
name : In ( notCachedEmojis . filter ( e = > e . host === host ) . map ( e = > e . name ) ) ,
host : host ,
} ) ;
}
const _emojis = emojisQuery . length > 0 ? await this . emojisRepository . find ( {
where : emojisQuery ,
select : [ 'name' , 'host' , 'originalUrl' , 'publicUrl' ] ,
} ) : [ ] ;
for ( const emoji of _emojis ) {
this . cache . set ( ` ${ emoji . name } ${ emoji . host } ` , emoji ) ;
}
}
}