diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index b4cca8cc1d..2aa2d28a09 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -444,12 +444,12 @@ export class CustomEmojiService implements OnApplicationShutdown { function multipleWordsToQuery( query: string, builder: SelectQueryBuilder, - action: (qb: WhereExpressionBuilder, word: string) => void, + action: (qb: WhereExpressionBuilder, idx: number, word: string) => void, ) { const words = query.split(/\s/); builder.andWhere(new Brackets((qb => { - for (const word of words) { - action(qb, word); + for (const [idx, word] of words.entries()) { + action(qb, idx, word); } }))); } @@ -466,8 +466,8 @@ export class CustomEmojiService implements OnApplicationShutdown { builder.andWhere('emoji.updatedAt <= :updateAtTo', { updateAtTo: q.updatedAtTo }); } if (q.name) { - multipleWordsToQuery(q.name, builder, (qb, word) => { - qb.orWhere('emoji.name LIKE :name', { name: `%${word}%` }); + multipleWordsToQuery(q.name, builder, (qb, idx, word) => { + qb.orWhere(`emoji.name LIKE :name${idx}`, Object.fromEntries([[`name${idx}`, `%${word}%`]])); }); } @@ -479,8 +479,8 @@ export class CustomEmojiService implements OnApplicationShutdown { case q.hostType === 'remote': { if (q.host) { // noIndexScan - multipleWordsToQuery(q.host, builder, (qb, word) => { - qb.orWhere('emoji.host LIKE :host', { host: `%${word}%` }); + multipleWordsToQuery(q.host, builder, (qb, idx, word) => { + qb.orWhere(`emoji.host LIKE :host${idx}`, Object.fromEntries([[`host${idx}`, `%${word}%`]])); }); } else { builder.andWhere('emoji.host IS NOT NULL'); @@ -491,38 +491,38 @@ export class CustomEmojiService implements OnApplicationShutdown { if (q.uri) { // noIndexScan - multipleWordsToQuery(q.uri, builder, (qb, word) => { - qb.orWhere('emoji.uri LIKE :uri', { uri: `%${word}%` }); + multipleWordsToQuery(q.uri, builder, (qb, idx, word) => { + qb.orWhere(`emoji.uri LIKE :uri${idx}`, Object.fromEntries([[`uri${idx}`, `%${word}%`]])); }); } if (q.publicUrl) { // noIndexScan - multipleWordsToQuery(q.publicUrl, builder, (qb, word) => { - qb.orWhere('emoji.publicUrl LIKE :publicUrl', { publicUrl: `%${word}%` }); + multipleWordsToQuery(q.publicUrl, builder, (qb, idx, word) => { + qb.orWhere(`emoji.publicUrl LIKE :publicUrl${idx}`, Object.fromEntries([[`publicUrl${idx}`, `%${word}%`]])); }); } if (q.type) { // noIndexScan - multipleWordsToQuery(q.type, builder, (qb, word) => { - qb.orWhere('emoji.type LIKE :type', { type: `%${word}%` }); + multipleWordsToQuery(q.type, builder, (qb, idx, word) => { + qb.orWhere(`emoji.type LIKE :type${idx}`, Object.fromEntries([[`type${idx}`, `%${word}%`]])); }); } if (q.aliases) { // noIndexScan - multipleWordsToQuery(q.aliases, builder, (qb, word) => { - qb.orWhere('emoji.aliases LIKE :aliases', { aliases: `%${word}%` }); + multipleWordsToQuery(q.aliases, builder, (qb, idx, word) => { + qb.orWhere(`emoji.aliases LIKE :aliases${idx}`, Object.fromEntries([[`aliases${idx}`, `%${word}%`]])); }); } if (q.category) { // noIndexScan - multipleWordsToQuery(q.category, builder, (qb, word) => { - qb.orWhere('emoji.category LIKE :category', { category: `%${word}%` }); + multipleWordsToQuery(q.category, builder, (qb, idx, word) => { + qb.orWhere(`emoji.category LIKE :category${idx}`, Object.fromEntries([[`category${idx}`, `%${word}%`]])); }); } if (q.license) { // noIndexScan - multipleWordsToQuery(q.license, builder, (qb, word) => { - qb.orWhere('emoji.license LIKE :license', { license: `%${word}%` }); + multipleWordsToQuery(q.license, builder, (qb, idx, word) => { + qb.orWhere(`emoji.license LIKE :license${idx}`, Object.fromEntries([[`license${idx}`, `%${word}%`]])); }); } if (q.isSensitive != null) { @@ -533,6 +533,9 @@ export class CustomEmojiService implements OnApplicationShutdown { // noIndexScan builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); } + if (q.roleIds && q.roleIds.length > 0) { + builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction @> :roleIds', { roleIds: q.roleIds }); + } } if (params?.sinceId) { @@ -542,7 +545,7 @@ export class CustomEmojiService implements OnApplicationShutdown { builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); } - if (params?.sort) { + if (params?.sort && params.sort.length > 0) { for (const sort of params.sort) { builder.addOrderBy(`emoji.${sort.key}`, sort.direction); } diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 6515089fe1..490d3f2511 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -97,6 +97,14 @@ export class EmojiEntityService { ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }), ); } + + roles.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); } return { diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue new file mode 100644 index 0000000000..14c8232b02 --- /dev/null +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index ce8778af24..d7cb8d3ae5 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -59,7 +59,6 @@ import * as os from '@/os.js'; import { CellValue, GridCell } from '@/components/grid/cell.js'; import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; import { GridRowSetting } from '@/components/grid/row.js'; -import { selectFile } from '@/scripts/select-file.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; @@ -107,7 +106,7 @@ watch(() => cell.value.selected, () => { function onCellDoubleClick(ev: MouseEvent) { switch (ev.type) { case 'dblclick': { - beginEditing(); + beginEditing(ev.target as HTMLElement); break; } } @@ -127,7 +126,7 @@ function onCellKeyDown(ev: KeyboardEvent) { case 'NumpadEnter': case 'Enter': case 'F2': { - beginEditing(); + beginEditing(ev.target as HTMLElement); break; } } @@ -164,37 +163,47 @@ function unregisterOutsideMouseDown() { removeEventListener('mousedown', onOutsideMouseDown); } -async function beginEditing() { +async function beginEditing(target: HTMLElement) { if (editing.value || !cell.value.column.setting.editable) { return; } - switch (cellType.value) { - case 'text': { - editingValue.value = cell.value.value; - editing.value = true; - registerOutsideMouseDown(); - emit('operation:beginEdit', cell.value); + if (cell.value.column.setting.customValueEditor) { + emit('operation:beginEdit', cell.value); + const newValue = await cell.value.column.setting.customValueEditor( + cell.value.row, + cell.value.column, + cell.value.value, + target, + ); + emit('operation:endEdit', cell.value); - await nextTick(() => { - // inputの展開後にフォーカスを当てたい - if (inputAreaEl.value) { - (inputAreaEl.value.querySelector('*') as HTMLElement).focus(); - } - }); - break; + if (newValue !== cell.value.value) { + emitValueChange(newValue); } - case 'boolean': { - // とくに特殊なUIは設けず、トグルするだけ - emitValueChange(!cell.value.value); - break; - } - case 'image': { - const file = await selectFile(rootEl.value); - if (file) { - emitValueChange(JSON.stringify(file)); + + rootEl.value?.focus(); + } else { + switch (cellType.value) { + case 'text': { + editingValue.value = cell.value.value; + editing.value = true; + registerOutsideMouseDown(); + emit('operation:beginEdit', cell.value); + + await nextTick(() => { + // inputの展開後にフォーカスを当てたい + if (inputAreaEl.value) { + (inputAreaEl.value.querySelector('*') as HTMLElement).focus(); + } + }); + break; + } + case 'boolean': { + // とくに特殊なUIは設けず、トグルするだけ + emitValueChange(!cell.value.value); + break; } - break; } } } diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 36b7d04d3b..549df2a9b3 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -1170,7 +1170,9 @@ function patchData(newItems: DataSource[]) { const newValue = newItem[_col.setting.bindTo]; if (oldCell.value !== newValue) { oldCell.violation = cellValidation(oldCell, newValue); - oldCell.value = newValue; + oldCell.value = _col.setting.valueTransformer + ? _col.setting.valueTransformer(holder.row, _col, newValue) + : newValue; changedCells.push(oldCell); } } @@ -1199,6 +1201,8 @@ function patchData(newItems: DataSource[]) { // #endregion onMounted(() => { + state.value = 'normal'; + const bindToList = columnSettings.map(it => it.bindTo); if (new Set(bindToList).size !== columnSettings.length) { // 取得元のプロパティ名重複は許容したくない diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts index bae4c92347..e76ff98c19 100644 --- a/packages/frontend/src/components/grid/cell.ts +++ b/packages/frontend/src/components/grid/cell.ts @@ -5,7 +5,7 @@ import { GridRow } from '@/components/grid/row.js'; import { MenuItem } from '@/types/menu.js'; import { GridContext } from '@/components/grid/grid-event.js'; -export type CellValue = string | boolean | number | undefined | null +export type CellValue = string | boolean | number | undefined | null | Array | Object; export type CellAddress = { row: number; @@ -41,9 +41,13 @@ export function createCell( value: CellValue, setting: GridCellSetting, ): GridCell { + const newValue = (row.using && column.setting.valueTransformer) + ? column.setting.valueTransformer(row, column, value) + : value; + return { address: { row: row.index, col: column.index }, - value, + value: newValue, column, row, selected: false, diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts index b70ee2fb6c..c1dc4514e4 100644 --- a/packages/frontend/src/components/grid/column.ts +++ b/packages/frontend/src/components/grid/column.ts @@ -8,7 +8,8 @@ import { GridContext } from '@/components/grid/grid-event.js'; export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden'; -export type CellValueConverter = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; +export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise; +export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue; export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[]; export type GridColumnSetting = { @@ -19,7 +20,8 @@ export type GridColumnSetting = { width: SizeStyle; editable?: boolean; validators?: CellValidator[]; - valueConverter?: CellValueConverter; + customValueEditor?: CustomValueEditor; + valueTransformer?: CellValueTransformer; contextMenuFactory?: GridColumnContextMenuFactory; }; diff --git a/packages/frontend/src/components/grid/optin-utils.ts b/packages/frontend/src/components/grid/optin-utils.ts index e730a5c695..0ec70eab71 100644 --- a/packages/frontend/src/components/grid/optin-utils.ts +++ b/packages/frontend/src/components/grid/optin-utils.ts @@ -41,18 +41,22 @@ class OptInGridUtils { } } - copyToClipboard(gridItems: Ref, context: GridContext) { + copyToClipboard(gridItems: Ref | DataSource[], context: GridContext) { + const items = typeof gridItems === 'object' ? (gridItems as Ref).value : gridItems; const lines = Array.of(); const bounds = context.randedBounds; for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { - const items = Array.of(); + const rowItems = Array.of(); for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) { const bindTo = context.columns[col].setting.bindTo; - const cell = gridItems.value[row][bindTo]; - items.push(cell?.toString() ?? ''); + const cell = items[row][bindTo]; + const value = typeof cell === 'object' || Array.isArray(cell) + ? JSON.stringify(cell) + : cell?.toString() ?? ''; + rowItems.push(value); } - lines.push(items.join('\t')); + lines.push(rowItems.join('\t')); } const text = lines.join('\n'); @@ -66,9 +70,12 @@ class OptInGridUtils { async pasteFromClipboard( gridItems: Ref, context: GridContext, + valueConverters?: { bindTo: string, converter: (value: string) => CellValue }[], ) { - function parseValue(value: string, type: GridColumnSetting['type']): CellValue { - switch (type) { + const converterMap = new Map CellValue>(valueConverters?.map(it => [it.bindTo, it.converter]) ?? []); + + function parseValue(value: string, setting: GridColumnSetting): CellValue { + switch (setting.type) { case 'number': { return Number(value); } @@ -76,7 +83,9 @@ class OptInGridUtils { return value === 'true'; } default: { - return value; + return converterMap.has(setting.bindTo) + ? converterMap.get(setting.bindTo)!(value) + : value; } } } @@ -95,7 +104,7 @@ class OptInGridUtils { // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける const ranges = context.rangedCells; for (const cell of ranges) { - gridItems.value[cell.row.index][cell.column.setting.bindTo] = parseValue(lines[0][0], cell.column.setting.type); + gridItems.value[cell.row.index][cell.column.setting.bindTo] = parseValue(lines[0][0], cell.column.setting); } } else { // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける @@ -117,13 +126,13 @@ class OptInGridUtils { break; } - gridItems.value[row][columns[col].setting.bindTo] = parseValue(items[colIdx], columns[col].setting.type); + gridItems.value[row][columns[col].setting.bindTo] = parseValue(items[colIdx], columns[col].setting); } } } } - deleteSelectionRange(gridItems: Ref, context: GridContext) { + deleteSelectionRange(gridItems: Ref[]>, context: GridContext) { if (context.rangedRows.length > 0) { const deletedIndexes = context.rangedRows.map(it => it.index); gridItems.value = gridItems.value.filter((_, index) => !deletedIndexes.includes(index)); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index a4fde6b701..5f8ffe70bd 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -475,6 +475,27 @@ export async function selectDriveFolder(multiple: boolean) { }); } +export async function selectRole(params: { + initialRoleIds?: string[], + title?: string, + infoMessage?: string, + publicOnly?: boolean, +}): Promise< + { canceled: true; result: undefined; } | + { canceled: false; result: Misskey.entities.Role[] } +> { + return new Promise((resolve) => { + popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + done: roles => { + resolve({ canceled: false, result: roles }); + }, + closed: () => { + resolve({ canceled: true, result: undefined }); + }, + }, 'closed'); + }); +} + export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { popup(MkEmojiPickerDialog, { diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue index 306ead4e3a..5e3f1a335d 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.local.list.vue @@ -56,6 +56,18 @@ + + + + @@ -153,6 +165,8 @@ import { deviceKind } from '@/scripts/device-kind.js'; import { GridSetting } from '@/components/grid/grid.js'; import MkTagItem from '@/components/MkTagItem.vue'; import { MenuItem } from '@/types/menu.js'; +import { CellValue } from '@/components/grid/cell.js'; +import { selectFile } from '@/scripts/select-file.js'; type GridItem = { checked: boolean; @@ -165,7 +179,7 @@ type GridItem = { license: string; isSensitive: boolean; localOnly: boolean; - roleIdsThatCanBeUsedThisEmojiAsReaction: string; + roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; fileId?: string; updatedAt: string | null; publicUrl?: string | null; @@ -230,14 +244,54 @@ function setupGrid(): GridSetting { }, cols: [ { bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 }, - { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required] }, + { + bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required], + customValueEditor: async (row, col, value, cellElement) => { + const file = await selectFile(cellElement); + if (file) { + gridItems.value[row.index].url = file.url; + gridItems.value[row.index].fileId = file.id; + } else { + gridItems.value[row.index].url = ''; + gridItems.value[row.index].fileId = undefined; + } + + return file ? file.url : ''; + }, + }, { bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140, validators: [required, regex] }, { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 }, { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 }, { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 }, { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 }, - { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140 }, + { + bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140, + valueTransformer: (row) => { + // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする + return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction + .map(({ name }) => name) + .join(','); + }, + customValueEditor: async (row) => { + // ID直記入は体験的に最悪なのでモーダルを使って入力する + const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id); + const result = await os.selectRole({ + initialRoleIds: current, + title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction, + infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription, + publicOnly: true, + }); + if (result.canceled) { + return current; + } + + const transform = result.result.map(it => ({ id: it.id, name: it.name })); + gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform; + + return transform; + }, + }, { bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' }, { bindTo: 'publicUrl', type: 'text', editable: false, width: 180 }, { bindTo: 'originalUrl', type: 'text', editable: false, width: 180 }, @@ -249,7 +303,9 @@ function setupGrid(): GridSetting { type: 'button', text: '選択範囲をコピー', icon: 'ti ti-copy', - action: () => optInGridUtils.copyToClipboard(gridItems, context), + action: () => { + return optInGridUtils.copyToClipboard(gridItems, context); + }, }, { type: 'button', @@ -286,6 +342,7 @@ const queryUpdatedAtFrom = ref(null); const queryUpdatedAtTo = ref(null); const querySensitive = ref(null); const queryLocalOnly = ref(null); +const queryRoles = ref<{ id: string, name: string }[]>([]); const previousQuery = ref(undefined); const sortOrders = ref([]); const requestLogs = ref([]); @@ -295,6 +352,7 @@ const originGridItems = ref([]); const updateButtonDisabled = ref(false); const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind)); +const queryRolesText = computed(() => queryRoles.value.map(it => it.name).join(',')); async function onUpdateButtonClicked() { const _items = gridItems.value; @@ -334,7 +392,7 @@ async function onUpdateButtonClicked() { license: emptyStrToNull(item.license), isSensitive: item.isSensitive, localOnly: item.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: emptyStrToEmptyArray(item.roleIdsThatCanBeUsedThisEmojiAsReaction), + roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id), fileId: item.fileId, }) .then(() => ({ item, success: true, err: undefined })) @@ -402,6 +460,19 @@ function onGridResetButtonClicked() { refreshGridItems(); } +async function onQueryRolesEditClicked() { + const result = await os.selectRole({ + initialRoleIds: queryRoles.value.map(it => it.id), + title: '絵文字に設定されたロールで検索', + publicOnly: true, + }); + if (result.canceled) { + return; + } + + queryRoles.value = result.result; +} + function onToggleSortOrderButtonClicked(order: GridSortOrder) { console.log(order); switch (order.direction) { @@ -446,6 +517,7 @@ function onQueryResetButtonClicked() { queryUpdatedAtTo.value = null; querySensitive.value = null; queryLocalOnly.value = null; + queryRoles.value = []; } async function onPageChanged(pageNumber: number) { @@ -474,17 +546,27 @@ function onGridCellValidation(event: GridCellValidationEvent) { function onGridCellValueChange(event: GridCellValueChangeEvent) { const { row, column, newValue } = event; if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { - if (column.setting.bindTo === 'url') { - const file = JSON.parse(newValue as string) as Misskey.entities.DriveFile; - gridItems.value[row.index].url = file.url; - gridItems.value[row.index].fileId = file.id; - } else { - gridItems.value[row.index][column.setting.bindTo] = newValue; - } + gridItems.value[row.index][column.setting.bindTo] = newValue; } } async function onGridKeyDown(event: GridKeyDownEvent, currentState: GridContext) { + function roleIdConverter(value: string): CellValue { + try { + const obj = JSON.parse(value); + if (!Array.isArray(obj)) { + return undefined; + } + if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) { + return undefined; + } + + return obj.map(it => ({ id: it.id, name: it.name })); + } catch (ex) { + return undefined; + } + } + const { ctrlKey, shiftKey, code } = event.event; switch (true) { @@ -498,7 +580,13 @@ async function onGridKeyDown(event: GridKeyDownEvent, currentState: GridContext) break; } case 'KeyV': { - await optInGridUtils.pasteFromClipboard(gridItems, currentState); + await optInGridUtils.pasteFromClipboard( + gridItems, + currentState, + [ + { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', converter: roleIdConverter }, + ], + ); break; } } @@ -543,6 +631,7 @@ async function refreshCustomEmojis() { localOnly: queryLocalOnly.value ? Boolean(queryLocalOnly.value).valueOf() : undefined, updatedAtFrom: emptyStrToUndefined(queryUpdatedAtFrom.value), updatedAtTo: emptyStrToUndefined(queryUpdatedAtTo.value), + roleIds: queryRoles.value.map(it => it.id), hostType: 'local', }; @@ -584,7 +673,7 @@ function refreshGridItems() { license: it.license ?? '', isSensitive: it.isSensitive, localOnly: it.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction.join(','), + roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction, updatedAt: it.updatedAt, publicUrl: it.publicUrl, originalUrl: it.originalUrl, diff --git a/packages/frontend/src/pages/admin/custom-emojis-grid.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-grid.local.register.vue index dff3a417fb..37e4672e2e 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-grid.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-grid.local.register.vue @@ -116,7 +116,7 @@ type GridItem = { license: string; isSensitive: boolean; localOnly: boolean; - roleIdsThatCanBeUsedThisEmojiAsReaction: string; + roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; } function setupGrid(): GridSetting { @@ -159,7 +159,33 @@ function setupGrid(): GridSetting { { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 }, { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 }, { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 }, - { bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 100 }, + { + bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140, + valueTransformer: (row) => { + // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする + return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction + .map(({ name }) => name) + .join(','); + }, + customValueEditor: async (row) => { + // ID直記入は体験的に最悪なのでモーダルを使って入力する + const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id); + const result = await os.selectRole({ + initialRoleIds: current, + title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction, + infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription, + publicOnly: true, + }); + if (result.canceled) { + return current; + } + + const transform = result.result.map(it => ({ id: it.id, name: it.name })); + gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform; + + return transform; + }, + }, ], cells: { contextMenuFactory: (col, row, value, context) => { @@ -214,7 +240,7 @@ async function onRegistryClicked() { license: emptyStrToNull(item.license), isSensitive: item.isSensitive, localOnly: item.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: emptyStrToEmptyArray(item.roleIdsThatCanBeUsedThisEmojiAsReaction), + roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id), fileId: item.fileId!, }) .then(() => ({ item, success: true, err: undefined })) @@ -372,7 +398,7 @@ function fromDriveFile(it: Misskey.entities.DriveFile): GridItem { license: '', isSensitive: it.isSensitive, localOnly: false, - roleIdsThatCanBeUsedThisEmojiAsReaction: '', + roleIdsThatCanBeUsedThisEmojiAsReaction: [], }; }